From c4c7cc025313851a1dd23beb8653a6a4342d39b0 Mon Sep 17 00:00:00 2001 From: tooomm Date: Thu, 22 Oct 2026 20:13:43 +0200 Subject: [PATCH 001/108] [README] Small tweaks (#7585) --- README.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index f2a170868..fa9272c0f 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,10 @@ ---

-To get started with Cockatrice ⇢ [view our webpage](https://cockatrice.github.io/)
-To get support, or suggest changes to the app ⇢ [file an issue](https://github.com/Cockatrice/Cockatrice/issues) ([How?](https://github.com/Cockatrice/Cockatrice/wiki/How-to-Create-a-GitHub-Ticket-Regarding-Cockatrice)) -To help with development ⇢ learn [how to contribute](#contribute-) +To get started with Cockatrice ⇢ [view our webpage](https://cockatrice.github.io/) +To get support, or suggest changes to the app ⇢ [file an issue](https://github.com/Cockatrice/Cockatrice/issues) ([How?](https://github.com/Cockatrice/Cockatrice/wiki/How-to-Create-a-GitHub-Ticket-Regarding-Cockatrice))
+To learn about the app and its code ⇢ [read our code documentation](https://cockatrice.github.io/docs/) +To help with development ⇢ learn [how to contribute](#contribute)


@@ -83,7 +84,7 @@ Join our [Discord community](https://discord.gg/3Z9yzmA) to connect with other p ### Code -To contribute code to the project, please review our [guidelines](https://github.com/Cockatrice/Cockatrice/blob/master/.github/CONTRIBUTING.md) first.
+To commit code to the project, please review our [Contribution Guidelines](https://github.com/Cockatrice/Cockatrice/blob/master/.github/CONTRIBUTING.md) first.
We maintain two tags for contributors to easier find issues to potentially work on: - [![Good first issue](https://img.shields.io/github/issues/cockatrice/cockatrice/Good%20First%20Issue)](https://github.com/Cockatrice/Cockatrice/issues?utf8=%E2%9C%93&q=is%3Aopen%20is%3Aissue%20label%3A%22Good%20first%20issue%22%20)
Issues tagged in this way provide a simple way to get started. They don't require much experience to be worked on. @@ -95,7 +96,7 @@ We'll happily advice on how best to implement a feature, or we can show you wher You can also have a look at our `Todo List` in our [Code Documentation](https://cockatrice.github.io/docs) or search the repo for [`\todo` comments](https://github.com/search?q=repo%3ACockatrice%2FCockatrice%20%5Ctodo&type=code). -### Documentation [![CI Docs](https://github.com/Cockatrice/Cockatrice/actions/workflows/documentation-build.yml/badge.svg?event=push)](https://github.com/Cockatrice/Cockatrice/actions/workflows/documentation-build.yml?query=event%3Apush) +### Documentation [![CI Docs](https://github.com/Cockatrice/Cockatrice/actions/workflows/documentation-build.yml/badge.svg?event=push)](https://github.com/Cockatrice/Cockatrice/actions/workflows/documentation-build.yml?query=event%3Arelease+event%3Aworkflow_dispatch) There are various places where useful information for different needs are maintained: - [Official Code Documentation](https://cockatrice.github.io/docs/) @@ -104,7 +105,8 @@ There are various places where useful information for different needs are mainta - [Official README](https://github.com/Cockatrice/Cockatrice/blob/master/README.md) `This file` - [Official CONTRIBUTING](https://github.com/Cockatrice/Cockatrice/blob/master/.github/CONTRIBUTING.md) -Cockatrice tries to use the [Google Developer Documentation Style Guide](https://developers.google.com/style/) to ensure consistent documentation. We encourage you to improve the documentation by suggesting edits based on this guide. +Cockatrice tries to use the [Google Developer Documentation Style Guide](https://developers.google.com/style/) to ensure consistent documentation. +We encourage everybody to improve the documentation by suggesting edits based on this guide. ### Translation [![Transifex Project](https://img.shields.io/badge/translate-on%20transifex-brightgreen)](https://explore.transifex.com/cockatrice/cockatrice/) From e666a4978d706bf70e7e24e41c41816c352cf369 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:33:29 +0200 Subject: [PATCH 002/108] [VisualDatabase] Use scoped QSignalBlockers for filter updates (#7641) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [VisualDatabase] Use scoped QSignalBlockers for filter updates updateFilterModel() blocked signals on the filter model and its filter tree,\nthen unblocked each of them twice. The second pair was a no-op only because\nthe first pair had already restored the default. Use two scoped\nQSignalBlockers instead, so the models are unblocked exactly once before the\nexplicit changed()/layoutChanged() notifications.\n\nVerification: cockatrice built. * Drop stale typo note from QSignalBlocker comment --------- Co-authored-by: Lukas Brübach --- ...al_database_display_name_filter_widget.cpp | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_name_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_name_filter_widget.cpp index d3e497b21..3e4e8bcc0 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_name_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_name_filter_widget.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -139,20 +140,19 @@ void VisualDatabaseDisplayNameFilterWidget::updateFilterModel() emit filterModel->layoutAboutToBeChanged(); filterModel->clearFiltersOfType(CardFilter::Attr::AttrNameExact); - filterModel->blockSignals(true); - filterModel->filterTree()->blockSignals(true); + { + // Scope the blockers so both models are unblocked before the explicit + // change notifications below. + const QSignalBlocker modelBlocker(filterModel); + const QSignalBlocker treeBlocker(filterModel->filterTree()); - for (const auto &name : activeFilters.keys()) { - QString nameString = name; - filterModel->addFilter(new CardFilter(nameString, CardFilter::Type::TypeOr, CardFilter::Attr::AttrNameExact)); + for (const auto &name : activeFilters.keys()) { + QString nameString = name; + filterModel->addFilter( + new CardFilter(nameString, CardFilter::Type::TypeOr, CardFilter::Attr::AttrNameExact)); + } } - filterModel->blockSignals(false); - filterModel->filterTree()->blockSignals(false); - - filterModel->blockSignals(false); - filterModel->filterTree()->blockSignals(false); - emit filterModel->filterTree()->changed(); emit filterModel->layoutChanged(); } From 4980f88a7a64dc7d2da7457685b5618bc444607f Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:34:13 +0200 Subject: [PATCH 003/108] [CardDB] Sort power/toughness without splitting every comparison (#7607) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [CardDB] Sort power/toughness without splitting every comparison When the card database is sorted by the P/T column, lessThan() runs O(n log n) times over the whole view, and each call split both strings into a QStringList ("power/toughness") and then read the two elements back. For the roughly 36k cards in the database that is millions of list and string allocations, all for a value that contains at most one separator. The comparator now locates the single '/' with indexOf and passes the two halves as QStringView to lessThanNumerically(), which is changed from QString to QStringView. The numeric parsing, the "1+*" fallback and the locale-aware tie-break are unchanged, and strings with no separator or more than one still fall through to the plain string comparison, exactly as split()-based size checks did before. lessThanNumerically() also initializes its two bool out-parameters; they were declared uninitialized, which only worked because toFloat() always assigns them. Compared old and new comparator over a corpus of 2024 power/toughness strings (4096576 ordered pairs, including malformed values): zero differences. Full build and ctest (628 tests) pass. * Extract well-formedness checks in P/T comparator --------- Co-authored-by: Lukas Brübach --- .../database/card_database_display_model.cpp | 49 ++++++++++--------- .../database/card_database_display_model.h | 3 +- 2 files changed, 29 insertions(+), 23 deletions(-) diff --git a/libcockatrice_models/libcockatrice/models/database/card_database_display_model.cpp b/libcockatrice_models/libcockatrice/models/database/card_database_display_model.cpp index 39bb8314c..c333f5506 100644 --- a/libcockatrice_models/libcockatrice/models/database/card_database_display_model.cpp +++ b/libcockatrice_models/libcockatrice/models/database/card_database_display_model.cpp @@ -82,13 +82,12 @@ int CardDatabaseDisplayModel::rowCount(const QModelIndex &parent) const bool CardDatabaseDisplayModel::lessThan(const QModelIndex &left, const QModelIndex &right) const { - - QString leftString = sourceModel()->data(left, CardDatabaseModel::SortRole).toString(); - QString rightString = sourceModel()->data(right, CardDatabaseModel::SortRole).toString(); + const QString leftString = sourceModel()->data(left, CardDatabaseModel::SortRole).toString(); + const QString rightString = sourceModel()->data(right, CardDatabaseModel::SortRole).toString(); if (!cardName.isEmpty() && left.column() == CardDatabaseModel::NameColumn) { - bool isLeftType = leftString.startsWith(cardName, Qt::CaseInsensitive); - bool isRightType = rightString.startsWith(cardName, Qt::CaseInsensitive); + const bool isLeftType = leftString.startsWith(cardName, Qt::CaseInsensitive); + const bool isRightType = rightString.startsWith(cardName, Qt::CaseInsensitive); // test for an exact match: isLeftType && leftString.size() == cardName.size() // or an exclusive start match: isLeftType && !isRightType @@ -101,31 +100,37 @@ bool CardDatabaseDisplayModel::lessThan(const QModelIndex &left, const QModelInd return false; } } else if (right.column() == CardDatabaseModel::PTColumn && left.column() == CardDatabaseModel::PTColumn) { - QStringList leftList = leftString.split("/"); - QStringList rightList = rightString.split("/"); + // The comparator runs O(n log n) times while the view is sorted, so + // splitting every power/toughness string into a QStringList allocates + // a lot for data that has exactly one separator. Work on views and + // only split the two halves out when both strings are well formed. + const QStringView leftPT = leftString; + const QStringView rightPT = rightString; + const qsizetype leftSlash = leftPT.indexOf(u'/'); + const qsizetype rightSlash = rightPT.indexOf(u'/'); + const bool leftIsWellFormed = leftSlash >= 0 && leftPT.indexOf(u'/', leftSlash + 1) < 0; + const bool rightIsWellFormed = rightSlash >= 0 && rightPT.indexOf(u'/', rightSlash + 1) < 0; - if (leftList.size() == 2 && rightList.size() == 2) { - - // cool, have both P/T in list now - int lessThanNum = lessThanNumerically(leftList.at(0), rightList.at(0)); - if (lessThanNum != 0) { - return lessThanNum < 0; - } else { - // power equal, check toughness - return lessThanNumerically(leftList.at(1), rightList.at(1)) < 0; + if (leftIsWellFormed && rightIsWellFormed) { + const int powerComparison = lessThanNumerically(leftPT.left(leftSlash), rightPT.left(rightSlash)); + if (powerComparison != 0) { + return powerComparison < 0; } + // power equal, check toughness + return lessThanNumerically(leftPT.mid(leftSlash + 1), rightPT.mid(rightSlash + 1)) < 0; } } return QString::localeAwareCompare(leftString, rightString) < 0; } -int CardDatabaseDisplayModel::lessThanNumerically(const QString &left, const QString &right) +int CardDatabaseDisplayModel::lessThanNumerically(QStringView left, QStringView right) { if (left == right) { return 0; } - bool okLeft, okRight; + bool okLeft = false; + bool okRight = false; float leftNum = left.toFloat(&okLeft); float rightNum = right.toFloat(&okRight); @@ -139,11 +144,11 @@ int CardDatabaseDisplayModel::lessThanNumerically(const QString &left, const QSt } } // try and parsing again, for weird ones like "1+*" - QString leftAfterNum = ""; - QString rightAfterNum = ""; + QStringView leftAfterNum; + QStringView rightAfterNum; if (!okLeft) { int leftNumIndex = 0; - for (; leftNumIndex < left.length(); leftNumIndex++) { + for (; leftNumIndex < left.size(); leftNumIndex++) { if (!left.at(leftNumIndex).isDigit()) { break; } @@ -155,7 +160,7 @@ int CardDatabaseDisplayModel::lessThanNumerically(const QString &left, const QSt } if (!okRight) { int rightNumIndex = 0; - for (; rightNumIndex < right.length(); rightNumIndex++) { + for (; rightNumIndex < right.size(); rightNumIndex++) { if (!right.at(rightNumIndex).isDigit()) { break; } diff --git a/libcockatrice_models/libcockatrice/models/database/card_database_display_model.h b/libcockatrice_models/libcockatrice/models/database/card_database_display_model.h index d12dd1fb4..77152a390 100644 --- a/libcockatrice_models/libcockatrice/models/database/card_database_display_model.h +++ b/libcockatrice_models/libcockatrice/models/database/card_database_display_model.h @@ -14,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -88,7 +89,7 @@ signals: protected: [[nodiscard]] bool lessThan(const QModelIndex &left, const QModelIndex &right) const override; - static int lessThanNumerically(const QString &left, const QString &right); + static int lessThanNumerically(QStringView left, QStringView right); [[nodiscard]] bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override; [[nodiscard]] bool rowMatchesCardName(CardInfoPtr info) const; From f4b4365464cce72b69d929d318e49025753f5a53 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:23:04 +0200 Subject: [PATCH 004/108] [Search] Stop allocating per card in card-name completion (#7604) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Search] Stop allocating per card in card-name completion CardSearchModel::updateSearchResults() runs on every textEdited() signal, walks the whole card database and runs Levenshtein against every matching name. Three costs dominated that path: 1. Every candidate built a QStringList of its searchable names, then lowercased each name into another QString, only to break out of the loop after the first hit. 2. The sort comparator called sortableName() for each comparison, which re-fetched the name (QMap lookup plus QString construction) O(n log n) times. 3. levenshteinDistance() allocated a full len1 x len2 vector of vectors. The search model now lowercases the one name it tests directly, stores the tie-breaking name length once per result, and the comparator only compares integers. levenshteinDistance() uses the standard two-row formulation: same result, O(len2) memory instead of O(len1*len2), with early exits for empty and equal strings. FilterString query closures get the same treatment: - Set queries iterate the set map directly instead of materializing keys()/values() lists for every card. - Rarity queries iterate printings in place instead of copying them into a QList first. - Power/toughness split the string with indexOf and compare QStringViews instead of allocating a QStringList. - Field queries do one hash lookup instead of contains() followed by value(). - Color queries bind the colors string by reference instead of copying. Verified with filter_string_test (42 tests) and a full client build. * [Search] Address review comments - Use std::any_of/std::none_of over the set map's key/value iterators instead of hand-rolled loops. - Extract the name matching into matchingLowercaseName() returning std::optional. - Reuse the matched name's length for the distance tie-break instead of looking the name up again, removing one getLocalizedName() call per card. - Take the color strings by value; the getters already return by value. --------- Co-authored-by: Lukas Brübach --- .../libcockatrice/filters/filter_string.cpp | 64 ++++++++++-------- .../database/card/card_search_model.cpp | 66 ++++++++++--------- .../models/database/card/card_search_model.h | 18 +++-- .../libcockatrice/utility/levenshtein.cpp | 41 ++++++++---- 4 files changed, 113 insertions(+), 76 deletions(-) diff --git a/libcockatrice_filters/libcockatrice/filters/filter_string.cpp b/libcockatrice_filters/libcockatrice/filters/filter_string.cpp index 0acc0eca0..081ada824 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_string.cpp +++ b/libcockatrice_filters/libcockatrice/filters/filter_string.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -161,30 +162,28 @@ static void setupParserRules() if (sv.choice() == 0) { auto matcher = std::any_cast(sv[0]); return [=](const CardData &x) -> bool { - QList sets = x->getSets().keys(); - - auto matchesSet = [&matcher](const QString &set) { return matcher(set); }; - return std::any_of(sets.begin(), sets.end(), matchesSet); + const auto &sets = x->getSets(); + return std::any_of(sets.keyValueBegin(), sets.keyValueEnd(), + [&matcher](const auto &set) { return matcher(set.first); }); }; } if (sv.choice() == 1) { auto matcher = std::any_cast(sv[0]); return [=](const CardData &x) -> bool { - QList sets = x->getSets().keys(); - - auto matchesSet = [&matcher](const QString &set) { return matcher(set); }; - return std::none_of(sets.begin(), sets.end(), matchesSet); + const auto &sets = x->getSets(); + return std::none_of(sets.keyValueBegin(), sets.keyValueEnd(), + [&matcher](const auto &set) { return matcher(set.first); }); }; } auto matcher = std::any_cast(sv[0]); return [=](const CardData &x) -> bool { - const auto &sets = x->getSets().values(); - auto matchesSet = [&](const PrintingInfo &printing) { - return printing.getSet()->getEnabled() && matcher(printing.getSet()->getReleaseDate().toJulianDay()); - }; - return std::any_of(sets.begin(), sets.end(), [&](const auto &printings) { - return std::any_of(printings.begin(), printings.end(), matchesSet); + const auto &sets = x->getSets(); + return std::any_of(sets.constBegin(), sets.constEnd(), [&matcher](const auto &printings) { + return std::any_of(printings.begin(), printings.end(), [&matcher](const PrintingInfo &printing) { + const CardSetPtr set = printing.getSet(); + return set && set->getEnabled() && matcher(set->getReleaseDate().toJulianDay()); + }); }); }; }; @@ -207,15 +206,14 @@ static void setupParserRules() search["RarityQuery"] = [](const peg::SemanticValues &sv) -> Filter { const auto rarity = std::any_cast(sv[0]); return [=](const CardData &x) -> bool { - QList infos; for (const auto &printings : x->getSets()) { for (const auto &printing : printings) { - infos.append(printing); + if (rarity == printing.getProperty("rarity")) { + return true; + } } } - - auto matchesRarity = [&rarity](const PrintingInfo &info) { return rarity == info.getProperty("rarity"); }; - return std::any_of(infos.begin(), infos.end(), matchesRarity); + return false; }; }; @@ -411,7 +409,7 @@ static void setupParserRules() const bool identity = sv.tokens[0].empty() || sv.tokens[0][0] != 'i'; if (sv.tokens[1][0] == ':') { return [=](const CardData &x) { - QString match = identity ? x->getColors() : x->getProperty("coloridentity"); + const QString match = identity ? x->getColors() : x->getProperty("coloridentity"); if (parts.contains("m") && match.length() < 2) { return false; } @@ -429,7 +427,7 @@ static void setupParserRules() } return [=](const CardData &x) { - QString match = identity ? x->getColors() : x->getProperty("colorIdentity"); + const QString match = identity ? x->getColors() : x->getProperty("colorIdentity"); if (parts.contains("m") && match.length() < 2) { return false; } @@ -455,25 +453,37 @@ static void setupParserRules() }; search["PowerQuery"] = [](const peg::SemanticValues &sv) -> Filter { const auto matcher = std::any_cast(sv[0]); - return [=](const CardData &x) -> bool { return matcher(x->getPowTough().split("/")[0].toInt()); }; + return [=](const CardData &x) -> bool { + const QString &powTough = x->getPowTough(); + const qsizetype slash = powTough.indexOf('/'); + return matcher(slash < 0 ? powTough.toInt() : QStringView(powTough).left(slash).toInt()); + }; }; search["ToughnessQuery"] = [](const peg::SemanticValues &sv) -> Filter { const auto matcher = std::any_cast(sv[0]); return [=](const CardData &x) -> bool { - auto parts = x->getPowTough().split("/"); - return matcher(parts.length() == 2 ? parts[1].toInt() : 0); + const QString &powTough = x->getPowTough(); + const qsizetype slash = powTough.indexOf('/'); + return matcher(slash < 0 ? 0 : QStringView(powTough).mid(slash + 1).toInt()); }; }; search["FieldQuery"] = [](const peg::SemanticValues &sv) -> Filter { const auto field = std::any_cast(sv[0]); if (sv.choice() == 0) { const auto matcher = std::any_cast(sv[1]); - return [=](const CardData &x) -> bool { return x->hasProperty(field) && matcher(x->getProperty(field)); }; + return [=](const CardData &x) -> bool { + const auto &properties = x->getPropertiesHash(); + const auto it = properties.constFind(field); + return it != properties.constEnd() && matcher(it.value()); + }; } const auto matcher = std::any_cast(sv[1]); - return - [=](const CardData &x) -> bool { return x->hasProperty(field) && matcher(x->getProperty(field).toInt()); }; + return [=](const CardData &x) -> bool { + const auto &properties = x->getPropertiesHash(); + const auto it = properties.constFind(field); + return it != properties.constEnd() && matcher(it.value().toInt()); + }; }; search["GenericQuery"] = [](const peg::SemanticValues &sv) -> Filter { const auto matcher = std::any_cast(sv[0]); diff --git a/libcockatrice_models/libcockatrice/models/database/card/card_search_model.cpp b/libcockatrice_models/libcockatrice/models/database/card/card_search_model.cpp index 5bde6e3fc..8a267ed7f 100644 --- a/libcockatrice_models/libcockatrice/models/database/card/card_search_model.cpp +++ b/libcockatrice_models/libcockatrice/models/database/card/card_search_model.cpp @@ -9,6 +9,7 @@ #include #include #include +#include CardSearchModel::CardSearchModel(CardDatabaseDisplayModel *sourceModel, QObject *parent) : QAbstractListModel(parent), sourceModel(sourceModel) @@ -69,31 +70,28 @@ void CardSearchModel::updateSearchResults(const QString &query) continue; } - // The completer suggestions match against the same languages the card - // search uses, so typing a localized name finds the card. In Both mode - // either language can match. - for (const QString &matchName : searchableNames(card)) { - const QString lowerName = matchName.toLower(); - if (!lowerName.contains(lowerQuery)) { - continue; - } + const std::optional lowerName = matchingLowercaseName(card, lowerQuery); + if (!lowerName) { + continue; + } - const int distance = levenshteinDistance(lowerQuery, lowerName); + const int distance = levenshteinDistance(lowerQuery, *lowerName); + // Length of the matched name, used to break distance ties. Stored here + // so the comparator does not look the name up again for every comparison. + const int nameLength = static_cast(lowerName->size()); - if (lowerName.startsWith(lowerQuery)) { - prefixMatches.append({card, distance}); - } else { - containsMatches.append({card, distance}); - } - break; + if (lowerName->startsWith(lowerQuery)) { + prefixMatches.append({card, distance, nameLength}); + } else { + containsMatches.append({card, distance, nameLength}); } } - auto sortByDistanceThenLength = [this](const SearchResult &a, const SearchResult &b) { + auto sortByDistanceThenLength = [](const SearchResult &a, const SearchResult &b) { if (a.distance != b.distance) { return a.distance < b.distance; } - return sortableName(a.card).size() < sortableName(b.card).size(); + return a.nameLength < b.nameLength; }; std::sort(prefixMatches.begin(), prefixMatches.end(), sortByDistanceThenLength); @@ -112,24 +110,28 @@ void CardSearchModel::updateSearchResults(const QString &query) endResetModel(); } -QStringList CardSearchModel::searchableNames(const CardInfoPtr &card) const +std::optional CardSearchModel::matchingLowercaseName(const CardInfoPtr &card, const QString &lowerQuery) const { + if (!searchLanguage.isEnglishOnly() && searchLanguage.mode == SearchLanguageMode::Selected) { + QString localizedName = card->getLocalizedName(searchLanguage.language).toLower(); + if (localizedName.contains(lowerQuery)) { + return std::optional(std::move(localizedName)); + } + return std::nullopt; + } + + QString englishName = card->getName().toLower(); + if (englishName.contains(lowerQuery)) { + return std::optional(std::move(englishName)); + } + if (searchLanguage.isEnglishOnly()) { - return {card->getName()}; + return std::nullopt; } - const QString localizedName = card->getLocalizedName(searchLanguage.language); - if (searchLanguage.mode == SearchLanguageMode::Selected) { - return {localizedName}; + QString localizedName = card->getLocalizedName(searchLanguage.language).toLower(); + if (localizedName.contains(lowerQuery)) { + return std::optional(std::move(localizedName)); } - - return {card->getName(), localizedName}; -} - -QString CardSearchModel::sortableName(const CardInfoPtr &card) const -{ - if (searchLanguage.isEnglishOnly()) { - return card->getName(); - } - return card->getLocalizedName(searchLanguage.language); + return std::nullopt; } diff --git a/libcockatrice_models/libcockatrice/models/database/card/card_search_model.h b/libcockatrice_models/libcockatrice/models/database/card/card_search_model.h index 08d0e8636..ec7c1195e 100644 --- a/libcockatrice_models/libcockatrice/models/database/card/card_search_model.h +++ b/libcockatrice_models/libcockatrice/models/database/card/card_search_model.h @@ -16,6 +16,7 @@ #include #include #include +#include #include #include @@ -51,13 +52,20 @@ private: { CardInfoPtr card; int distance; + int nameLength; }; - /** @brief The names a card is searched by with the current search language. */ - [[nodiscard]] QStringList searchableNames(const CardInfoPtr &card) const; - - /** @brief The name used to break distance ties when sorting suggestions. */ - [[nodiscard]] QString sortableName(const CardInfoPtr &card) const; + /** + * @brief Finds the lowercased card name that matches the query. + * + * The completer suggestions match against the same languages the card + * search uses, so typing a localized name finds the card. In Both mode + * either language can match. + * + * @return The matched lowercased name, or std::nullopt when the card does not match. + */ + [[nodiscard]] std::optional matchingLowercaseName(const CardInfoPtr &card, + const QString &lowerQuery) const; CardDatabaseDisplayModel *sourceModel; QList searchResults; diff --git a/libcockatrice_utility/libcockatrice/utility/levenshtein.cpp b/libcockatrice_utility/libcockatrice/utility/levenshtein.cpp index f7d8459cb..11eeef19b 100644 --- a/libcockatrice_utility/libcockatrice/utility/levenshtein.cpp +++ b/libcockatrice_utility/libcockatrice/utility/levenshtein.cpp @@ -7,23 +7,40 @@ int levenshteinDistance(const QString &s1, const QString &s2) { - int len1 = s1.size(); - int len2 = s2.size(); - std::vector> dp(len1 + 1, std::vector(len2 + 1)); + const qsizetype len1 = s1.size(); + const qsizetype len2 = s2.size(); - for (int i = 0; i <= len1; i++) { - dp[i][0] = i; + if (len1 == 0) { + return static_cast(len2); } - for (int j = 0; j <= len2; j++) { - dp[0][j] = j; + if (len2 == 0) { + return static_cast(len1); + } + if (s1 == s2) { + return 0; } - for (int i = 1; i <= len1; i++) { - for (int j = 1; j <= len2; j++) { - int cost = (s1[i - 1] == s2[j - 1]) ? 0 : 1; - dp[i][j] = std::min({dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost}); + // Only the previous row of the distance matrix is needed to compute the + // next one, so two rows replace the full len1 x len2 matrix. The card + // search runs this for every matching card on each keystroke. + std::vector previous(len2 + 1); + std::vector current(len2 + 1); + + for (qsizetype j = 0; j <= len2; j++) { + previous[j] = static_cast(j); + } + + for (qsizetype i = 1; i <= len1; i++) { + current[0] = static_cast(i); + const QChar c1 = s1.at(i - 1); + + for (qsizetype j = 1; j <= len2; j++) { + const int cost = (c1 == s2.at(j - 1)) ? 0 : 1; + current[j] = std::min(previous[j] + 1, std::min(current[j - 1] + 1, previous[j - 1] + cost)); } + + std::swap(previous, current); } - return dp[len1][len2]; + return previous[len2]; } From b85436461eaab487fbd3758be6dcff4d604deb98 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:23:17 +0200 Subject: [PATCH 005/108] [Filters] Precompute term data instead of redoing it per card (#7603) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Filters] Precompute term data instead of redoing it per card Card filters are evaluated once per card in the database, and several attributes derived constant values from the filter term on every single evaluation: - acceptColor() ran up to twelve case-insensitive string replacements and a space strip for each card, then called getColors() again for every character of the term. - acceptRarity() ran the rarity abbreviation expansion loop per card. - acceptManaCost() uppercased and sorted the term per card, before sorting the card's own cost. - acceptLoyalty() and acceptFormat() trimmed/lowercased the term per card. - relationCheck() re-parsed the numeric operator and operand per card, for every cmc/power/toughness/loyalty comparison. The term of a FilterItem never changes, so these values are now computed once in the constructor and only the card-dependent part remains in the accept path. For colors this also removes the repeat getColors() call, which materializes the printing properties hash. Additionally: - CardInfoPtr is passed by const reference through FilterItemList, FilterItem and FilterTree instead of by value, removing an atomic refcount pair per card per filter node. - Main/sub type filtering no longer allocates a QStringList via split(); it looks the " - " separator up directly and compares QStringViews. - acceptSet() and acceptPowerToughness() fetch the printing/set or the power/toughness string once instead of once per branch/accessor. No filtering behavior is intended to change. Invalid numeric terms (for example a bare "<") now return false instead of indexing trimmedTerm[1] past the end. Verified with filter_string_test (42 tests) and a full client build. * [Filters] Address review comments - Replace the main/sub type separator accessor function with a plain namespace-scope constant. - Take card type, colors, cmc, loyalty, power/toughness and the printing's set by value; those getters already return by value. --------- Co-authored-by: Lukas Brübach --- .../libcockatrice/filters/filter_tree.cpp | 355 ++++++++++-------- .../libcockatrice/filters/filter_tree.h | 69 ++-- 2 files changed, 250 insertions(+), 174 deletions(-) diff --git a/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp b/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp index 742b44e97..f12f87181 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp +++ b/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp @@ -9,9 +9,16 @@ #include #include #include +#include #include #include +namespace +{ +/** Separator between the main and sub types of a card type ("Creature — Elf"). */ +const QString MAIN_SUB_TYPE_SEPARATOR = QStringLiteral(" — "); +} // namespace + template FilterTreeNode *FilterTreeBranch::nodeAt(int i) const { return (childNodes.size() > i) ? childNodes.at(i) : nullptr; @@ -108,7 +115,7 @@ FilterTreeNode *FilterItemList::termNode(const QString &term) return childNodes.at(i); } -bool FilterItemList::testTypeAnd(const CardInfoPtr info, +bool FilterItemList::testTypeAnd(const CardInfoPtr &info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const { @@ -125,7 +132,7 @@ bool FilterItemList::testTypeAnd(const CardInfoPtr info, return true; } -bool FilterItemList::testTypeAndNot(const CardInfoPtr info, +bool FilterItemList::testTypeAndNot(const CardInfoPtr &info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const { @@ -133,7 +140,7 @@ bool FilterItemList::testTypeAndNot(const CardInfoPtr info, return !testTypeOr(info, attr, searchLanguage); } -bool FilterItemList::testTypeOr(const CardInfoPtr info, +bool FilterItemList::testTypeOr(const CardInfoPtr &info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const { @@ -156,7 +163,7 @@ bool FilterItemList::testTypeOr(const CardInfoPtr info, return noChildEnabledChild; } -bool FilterItemList::testTypeOrNot(const CardInfoPtr info, +bool FilterItemList::testTypeOrNot(const CardInfoPtr &info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const { @@ -164,7 +171,118 @@ bool FilterItemList::testTypeOrNot(const CardInfoPtr info, return !testTypeAnd(info, attr, searchLanguage); } -bool FilterItem::acceptName(const CardInfoPtr info, const CardSearchLanguage &searchLanguage) const +FilterItem::FilterItem(QString trm, FilterItemList *parent) : p(parent), term(std::move(trm)) +{ + lowercaseTerm = term.toLower(); + + switch (attr()) { + case CardFilter::AttrColor: { + QString converted = term.trimmed(); + + converted.replace("green", "g", Qt::CaseInsensitive); + converted.replace("grn", "g", Qt::CaseInsensitive); + converted.replace("blue", "u", Qt::CaseInsensitive); + converted.replace("blu", "u", Qt::CaseInsensitive); + converted.replace("black", "b", Qt::CaseInsensitive); + converted.replace("blk", "b", Qt::CaseInsensitive); + converted.replace("red", "r", Qt::CaseInsensitive); + converted.replace("white", "w", Qt::CaseInsensitive); + converted.replace("wht", "w", Qt::CaseInsensitive); + converted.replace("colorless", "c", Qt::CaseInsensitive); + converted.replace("colourless", "c", Qt::CaseInsensitive); + converted.replace("none", "c", Qt::CaseInsensitive); + + converted.replace(QString(" "), QString(""), Qt::CaseInsensitive); + + convertedColorTerm = converted; + break; + } + case CardFilter::AttrRarity: { + QString converted = term.trimmed(); + + /* + * The purpose of this loop is to only apply one of the replacement + * policies and then escape. If we attempt to layer them on top of + * each other, we will get awkward results (i.e. comythic rare mythic rareon) + * Conditional statement will exit once a case is successful in + * replacement OR we go through all possible cases. + * Will also need to replace just "mythic" + */ + for (int i = 0; converted.length() <= 3 && i <= 6; i++) { + switch (i) { + case 0: + converted.replace("mr", "mythic", Qt::CaseInsensitive); + break; + case 1: + converted.replace("m r", "mythic", Qt::CaseInsensitive); + break; + case 2: + converted.replace("m", "mythic", Qt::CaseInsensitive); + break; + case 3: + converted.replace("c", "common", Qt::CaseInsensitive); + break; + case 4: + converted.replace("u", "uncommon", Qt::CaseInsensitive); + break; + case 5: + converted.replace("r", "rare", Qt::CaseInsensitive); + break; + case 6: + converted.replace("s", "special", Qt::CaseInsensitive); + break; + default: + break; + } + } + + convertedRarityTerm = converted; + break; + } + case CardFilter::AttrManaCost: + sortedManaCostTerm = term.toUpper(); + // Sort the mana cost so it will be easy to find + std::sort(sortedManaCostTerm.begin(), sortedManaCostTerm.end()); + break; + case CardFilter::AttrLoyalty: + uppercaseLoyaltyTerm = term.trimmed().toUpper(); + break; + default: + break; + } + + // Parse the numeric relation used by cmc, power, toughness and loyalty once. + // If the term is not numeric, it probably starts with an operator. + const QString trimmedTerm = term.trimmed(); + bool converted = false; + const int value = trimmedTerm.toInt(&converted); + if (converted) { + numericOperator = NumericOperator::Equal; + numericOperand = value; + } else if (trimmedTerm.size() > 1) { + if (trimmedTerm.at(1) == '=') { + numericOperand = trimmedTerm.mid(2).toInt(); + if (trimmedTerm.startsWith('<')) { + numericOperator = NumericOperator::LessEqual; + } else if (trimmedTerm.startsWith('>')) { + numericOperator = NumericOperator::GreaterEqual; + } else { + numericOperator = NumericOperator::Equal; + } + } else { + numericOperand = trimmedTerm.mid(1).toInt(); + if (trimmedTerm.startsWith('<')) { + numericOperator = NumericOperator::Less; + } else if (trimmedTerm.startsWith('>')) { + numericOperator = NumericOperator::Greater; + } else if (trimmedTerm.startsWith('=')) { + numericOperator = NumericOperator::Equal; + } + } + } +} + +bool FilterItem::acceptName(const CardInfoPtr &info, const CardSearchLanguage &searchLanguage) const { const QString &englishName = info->getName(); const QString &localizedName = info->getLocalizedName(searchLanguage.language); @@ -187,7 +305,7 @@ bool FilterItem::acceptName(const CardInfoPtr info, const CardSearchLanguage &se return false; } -bool FilterItem::acceptNameExact(const CardInfoPtr info, const CardSearchLanguage &searchLanguage) const +bool FilterItem::acceptNameExact(const CardInfoPtr &info, const CardSearchLanguage &searchLanguage) const { const QString &englishName = info->getName(); const QString &localizedName = info->getLocalizedName(searchLanguage.language); @@ -210,44 +328,42 @@ bool FilterItem::acceptNameExact(const CardInfoPtr info, const CardSearchLanguag return false; } -bool FilterItem::acceptType(const CardInfoPtr info) const +bool FilterItem::acceptType(const CardInfoPtr &info) const { return info->getCardType().contains(term, Qt::CaseInsensitive); } -bool FilterItem::acceptMainType(const CardInfoPtr info) const +bool FilterItem::acceptMainType(const CardInfoPtr &info) const { - const QStringList typeParts = info->getCardType().split(" — "); - return !typeParts.isEmpty() && typeParts[0].contains(term, Qt::CaseInsensitive); + const QString type = info->getCardType(); + const qsizetype separator = type.indexOf(MAIN_SUB_TYPE_SEPARATOR); + if (separator >= 0) { + return QStringView(type).left(separator).contains(term, Qt::CaseInsensitive); + } + return type.contains(term, Qt::CaseInsensitive); } -bool FilterItem::acceptSubType(const CardInfoPtr info) const +bool FilterItem::acceptSubType(const CardInfoPtr &info) const { - const QStringList typeParts = info->getCardType().split(" — "); - return typeParts.size() > 1 && typeParts[1].contains(term, Qt::CaseInsensitive); + const QString type = info->getCardType(); + const qsizetype separator = type.indexOf(MAIN_SUB_TYPE_SEPARATOR); + if (separator < 0) { + return false; + } + + const qsizetype subTypeStart = separator + MAIN_SUB_TYPE_SEPARATOR.size(); + const qsizetype nextSeparator = type.indexOf(MAIN_SUB_TYPE_SEPARATOR, subTypeStart); + const QStringView subType = nextSeparator < 0 ? QStringView(type).mid(subTypeStart) + : QStringView(type).mid(subTypeStart, nextSeparator - subTypeStart); + return subType.contains(term, Qt::CaseInsensitive); } -bool FilterItem::acceptColor(const CardInfoPtr info) const +bool FilterItem::acceptColor(const CardInfoPtr &info) const { - QString converted_term = term.trimmed(); - - converted_term.replace("green", "g", Qt::CaseInsensitive); - converted_term.replace("grn", "g", Qt::CaseInsensitive); - converted_term.replace("blue", "u", Qt::CaseInsensitive); - converted_term.replace("blu", "u", Qt::CaseInsensitive); - converted_term.replace("black", "b", Qt::CaseInsensitive); - converted_term.replace("blk", "b", Qt::CaseInsensitive); - converted_term.replace("red", "r", Qt::CaseInsensitive); - converted_term.replace("white", "w", Qt::CaseInsensitive); - converted_term.replace("wht", "w", Qt::CaseInsensitive); - converted_term.replace("colorless", "c", Qt::CaseInsensitive); - converted_term.replace("colourless", "c", Qt::CaseInsensitive); - converted_term.replace("none", "c", Qt::CaseInsensitive); - - converted_term.replace(QString(" "), QString(""), Qt::CaseInsensitive); + const QString colors = info->getColors(); // Colorless card filter - if (converted_term.toLower() == "c" && info->getColors().length() < 1) { + if (convertedColorTerm.toLower() == "c" && colors.length() < 1) { return true; } @@ -256,16 +372,16 @@ bool FilterItem::acceptColor(const CardInfoPtr info) const * then we should match all of them to the card's colors */ int match_count = 0; - for (auto &it : converted_term) { - if (info->getColors().contains(it, Qt::CaseInsensitive)) { + for (const QChar &color : convertedColorTerm) { + if (colors.contains(color, Qt::CaseInsensitive)) { match_count++; } } - return match_count == converted_term.length(); + return match_count == convertedColorTerm.length(); } -bool FilterItem::acceptText(const CardInfoPtr info, const CardSearchLanguage &searchLanguage) const +bool FilterItem::acceptText(const CardInfoPtr &info, const CardSearchLanguage &searchLanguage) const { const QString &englishText = info->getText(); const QString &localizedText = info->getLocalizedText(searchLanguage.language); @@ -288,51 +404,47 @@ bool FilterItem::acceptText(const CardInfoPtr info, const CardSearchLanguage &se return false; } -bool FilterItem::acceptSet(const CardInfoPtr info) const +bool FilterItem::acceptSet(const CardInfoPtr &info) const { - bool status = false; for (const auto &printings : info->getSets()) { - for (const auto &set : printings) { - if (set.getSet()->getShortName().compare(term, Qt::CaseInsensitive) == 0 || - set.getSet()->getLongName().compare(term, Qt::CaseInsensitive) == 0) { - status = true; - break; + for (const auto &printing : printings) { + const CardSetPtr set = printing.getSet(); + if (set && (set->getShortName().compare(term, Qt::CaseInsensitive) == 0 || + set->getLongName().compare(term, Qt::CaseInsensitive) == 0)) { + return true; } } } - return status; + return false; } -bool FilterItem::acceptManaCost(const CardInfoPtr info) const +bool FilterItem::acceptManaCost(const CardInfoPtr &info) const { - QString partialCost = term.toUpper(); - - // Sort the mana cost so it will be easy to find - std::sort(partialCost.begin(), partialCost.end()); - // Try to seperate the mana cost in case it's a split card // if it's not a split card the loop will run only once - for (QString fullManaCost : info->getManaCost().split("//")) { + for (const QString &manaCostPart : info->getManaCost().split("//")) { + QString fullManaCost = manaCostPart; std::sort(fullManaCost.begin(), fullManaCost.end()); // If the partial is found in the full, return true - if (fullManaCost.contains(partialCost)) { + if (fullManaCost.contains(sortedManaCostTerm)) { return true; } } return false; } -bool FilterItem::acceptCmc(const CardInfoPtr info) const +bool FilterItem::acceptCmc(const CardInfoPtr &info) const { + const QString cmc = info->getCmc(); bool convertSuccess; - int cmcInt = info->getCmc().toInt(&convertSuccess); + int cmcInt = cmc.toInt(&convertSuccess); // if conversion failed, check for the "//" separator used in split cards if (!convertSuccess) { int cmcSum = 0; - for (const QString &cmc : info->getCmc().split("//")) { - cmcInt = cmc.toInt(); + for (const QString &cmcPart : cmc.split("//")) { + cmcInt = cmcPart.toInt(); cmcSum += cmcInt; if (relationCheck(cmcInt)) { return true; @@ -344,39 +456,36 @@ bool FilterItem::acceptCmc(const CardInfoPtr info) const } } -bool FilterItem::acceptFormat(const CardInfoPtr info) const +bool FilterItem::acceptFormat(const CardInfoPtr &info) const { - return info->getLegalityProp(term.toLower()) == "legal"; + return info->getLegalityProp(lowercaseTerm) == "legal"; } -bool FilterItem::acceptLoyalty(const CardInfoPtr info) const +bool FilterItem::acceptLoyalty(const CardInfoPtr &info) const { - if (info->getLoyalty().isEmpty()) { + const QString loyaltyString = info->getLoyalty(); + if (loyaltyString.isEmpty()) { return false; - } else { - bool success; - // if loyalty can't be converted to "int" it must be "X" - int loyalty = info->getLoyalty().toInt(&success); - if (success) { - return relationCheck(loyalty); - } else { - return term.trimmed().toUpper() == info->getLoyalty(); - } } + + bool success; + // if loyalty can't be converted to "int" it must be "X" + int loyalty = loyaltyString.toInt(&success); + if (success) { + return relationCheck(loyalty); + } + return uppercaseLoyaltyTerm == loyaltyString; } -bool FilterItem::acceptPowerToughness(const CardInfoPtr info, CardFilter::Attr attr) const +bool FilterItem::acceptPowerToughness(const CardInfoPtr &info, CardFilter::Attr attr) const { - int slash = info->getPowTough().indexOf("/"); + const QString powTough = info->getPowTough(); + const qsizetype slash = powTough.indexOf("/"); if (slash == -1) { return false; } - QString valueString; - if (attr == CardFilter::AttrPow) { - valueString = info->getPowTough().mid(0, slash); - } else { - valueString = info->getPowTough().mid(slash + 1); - } + + QString valueString = attr == CardFilter::AttrPow ? powTough.mid(0, slash) : powTough.mid(slash + 1); if (term == valueString) { return true; } @@ -386,49 +495,11 @@ bool FilterItem::acceptPowerToughness(const CardInfoPtr info, CardFilter::Attr a return conversion ? relationCheck(value) : false; } -bool FilterItem::acceptRarity(const CardInfoPtr info) const +bool FilterItem::acceptRarity(const CardInfoPtr &info) const { - QString converted_term = term.trimmed(); - - /* - * The purpose of this loop is to only apply one of the replacement - * policies and then escape. If we attempt to layer them on top of - * each other, we will get awkward results (i.e. comythic rare mythic rareon) - * Conditional statement will exit once a case is successful in - * replacement OR we go through all possible cases. - * Will also need to replace just "mythic" - */ - for (int i = 0; converted_term.length() <= 3 && i <= 6; i++) { - switch (i) { - case 0: - converted_term.replace("mr", "mythic", Qt::CaseInsensitive); - break; - case 1: - converted_term.replace("m r", "mythic", Qt::CaseInsensitive); - break; - case 2: - converted_term.replace("m", "mythic", Qt::CaseInsensitive); - break; - case 3: - converted_term.replace("c", "common", Qt::CaseInsensitive); - break; - case 4: - converted_term.replace("u", "uncommon", Qt::CaseInsensitive); - break; - case 5: - converted_term.replace("r", "rare", Qt::CaseInsensitive); - break; - case 6: - converted_term.replace("s", "special", Qt::CaseInsensitive); - break; - default: - break; - } - } - for (const auto &printings : info->getSets()) { for (const auto &printing : printings) { - if (printing.getProperty("rarity").compare(converted_term, Qt::CaseInsensitive) == 0) { + if (printing.getProperty("rarity").compare(convertedRarityTerm, Qt::CaseInsensitive) == 0) { return true; } } @@ -438,41 +509,25 @@ bool FilterItem::acceptRarity(const CardInfoPtr info) const bool FilterItem::relationCheck(int cardInfo) const { - bool result, conversion; - - // if int conversion fails, there's probably an operator at the start - result = (cardInfo == term.toInt(&conversion)); - if (!conversion) { - // leading whitespaces could cause indexing to fail - QString trimmedTerm = term.trimmed(); - // check whether it's a 2 char operator (<=, >=, or ==) - if (trimmedTerm[1] == '=') { - int termInt = trimmedTerm.mid(2).toInt(); - if (trimmedTerm.startsWith('<')) { - result = (cardInfo <= termInt); - } else if (trimmedTerm.startsWith('>')) { - result = (cardInfo >= termInt); - } else { - result = (cardInfo == termInt); - } - } else { - int termInt = trimmedTerm.mid(1).toInt(); - if (trimmedTerm.startsWith('<')) { - result = (cardInfo < termInt); - } else if (trimmedTerm.startsWith('>')) { - result = (cardInfo > termInt); - } else if (trimmedTerm.startsWith("=")) { - result = (cardInfo == termInt); - } else { - // the int conversion hasn't failed due to an operator at the start - result = false; - } - } + switch (numericOperator) { + case NumericOperator::Equal: + return cardInfo == numericOperand; + case NumericOperator::Less: + return cardInfo < numericOperand; + case NumericOperator::LessEqual: + return cardInfo <= numericOperand; + case NumericOperator::Greater: + return cardInfo > numericOperand; + case NumericOperator::GreaterEqual: + return cardInfo >= numericOperand; + case NumericOperator::Invalid: + return false; } - return result; + + return false; } -bool FilterItem::acceptCardAttr(const CardInfoPtr info, +bool FilterItem::acceptCardAttr(const CardInfoPtr &info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const { @@ -556,7 +611,7 @@ FilterTreeNode *FilterTree::termNode(const CardFilter *f) return termNode(f->attr(), f->type(), f->term()); } -bool FilterTree::testAttr(const CardInfoPtr info, const LogicMap *lm, const CardSearchLanguage &searchLanguage) const +bool FilterTree::testAttr(const CardInfoPtr &info, const LogicMap *lm, const CardSearchLanguage &searchLanguage) const { const FilterItemList *fil; bool status = true; @@ -589,7 +644,7 @@ bool FilterTree::testAttr(const CardInfoPtr info, const LogicMap *lm, const Card return status; } -bool FilterTree::acceptsCard(const CardInfoPtr info, const CardSearchLanguage &searchLanguage) const +bool FilterTree::acceptsCard(const CardInfoPtr &info, const CardSearchLanguage &searchLanguage) const { for (auto i = childNodes.constBegin(); i != childNodes.constEnd(); i++) { if ((*i)->isEnabled() && !testAttr(info, *i, searchLanguage)) { diff --git a/libcockatrice_filters/libcockatrice/filters/filter_tree.h b/libcockatrice_filters/libcockatrice/filters/filter_tree.h index 0defd466b..85a57304d 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_tree.h +++ b/libcockatrice_filters/libcockatrice/filters/filter_tree.h @@ -174,13 +174,13 @@ public: } [[nodiscard]] bool - testTypeAnd(CardInfoPtr info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const; + testTypeAnd(const CardInfoPtr &info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const; [[nodiscard]] bool - testTypeAndNot(CardInfoPtr info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const; + testTypeAndNot(const CardInfoPtr &info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const; [[nodiscard]] bool - testTypeOr(CardInfoPtr info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const; + testTypeOr(const CardInfoPtr &info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const; [[nodiscard]] bool - testTypeOrNot(CardInfoPtr info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const; + testTypeOrNot(const CardInfoPtr &info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const; }; class FilterItem : public FilterTreeNode @@ -188,12 +188,33 @@ class FilterItem : public FilterTreeNode private: FilterItemList *const p; + /** + * Values derived from the filter term that never change while this node + * exists. Computing them in the constructor keeps them out of the per-card + * evaluation path. + */ + QString convertedColorTerm; + QString convertedRarityTerm; + QString sortedManaCostTerm; + QString uppercaseLoyaltyTerm; + QString lowercaseTerm; + + enum class NumericOperator + { + Invalid, + Equal, + Less, + LessEqual, + Greater, + GreaterEqual, + }; + NumericOperator numericOperator = NumericOperator::Invalid; + int numericOperand = 0; + public: const QString term; - FilterItem(QString trm, FilterItemList *parent) : p(parent), term(std::move(trm)) - { - } + FilterItem(QString trm, FilterItemList *parent); virtual ~FilterItem() = default; [[nodiscard]] CardFilter::Attr attr() const @@ -217,22 +238,22 @@ public: return true; } - [[nodiscard]] bool acceptName(CardInfoPtr info, const CardSearchLanguage &searchLanguage) const; - [[nodiscard]] bool acceptNameExact(CardInfoPtr info, const CardSearchLanguage &searchLanguage) const; - [[nodiscard]] bool acceptType(CardInfoPtr info) const; - [[nodiscard]] bool acceptMainType(CardInfoPtr info) const; - [[nodiscard]] bool acceptSubType(CardInfoPtr info) const; - [[nodiscard]] bool acceptColor(CardInfoPtr info) const; - [[nodiscard]] bool acceptText(CardInfoPtr info, const CardSearchLanguage &searchLanguage) const; - [[nodiscard]] bool acceptSet(CardInfoPtr info) const; - [[nodiscard]] bool acceptManaCost(CardInfoPtr info) const; - [[nodiscard]] bool acceptCmc(CardInfoPtr info) const; - [[nodiscard]] bool acceptPowerToughness(CardInfoPtr info, CardFilter::Attr attr) const; - [[nodiscard]] bool acceptLoyalty(CardInfoPtr info) const; - [[nodiscard]] bool acceptRarity(CardInfoPtr info) const; + [[nodiscard]] bool acceptName(const CardInfoPtr &info, const CardSearchLanguage &searchLanguage) const; + [[nodiscard]] bool acceptNameExact(const CardInfoPtr &info, const CardSearchLanguage &searchLanguage) const; + [[nodiscard]] bool acceptType(const CardInfoPtr &info) const; + [[nodiscard]] bool acceptMainType(const CardInfoPtr &info) const; + [[nodiscard]] bool acceptSubType(const CardInfoPtr &info) const; + [[nodiscard]] bool acceptColor(const CardInfoPtr &info) const; + [[nodiscard]] bool acceptText(const CardInfoPtr &info, const CardSearchLanguage &searchLanguage) const; + [[nodiscard]] bool acceptSet(const CardInfoPtr &info) const; + [[nodiscard]] bool acceptManaCost(const CardInfoPtr &info) const; + [[nodiscard]] bool acceptCmc(const CardInfoPtr &info) const; + [[nodiscard]] bool acceptPowerToughness(const CardInfoPtr &info, CardFilter::Attr attr) const; + [[nodiscard]] bool acceptLoyalty(const CardInfoPtr &info) const; + [[nodiscard]] bool acceptRarity(const CardInfoPtr &info) const; [[nodiscard]] bool - acceptCardAttr(CardInfoPtr info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const; - [[nodiscard]] bool acceptFormat(CardInfoPtr info) const; + acceptCardAttr(const CardInfoPtr &info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const; + [[nodiscard]] bool acceptFormat(const CardInfoPtr &info) const; [[nodiscard]] bool relationCheck(int cardInfo) const; }; @@ -251,7 +272,7 @@ private: LogicMap *attrLogicMap(CardFilter::Attr attr); FilterItemList *attrTypeList(CardFilter::Attr attr, CardFilter::Type type); - bool testAttr(CardInfoPtr info, const LogicMap *lm, const CardSearchLanguage &searchLanguage) const; + bool testAttr(const CardInfoPtr &info, const LogicMap *lm, const CardSearchLanguage &searchLanguage) const; void nodeChanged() const override { @@ -290,7 +311,7 @@ public: return 0; } - [[nodiscard]] bool acceptsCard(CardInfoPtr info, const CardSearchLanguage &searchLanguage) const; + [[nodiscard]] bool acceptsCard(const CardInfoPtr &info, const CardSearchLanguage &searchLanguage) const; void removeFiltersByAttr(CardFilter::Attr filterType); void removeFilter(const CardFilter *toRemove); void clear(); From c25a2c5c3ba7daa1f05688f652a96a746076b8df Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:58:58 +0200 Subject: [PATCH 006/108] [CardDB] Avoid copying printing maps and property strings (#7605) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [CardDB] Avoid copying printing maps and property strings Reading a card's sets was a measured hotspot: `SetToPrintingsMap setMap = card->getSets();` copies the whole QMap, and then iterating it with a non-const iterator detaches and deep-copies every PrintingInfo, including its property hash, on first use. A micro-benchmark of that exact pattern (copy plus non-const iteration) came out about 19x slower than a const reference over the same data. The querier, the cache writer, the XML writer, the picture loader and the printing/set selection widgets now take `getSets()` by const reference and only use const iterators. The tests that iterate generated data are covered by the existing card database and picture loader suites. String accessors were also returning by value where the value lives in a hash that has already been materialized: - CardInfo::getProperty() and the legacy getCardType()/getColors()/ getManaCost()/getCmc()/getPowTough()/getLoyalty() accessors, - PrintingInfo::getProperty(), getUuid(), getFlavorName(), getArtist(), and getSet(), - ExactCard::getPrinting() and getCardPtr(), - CardSet::getShortName(), getLongName() and getSetType(). getPrinting() in particular copied a PrintingInfo on every call, and a copy of an unmaterialized PrintingInfo has to take the properties mutex. Callers that need an owned copy still get one; the rest just read. Other allocation removals in the same path: - LazyPropertiesHash::ensureMaterialized() checks an atomic flag before taking the mutex, so property reads after the first one do not lock. The flag is release-stored after materialization and acquire-loaded by readers/copies, preserving the existing locking for the first read. - CardSet::getCorrectedShortName() no longer rebuilds its QSet of reserved Windows file names on every call. - CardSet::getIsKnownIgnored() no longer formats the release date into a throwaway string just to check its length. - The querier builds its card-type/format count caches without `cards.values()` or a properties key list, reserves its result lists, and picks a random card without materializing `cards.keys()`. - The querier's pinned-printing path reuses the CardInfoPtr it already has instead of looking the card up again by name. - CardDatabaseCache/XML serialization copy the alt-name QSet by reference. - The picture loader compiles its !prop!/!set! regular expressions once instead of per URL template, and walks the transform map with an iterator instead of keys() plus operator[]. Full build and ctest (628 tests) pass. * [CardDB] Address review comments PrintingInfo is a value type: it is stored in QLists and returned by value from the querier, so a const-ref getter can hand out a reference into a temporary. Return the QStrings and the CardSetPtr by value again. The constFind lookup stays, so a property read still never materializes the lazy property hash - only the last shared-data refcount is paid. * [CardDB] Undo the const-ref QString return changes QString is implicitly shared, so returning by value costs only a refcount and avoids handing out references into a lazily materialized hash. The constFind lookups stay, so a property read still never materializes the hash. * [CardDB] Use QMap::asKeyValueRange() in transformUrl The range is a view over the map's own iterators and yields references to the stored key and value, so it keeps the no-allocation property of the manual const iteration. --------- Co-authored-by: Lukas Brübach --- .../card_picture_to_load.cpp | 24 ++++--- .../dialogs/dlg_select_set_for_cards.cpp | 10 +-- .../printing_selector/printing_selector.cpp | 2 +- .../printing_selector_card_sorting_widget.cpp | 2 +- .../visual_database_display_widget.cpp | 7 +- .../libcockatrice/card/card_info.cpp | 2 +- .../libcockatrice/card/card_info.h | 6 +- .../card/database/card_database_cache.cpp | 4 +- .../card/database/card_database_querier.cpp | 71 ++++++++++--------- .../card/database/parser/cockatrice_xml_3.cpp | 2 +- .../card/lazy_properties_hash.cpp | 22 +++--- .../libcockatrice/card/lazy_properties_hash.h | 9 +-- .../libcockatrice/card/printing/exact_card.h | 4 +- .../card/printing/printing_info.cpp | 4 +- .../card/printing/printing_info.h | 4 +- .../libcockatrice/card/set/card_set.cpp | 12 ++-- .../libcockatrice/card/set/card_set.h | 2 +- 17 files changed, 108 insertions(+), 79 deletions(-) diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_to_load.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_to_load.cpp index e35c417c8..8e8feb5b5 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_to_load.cpp +++ b/cockatrice/src/interface/card_picture_loader/card_picture_to_load.cpp @@ -21,6 +21,7 @@ #include #include #include +#include CardPictureToLoad::CardPictureToLoad(const ExactCard &_card) : card(_card), urlTemplates(SettingsCache::instance().downloads().getAllURLs()) @@ -74,19 +75,20 @@ QList CardPictureToLoad::extractSetsSorted(const ExactCard &card) //! \todo Check if going through all sets is still necessary after the ExactCard refactor. static PrintingInfo findPrintingForSet(const ExactCard &card, const QString &setName) { - SetToPrintingsMap setsToPrintings = card.getInfo().getSets(); + const auto &setsToPrintings = card.getInfo().getSets(); - if (!setsToPrintings.contains(setName)) { + const auto setPrintings = setsToPrintings.constFind(setName); + if (setPrintings == setsToPrintings.constEnd() || setPrintings.value().isEmpty()) { return PrintingInfo(); } - for (const auto &printing : setsToPrintings[setName]) { + for (const auto &printing : setPrintings.value()) { if (printing.getUuid() == card.getPrinting().getUuid()) { return printing; } } - return setsToPrintings[setName][0]; + return setPrintings.value().first(); } void CardPictureToLoad::populateSetUrls() @@ -183,10 +185,14 @@ static int parse(const QString &urlTemplate, { static const QRegularExpression rxFillWith("^(.+)_fill_with_(.+)$"); static const QRegularExpression rxSubStr("^(.+)_substr_(\\d+)_(\\d+)$"); + static const QRegularExpression rxCardProp("!prop:([^!]+)!"); + static const QRegularExpression rxCardSet("!set:([^!]+)!"); - const QRegularExpression rxCardProp("!" + propType + ":([^!]+)!"); + // The property type only takes two values, so keep the expression that was + // compiled for it instead of rebuilding one per URL template. + const QRegularExpression &rxCardPropSelector = propType == "prop" ? rxCardProp : rxCardSet; - auto matches = rxCardProp.globalMatch(urlTemplate); + auto matches = rxCardPropSelector.globalMatch(urlTemplate); while (matches.hasNext()) { auto match = matches.next(); QString templatePropertyName = match.captured(1); @@ -303,10 +309,10 @@ QString CardPictureToLoad::transformUrl(const QString &urlTemplate) const } QString transformedUrl = urlTemplate; - for (const QString &prop : transformMap.keys()) { + for (const auto &[prop, value] : std::as_const(transformMap).asKeyValueRange()) { if (transformedUrl.contains(prop)) { - if (!transformMap[prop].isEmpty()) { - transformedUrl.replace(prop, QUrl::toPercentEncoding(transformMap[prop])); + if (!value.isEmpty()) { + transformedUrl.replace(prop, QUrl::toPercentEncoding(value)); } else { /* This means the template is requesting information that is not * populated in this card, so it should return an empty string, diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_select_set_for_cards.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_select_set_for_cards.cpp index 55c6569f7..57ea09628 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_select_set_for_cards.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_select_set_for_cards.cpp @@ -274,9 +274,9 @@ QMap DlgSelectSetForCards::getSetsForCards() continue; } - SetToPrintingsMap setMap = infoPtr->getSets(); - for (auto &setName : setMap.keys()) { - setCounts[setName]++; + const auto &setMap = infoPtr->getSets(); + for (auto it = setMap.constBegin(); it != setMap.constEnd(); ++it) { + setCounts[it.key()]++; } } @@ -393,8 +393,8 @@ QMap DlgSelectSetForCards::getCardsForSets() continue; } - SetToPrintingsMap setMap = infoPtr->getSets(); - for (auto it = setMap.begin(); it != setMap.end(); ++it) { + const auto &setMap = infoPtr->getSets(); + for (auto it = setMap.constBegin(); it != setMap.constEnd(); ++it) { setCards[it.key()].append(cardName); } } diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector.cpp b/cockatrice/src/interface/widgets/printing_selector/printing_selector.cpp index 27ae61b94..983d6ba32 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector.cpp @@ -229,7 +229,7 @@ void PrintingSelector::getAllSetsForCurrentCard() return; } - SetToPrintingsMap setMap = selectedCard->getSets(); + const auto &setMap = selectedCard->getSets(); const QList sortedPrintings = sortToolBar->sortSets(setMap); const QList filteredPrintings = sortToolBar->filterSets(sortedPrintings, searchBar->getSearchText().trimmed().toLower()); diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_sorting_widget.cpp b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_sorting_widget.cpp index f47ba0746..1cd372259 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_sorting_widget.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_sorting_widget.cpp @@ -208,7 +208,7 @@ QList PrintingSelectorCardSortingWidget::prependPrintingsInDeck(co return {}; } - SetToPrintingsMap setMap = selectedCard->getSets(); + const auto &setMap = selectedCard->getSets(); QList> countList; // Collect sets with their counts diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp index 599ac97b4..1a54ff0f5 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp @@ -432,10 +432,11 @@ void VisualDatabaseDisplayWidget::loadPage(int start, int end) if (CardInfoPtr info = CardDatabaseManager::query()->getCardInfo(name.toString())) { if (!setFilters.empty()) { - SetToPrintingsMap setMap = info->getSets(); + const auto &setMap = info->getSets(); for (const CardFilter *setFilter : setFilters) { - if (setMap.contains(setFilter->term())) { - for (PrintingInfo printing : setMap[setFilter->term()]) { + const auto it = setMap.constFind(setFilter->term()); + if (it != setMap.constEnd()) { + for (const PrintingInfo &printing : it.value()) { addCardToDisplay(ExactCard(info, printing)); } } diff --git a/libcockatrice_card/libcockatrice/card/card_info.cpp b/libcockatrice_card/libcockatrice/card/card_info.cpp index bd33f18e7..ae2382166 100644 --- a/libcockatrice_card/libcockatrice/card/card_info.cpp +++ b/libcockatrice_card/libcockatrice/card/card_info.cpp @@ -245,7 +245,7 @@ QString CardInfo::simplifyName(const QString &name) QChar CardInfo::getColorChar() const { - QString colors = getColors(); + const QString colors = getColors(); switch (colors.size()) { case 0: return QChar(); diff --git a/libcockatrice_card/libcockatrice/card/card_info.h b/libcockatrice_card/libcockatrice/card/card_info.h index 356da0546..c0dccb885 100644 --- a/libcockatrice_card/libcockatrice/card/card_info.h +++ b/libcockatrice_card/libcockatrice/card/card_info.h @@ -280,7 +280,7 @@ public: { return simpleName; } - const QSet &getAltNames() + const QSet &getAltNames() const { return altNames; } @@ -395,7 +395,9 @@ public: [[nodiscard]] QString getProperty(const QString &propertyName) const { - return getPropertiesHash().value(propertyName); + const auto &propertiesHash = getPropertiesHash(); + const auto it = propertiesHash.constFind(propertyName); + return it != propertiesHash.constEnd() ? it.value() : QString(); } void setProperty(const QString &_name, const QString &_value); [[nodiscard]] bool hasProperty(const QString &propertyName) const diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp b/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp index 4ab4d1fca..42658dd6b 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp +++ b/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp @@ -210,7 +210,7 @@ void writeCard(QDataStream &out, const CardInfoPtr &card) // per-printing alt-name scan entirely. writeString(out, card->getSimpleName()); { - const QSet altNames = card->getAltNames(); + const QSet &altNames = card->getAltNames(); out << static_cast(altNames.size()); for (const QString &alt : altNames) { writeString(out, alt); @@ -218,7 +218,7 @@ void writeCard(QDataStream &out, const CardInfoPtr &card) } // setsToPrintings - const SetToPrintingsMap sets = card->getSets(); + const auto &sets = card->getSets(); out << static_cast(sets.size()); for (auto it = sets.constBegin(); it != sets.constEnd(); ++it) { writeString(out, it.key()); diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_querier.cpp b/libcockatrice_card/libcockatrice/card/database/card_database_querier.cpp index 7c41daa3b..8df0d2e20 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database_querier.cpp +++ b/libcockatrice_card/libcockatrice/card/database/card_database_querier.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include CardDatabaseQuerier::CardDatabaseQuerier(QObject *_parent, @@ -52,6 +53,7 @@ CardInfoPtr CardDatabaseQuerier::getCardInfo(const QString &cardName) const QList CardDatabaseQuerier::getCardInfos(const QStringList &cardNames) const { QList cardInfos; + cardInfos.reserve(cardNames.size()); for (const QString &cardName : cardNames) { CardInfoPtr ptr = db->cards.value(cardName); if (ptr) { @@ -90,6 +92,7 @@ CardInfoPtr CardDatabaseQuerier::lookupCardByName(const QString &name) const QList CardDatabaseQuerier::getCards(const QList &cardRefs) const { QList cards; + cards.reserve(cardRefs.size()); for (const auto &cardRef : cardRefs) { ExactCard card = getCard(cardRef); if (card) { @@ -149,10 +152,10 @@ ExactCard CardDatabaseQuerier::getRandomCard() const return {}; } - const auto keys = db->cards.keys(); - int randomIndex = QRandomGenerator::global()->bounded(keys.size()); - const QString &randomKey = keys.at(randomIndex); - CardInfoPtr randomCard = getCardInfo(randomKey); + const auto randomIndex = QRandomGenerator::global()->bounded(db->cards.size()); + auto it = db->cards.constBegin(); + std::advance(it, randomIndex); + CardInfoPtr randomCard = it.value(); return ExactCard{randomCard, getPreferredPrinting(randomCard)}; } @@ -215,20 +218,23 @@ PrintingInfo CardDatabaseQuerier::getSpecificPrinting(const QString &cardName, return PrintingInfo(nullptr); } - SetToPrintingsMap setMap = cardInfo->getSets(); + const auto &setMap = cardInfo->getSets(); if (setMap.empty()) { return PrintingInfo(nullptr); } for (const auto &printings : setMap) { - for (auto &cardInfoForSet : printings) { + for (const auto &cardInfoForSet : printings) { + const CardSetPtr &set = cardInfoForSet.getSet(); + if (!set) { + continue; + } if (!collectorNumber.isEmpty()) { - if (cardInfoForSet.getSet()->getShortName() == setShortName && - cardInfoForSet.getProperty("num") == collectorNumber) { + if (set->getShortName() == setShortName && cardInfoForSet.getProperty("num") == collectorNumber) { return cardInfoForSet; } } else { - if (cardInfoForSet.getSet()->getShortName() == setShortName) { + if (set->getShortName() == setShortName) { return cardInfoForSet; } } @@ -284,10 +290,10 @@ PrintingInfo CardDatabaseQuerier::getPreferredPrinting(const CardInfoPtr &cardIn const auto &pinnedPrintingProviderId = prefs->getCardPreferenceOverride(cardInfo->getName()); if (!pinnedPrintingProviderId.isEmpty()) { - return getSpecificPrinting({cardInfo->getName(), pinnedPrintingProviderId}); + return findPrintingWithId(cardInfo, pinnedPrintingProviderId); } - SetToPrintingsMap setMap = cardInfo->getSets(); + const auto &setMap = cardInfo->getSets(); if (setMap.empty()) { return PrintingInfo(nullptr); } @@ -297,8 +303,8 @@ PrintingInfo CardDatabaseQuerier::getPreferredPrinting(const CardInfoPtr &cardIn SetPriorityComparator comparator; for (const auto &printings : setMap) { - for (auto &printing : printings) { - CardSetPtr currentSet = printing.getSet(); + for (const auto &printing : printings) { + const CardSetPtr ¤tSet = printing.getSet(); if (!preferredSet || comparator(currentSet, preferredSet)) { preferredSet = currentSet; preferredPrinting = printing; @@ -315,17 +321,18 @@ PrintingInfo CardDatabaseQuerier::getPreferredPrinting(const CardInfoPtr &cardIn QString CardDatabaseQuerier::getPreferredPrintingProviderId(const QString &cardName) const { - PrintingInfo preferredPrinting = getPreferredPrinting(cardName); - QString uuid = preferredPrinting.getUuid(); + CardInfoPtr cardInfo = getCardInfo(cardName); + if (!cardInfo) { + return cardName; + } + + PrintingInfo preferredPrinting = getPreferredPrinting(cardInfo); + const QString &uuid = preferredPrinting.getUuid(); if (!uuid.isEmpty()) { return uuid; } - CardInfoPtr defaultCardInfo = getCardInfo(cardName); - if (defaultCardInfo.isNull()) { - return cardName; - } - return defaultCardInfo->getName(); + return cardInfo->getName(); } QStringList CardDatabaseQuerier::getAllMainCardTypes() const @@ -339,8 +346,8 @@ QMap CardDatabaseQuerier::getAllMainCardTypesWithCount() const // produces an empty map, so the cache is only ever empty when it needs a // (trivially cheap) rebuild. if (mainCardTypeCountsCache.isEmpty()) { - for (const auto &card : db->cards.values()) { - QString type = card->getMainCardType(); + for (const auto &card : db->cards) { + const QString type = card->getMainCardType(); mainCardTypeCountsCache[type]++; } } @@ -351,13 +358,13 @@ QMap CardDatabaseQuerier::getAllMainCardTypesWithCount() const QMap CardDatabaseQuerier::getAllSubCardTypesWithCount() const { if (subCardTypeCountsCache.isEmpty()) { - for (const auto &card : db->cards.values()) { - QString type = card->getCardType(); + for (const auto &card : db->cards) { + const QString type = card->getCardType(); - QStringList parts = type.split(" — "); + const QStringList parts = type.split(" — "); if (parts.size() > 1) { // Ensure there are subtypes - QStringList subtypes = parts[1].split(" ", Qt::SkipEmptyParts); + const QStringList subtypes = parts[1].split(" ", Qt::SkipEmptyParts); for (const QString &subtype : subtypes) { subCardTypeCountsCache[subtype]++; @@ -377,13 +384,13 @@ FormatRulesPtr CardDatabaseQuerier::getFormat(const QString &formatName) const QMap CardDatabaseQuerier::getAllFormatsWithCount() const { if (formatsCountCache.isEmpty()) { - for (const auto &card : db->cards.values()) { - QStringList allProps = card->getProperties(); + static const QString formatPrefix = QStringLiteral("format-"); - for (const QString &prop : allProps) { - if (prop.startsWith("format-")) { - QString formatName = prop.mid(QStringLiteral("format-").size()); - formatsCountCache[formatName]++; + for (const auto &card : db->cards) { + const auto &properties = card->getPropertiesHash(); + for (auto it = properties.constBegin(); it != properties.constEnd(); ++it) { + if (it.key().startsWith(formatPrefix)) { + formatsCountCache[it.key().mid(formatPrefix.size())]++; } } } diff --git a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp index b17eb8b5e..33a0e0849 100644 --- a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp +++ b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp @@ -401,7 +401,7 @@ static QXmlStreamWriter &operator<<(QXmlStreamWriter &xml, const CardInfoPtr &in } // sets - const SetToPrintingsMap setMap = info->getSets(); + const auto &setMap = info->getSets(); for (const auto &printings : setMap) { for (const PrintingInfo &set : printings) { xml.writeStartElement("set"); diff --git a/libcockatrice_card/libcockatrice/card/lazy_properties_hash.cpp b/libcockatrice_card/libcockatrice/card/lazy_properties_hash.cpp index a12de469b..90628224a 100644 --- a/libcockatrice_card/libcockatrice/card/lazy_properties_hash.cpp +++ b/libcockatrice_card/libcockatrice/card/lazy_properties_hash.cpp @@ -20,15 +20,15 @@ LazyPropertiesHash::LazyPropertiesHash(const QHash &properties LazyPropertiesHash::LazyPropertiesHash(const LazyPropertiesHash &other) { // since we do not allow dematerialization, we only need to lock if not materialized yet - if (other.isMaterialized) { + if (other.isMaterialized.load(std::memory_order_acquire)) { blob = other.blob; properties = other.properties; - isMaterialized = true; + isMaterialized.store(true, std::memory_order_relaxed); } else { QMutexLocker lock(&other.propertiesMutex); blob = other.blob; properties = other.properties; - isMaterialized = false; + isMaterialized.store(other.isMaterialized.load(std::memory_order_relaxed), std::memory_order_relaxed); } } @@ -39,15 +39,15 @@ LazyPropertiesHash &LazyPropertiesHash::operator=(const LazyPropertiesHash &othe } // since we do not allow dematerialization, we only need to lock if not materialized yet - if (other.isMaterialized) { + if (other.isMaterialized.load(std::memory_order_acquire)) { blob = other.blob; properties = other.properties; - isMaterialized = true; + isMaterialized.store(true, std::memory_order_relaxed); } else { QMutexLocker lock(&other.propertiesMutex); blob = other.blob; properties = other.properties; - isMaterialized = false; + isMaterialized.store(false, std::memory_order_relaxed); } return *this; @@ -55,9 +55,15 @@ LazyPropertiesHash &LazyPropertiesHash::operator=(const LazyPropertiesHash &othe void LazyPropertiesHash::ensureMaterialized() const { + // Materialization happens once and is never undone, so the common case can + // skip the mutex entirely once it has happened. + if (isMaterialized.load(std::memory_order_acquire)) { + return; + } + QMutexLocker lock(&propertiesMutex); - if (isMaterialized) { + if (isMaterialized.load(std::memory_order_relaxed)) { return; } @@ -69,7 +75,7 @@ void LazyPropertiesHash::ensureMaterialized() const blob.clear(); - isMaterialized = true; + isMaterialized.store(true, std::memory_order_release); } QString LazyPropertiesHash::value(const QString &key) const diff --git a/libcockatrice_card/libcockatrice/card/lazy_properties_hash.h b/libcockatrice_card/libcockatrice/card/lazy_properties_hash.h index 04f5d0251..9e34050e5 100644 --- a/libcockatrice_card/libcockatrice/card/lazy_properties_hash.h +++ b/libcockatrice_card/libcockatrice/card/lazy_properties_hash.h @@ -5,6 +5,7 @@ #include #include #include +#include /** * @brief A property map that can lazily deserialize blobs to avoid loading overhead. @@ -18,10 +19,10 @@ class LazyPropertiesHash { - mutable QByteArray blob; ///< Serialized properties (load form). - mutable QHash properties; ///< Materialized properties (query form). - mutable QMutex propertiesMutex; ///< Guards lazy materialization. - mutable bool isMaterialized = false; ///< Whether propertiesCache is valid. + mutable QByteArray blob; ///< Serialized properties (load form). + mutable QHash properties; ///< Materialized properties (query form). + mutable QMutex propertiesMutex; ///< Guards lazy materialization. + mutable std::atomic isMaterialized{false}; ///< Whether properties is valid. /** * @brief Materializes properties from blob if not already done. Clears blob afterward. diff --git a/libcockatrice_card/libcockatrice/card/printing/exact_card.h b/libcockatrice_card/libcockatrice/card/printing/exact_card.h index ea49072fb..83ba97bd0 100644 --- a/libcockatrice_card/libcockatrice/card/printing/exact_card.h +++ b/libcockatrice_card/libcockatrice/card/printing/exact_card.h @@ -47,7 +47,7 @@ public: * * May be null if the ExactCard is empty. */ - [[nodiscard]] CardInfoPtr getCardPtr() const + [[nodiscard]] const CardInfoPtr &getCardPtr() const { return card; } @@ -57,7 +57,7 @@ public: * * May be empty if no specific printing was assigned. */ - [[nodiscard]] PrintingInfo getPrinting() const + [[nodiscard]] const PrintingInfo &getPrinting() const { return printing; } diff --git a/libcockatrice_card/libcockatrice/card/printing/printing_info.cpp b/libcockatrice_card/libcockatrice/card/printing/printing_info.cpp index 654efac5e..9440eb4b0 100644 --- a/libcockatrice_card/libcockatrice/card/printing/printing_info.cpp +++ b/libcockatrice_card/libcockatrice/card/printing/printing_info.cpp @@ -17,10 +17,10 @@ void PrintingInfo::setProperty(const QString &_name, const QString &_value) */ QString PrintingInfo::getUuid() const { - return getPropertiesHash().value("uuid"); + return getProperty("uuid"); } QString PrintingInfo::getFlavorName() const { - return getPropertiesHash().value("flavorName"); + return getProperty("flavorName"); } diff --git a/libcockatrice_card/libcockatrice/card/printing/printing_info.h b/libcockatrice_card/libcockatrice/card/printing/printing_info.h index 038960e86..75f634c3c 100644 --- a/libcockatrice_card/libcockatrice/card/printing/printing_info.h +++ b/libcockatrice_card/libcockatrice/card/printing/printing_info.h @@ -107,7 +107,9 @@ public: */ [[nodiscard]] QString getProperty(const QString &propertyName) const { - return properties.value(propertyName); + const auto &propertiesHash = getPropertiesHash(); + const auto it = propertiesHash.constFind(propertyName); + return it != propertiesHash.constEnd() ? it.value() : QString(); } /** diff --git a/libcockatrice_card/libcockatrice/card/set/card_set.cpp b/libcockatrice_card/libcockatrice/card/set/card_set.cpp index e873eeeab..e2b41a811 100644 --- a/libcockatrice_card/libcockatrice/card/set/card_set.cpp +++ b/libcockatrice_card/libcockatrice/card/set/card_set.cpp @@ -34,10 +34,14 @@ CardSetPtr CardSet::newInstance(ICardSetPriorityController *_priorityController, QString CardSet::getCorrectedShortName() const { // For Windows machines. - QSet invalidFileNames; - invalidFileNames << "CON" << "PRN" << "AUX" << "NUL" << "COM1" << "COM2" << "COM3" << "COM4" << "COM5" << "COM6" - << "COM7" << "COM8" << "COM9" << "LPT1" << "LPT2" << "LPT3" << "LPT4" << "LPT5" << "LPT6" << "LPT7" - << "LPT8" << "LPT9"; + static const QSet invalidFileNames = { + QStringLiteral("CON"), QStringLiteral("PRN"), QStringLiteral("AUX"), QStringLiteral("NUL"), + QStringLiteral("COM1"), QStringLiteral("COM2"), QStringLiteral("COM3"), QStringLiteral("COM4"), + QStringLiteral("COM5"), QStringLiteral("COM6"), QStringLiteral("COM7"), QStringLiteral("COM8"), + QStringLiteral("COM9"), QStringLiteral("LPT1"), QStringLiteral("LPT2"), QStringLiteral("LPT3"), + QStringLiteral("LPT4"), QStringLiteral("LPT5"), QStringLiteral("LPT6"), QStringLiteral("LPT7"), + QStringLiteral("LPT8"), QStringLiteral("LPT9"), + }; return invalidFileNames.contains(shortName) ? shortName + "_" : shortName; } diff --git a/libcockatrice_card/libcockatrice/card/set/card_set.h b/libcockatrice_card/libcockatrice/card/set/card_set.h index b9ac74e2d..b995ac88e 100644 --- a/libcockatrice_card/libcockatrice/card/set/card_set.h +++ b/libcockatrice_card/libcockatrice/card/set/card_set.h @@ -239,7 +239,7 @@ public: */ [[nodiscard]] bool getIsKnownIgnored() const { - return longName.length() + setType.length() + releaseDate.toString().length() == 0; + return longName.isEmpty() && setType.isEmpty() && !releaseDate.isValid(); } }; From ad4320cd7b62b2cce20f2150488b6fa0c50a52f9 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:17:31 +0200 Subject: [PATCH 007/108] [CardDB] Stream card properties from blobs (#7608) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [CardDB] Stream card properties from blobs Parsed and cached cards used to keep a materialized QHash for their properties: LazyPropertiesHash only deferred building that hash until the first query, and the cache writer re-serialized it on save. - LazyPropertiesHash answers value(), contains(), size(), isEmpty() and keys() straight out of the serialized blob, so a property query no longer builds a hash. - serialize() is public, and the cache writer passes the stored blob through instead of serializing a hash it just rebuilt. - CardInfo and PrintingInfo expose getPropertiesBlob() and getPropertyCount(); PrintingInfo::operator== compares key by key without materializing either side. - The XML parsers build cards via newInstanceFromPropertiesBlob(), which derives the simple name and the alternate names that the parser used to keep next to the hash. The reload cycle fix stays out of this change; it depends on changing the CardInfo self pointer and is reviewed separately. * [CardDB] Address review comments on streaming properties * [CardDB] Address final review nits on property streaming Move BlobReader and the blob accessors into LazyPropertiesHash as private members so the parsing helper is declared with the class instead of hiding in the translation unit. Add LazyPropertiesHash::serializedFrom() and use it where a PrintingInfo is built from a fresh property hash, replacing the nested LazyPropertiesHash(LazyPropertiesHash::serialize(props)) construction. The pre-materialized QHash constructor stays: CardInfo's non-blob factory still uses it for the oracle import path, and the tests cover it directly. --------- Co-authored-by: Lukas Brübach --- .../libcockatrice/card/card_info.cpp | 12 +- .../libcockatrice/card/card_info.h | 60 +++-- .../card/database/card_database_cache.cpp | 13 +- .../card/database/parser/cockatrice_xml_3.cpp | 7 +- .../card/database/parser/cockatrice_xml_4.cpp | 8 +- .../card/lazy_properties_hash.cpp | 213 +++++++++++++++++- .../libcockatrice/card/lazy_properties_hash.h | 128 ++++++++++- .../card/printing/printing_info.cpp | 17 ++ .../card/printing/printing_info.h | 26 ++- oracle/src/oracleimporter.cpp | 2 +- tests/carddatabase/carddatabase_test.cpp | 46 ++++ tests/carddatabase/load_benchmark.cpp | 2 +- 12 files changed, 475 insertions(+), 59 deletions(-) diff --git a/libcockatrice_card/libcockatrice/card/card_info.cpp b/libcockatrice_card/libcockatrice/card/card_info.cpp index ae2382166..c90027442 100644 --- a/libcockatrice_card/libcockatrice/card/card_info.cpp +++ b/libcockatrice_card/libcockatrice/card/card_info.cpp @@ -67,10 +67,16 @@ CardInfo::CardInfo(const QString &_name, reverseRelatedCards(_reverseRelatedCards), setsToPrintings(std::move(_sets)), uiAttributes(_uiAttributes), altNames(std::move(_altNames)) { - // propertiesBlob is materialized lazily on first query; simpleName and - // altNames are supplied by the caller (binary cache). Only the set-name - // display list still depends on the current enabled-set state. + // propertiesBlob is materialized lazily on first query. The binary cache supplies simpleName + // and altNames to skip the Unicode normalization and the printing scan; the XML parsers leave + // them empty and get the same derived state the property-hash constructor computes. + if (simpleName.isEmpty()) { + simpleName = simplifyName(name); + } refreshCachedSetNames(); + if (altNames.isEmpty()) { + refreshCachedAltNames(); + } } CardInfoPtr CardInfo::newInstance(const QString &_name) diff --git a/libcockatrice_card/libcockatrice/card/card_info.h b/libcockatrice_card/libcockatrice/card/card_info.h index c0dccb885..77ddce5ce 100644 --- a/libcockatrice_card/libcockatrice/card/card_info.h +++ b/libcockatrice_card/libcockatrice/card/card_info.h @@ -121,14 +121,12 @@ public: QMap _localizedTexts = {}); /** - * @brief Constructs a CardInfo from a cache snapshot with precomputed derived - * state. + * @brief Constructs a CardInfo from a serialized properties blob. * - * Used by the binary cache reader to skip recomputing @p _simpleName and - * @p _altNames (which otherwise require a Unicode normalization and a full - * printing scan). Properties are supplied as a pre-serialized @p _propertiesBlob - * so the QHash is not built at load time (it is materialized on first - * query). + * Properties stay in @p _propertiesBlob so the QHash is not + * built at load time (it is materialized on first query). @p _simpleName and + * @p _altNames are filled in when empty; the binary cache supplies them to + * skip the Unicode normalization and the printing scan. * * @param _name The card name. * @param _text Rules text or description of the card. @@ -138,8 +136,8 @@ public: * @param _reverseRelatedCards Reverse relationships. * @param _sets Printing information per set. * @param _uiAttributes Attributes that affect display and game logic. - * @param _simpleName Precomputed simplified name. - * @param _altNames Precomputed alternate names. + * @param _simpleName Precomputed simplified name; derived from @p _name when empty. + * @param _altNames Precomputed alternate names; derived from the printings when empty. * @param _localizedNames Localized card names, keyed by language code. * @param _localizedTexts Localized rules text, keyed by language code. */ @@ -210,20 +208,26 @@ public: QMap _localizedTexts = {}); /** - * @brief Creates a new instance from a cache snapshot with precomputed - * derived state. + * @brief Creates a new instance from a serialized properties blob. + * + * The properties stay in blob form, so the QHash is not + * built at load time (it is materialized on first query). The derived state + * is required so it is never recomputed here, and the overload cannot be a + * candidate for a shorter call that passes a property hash instead of a blob. * * @param _name Name of the card. * @param _text Rules text or description. * @param _isToken Token flag. - * @param _propertiesBlob Pre-serialized properties blob (as written by the cache). + * @param _propertiesBlob Serialized properties blob. * @param _relatedCards Forward relationships. * @param _reverseRelatedCards Reverse relationships. * @param _sets Printing information per set. * @param _uiAttributes Attributes that affect display and game logic. - * @param _simpleName Precomputed simplified name. - * @param _altNames Precomputed alternate names. - * @param _appendToSets When true (default), the card is appended to each of + * @param _simpleName Precomputed simplified name; pass {} to derive it from + * @p _name. + * @param _altNames Precomputed alternate names; pass {} to derive them from + * the printings. + * @param _appendToSets When true, the card is appended to each of * its CardSets. Pass false when building cards in parallel so the * (non-thread-safe) set membership is populated in a later * single-threaded pass. @@ -241,7 +245,7 @@ public: UiAttributes _uiAttributes, QString _simpleName, QSet _altNames, - bool _appendToSets = true, + bool _appendToSets, QMap _localizedNames = {}, QMap _localizedTexts = {}); @@ -389,20 +393,34 @@ public: } [[nodiscard]] QStringList getProperties() const { - return getPropertiesHash().keys(); + return properties.keys(); } + + /** + * @brief Returns the properties in their serialized blob form. + * + * Cheap while the properties were never materialized (the cache and the XML parsers pass the blob through). + */ + [[nodiscard]] QByteArray getPropertiesBlob() const + { + return properties.serialized(); + } + + /** + * @brief Returns the materialized property hash, forcing deserialization of the blob. + * + * Prefer getProperty(), hasProperty() or getPropertiesBlob(), which avoid building the hash. + */ [[nodiscard]] const QHash &getPropertiesHash() const; [[nodiscard]] QString getProperty(const QString &propertyName) const { - const auto &propertiesHash = getPropertiesHash(); - const auto it = propertiesHash.constFind(propertyName); - return it != propertiesHash.constEnd() ? it.value() : QString(); + return properties.value(propertyName); } void setProperty(const QString &_name, const QString &_value); [[nodiscard]] bool hasProperty(const QString &propertyName) const { - return getPropertiesHash().contains(propertyName); + return properties.contains(propertyName); } [[nodiscard]] const SetToPrintingsMap &getSets() const { diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp b/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp index 42658dd6b..611b24ad8 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp +++ b/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp @@ -53,13 +53,10 @@ QString readString(QDataStream &in) // Stores a QHash as a single pre-serialized blob. The reader keeps the // blob as-is and materializes the QHash lazily on first query, which is // what removes the allocation storm from database load (see card_info.cpp / -// printing_info.cpp). -void writeHashBlob(QDataStream &out, const QHash &h) +// printing_info.cpp). Writing the already-serialized blob also keeps cache writes from +// materializing every card's property hash. +void writeBlob(QDataStream &out, const QByteArray &blob) { - QByteArray blob; - QDataStream blobOut(&blob, QIODevice::WriteOnly); - blobOut.setVersion(QDataStream::Qt_6_4); - blobOut << h; out << static_cast(blob.size()); out.writeRawData(blob.constData(), blob.size()); } @@ -156,7 +153,7 @@ CardRelation *readRelation(QDataStream &in) void writePrinting(QDataStream &out, const PrintingInfo &p) { writeString(out, p.getSet() ? p.getSet()->getShortName() : QString()); - writeHashBlob(out, p.getPropertiesHash()); + writeBlob(out, p.getPropertiesBlob()); } PrintingInfo readPrinting(QDataStream &in, const SetNameMap &sets) @@ -198,7 +195,7 @@ void writeCard(QDataStream &out, const CardInfoPtr &card) writeString(out, card->getName()); writeString(out, card->getText()); out << card->getIsToken(); - writeHashBlob(out, card->getPropertiesHash()); + writeBlob(out, card->getPropertiesBlob()); CardInfo::UiAttributes ui = card->getUiAttributes(); out << ui.cipt; diff --git a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp index 33a0e0849..d40c1c063 100644 --- a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp +++ b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp @@ -268,7 +268,7 @@ void CockatriceXml3Parser::loadCardsFromXml(QXmlStreamReader &xml) if (attrs.hasAttribute("rarity")) { printingProps.insert("rarity", attrs.value("rarity").toString()); } - PrintingInfo setInfo(set, LazyPropertiesHash(printingProps)); + PrintingInfo setInfo(set, LazyPropertiesHash::serializedFrom(printingProps)); _sets[setName].append(setInfo); } // related cards @@ -325,8 +325,9 @@ void CockatriceXml3Parser::loadCardsFromXml(QXmlStreamReader &xml) .landscapeOrientation = landscapeOrientation, .tableRow = tableRow, .upsideDownArt = upsideDown}; - CardInfoPtr newCard = CardInfo::newInstance(name, text, isToken, properties, relatedCards, - reverseRelatedCards, _sets, attributes); + CardInfoPtr newCard = + CardInfo::newInstance(name, text, isToken, LazyPropertiesHash::serialize(properties), relatedCards, + reverseRelatedCards, _sets, attributes, {}, {}, true); if (targetData) { if (auto existing = targetData->cards.value(name)) { for (const auto &printings : newCard->getSets()) { diff --git a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp index 4d975e533..3ee1e808e 100644 --- a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp +++ b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp @@ -383,7 +383,7 @@ void CockatriceXml4Parser::loadCardsFromXml(QXmlStreamReader &xml) } printingProps.insert(attrName, attr.value().toString()); } - PrintingInfo printingInfo(set, LazyPropertiesHash(printingProps)); + PrintingInfo printingInfo(set, LazyPropertiesHash::serializedFrom(printingProps)); _sets[setName].append(printingInfo); } // related cards @@ -450,9 +450,9 @@ void CockatriceXml4Parser::loadCardsFromXml(QXmlStreamReader &xml) .landscapeOrientation = landscapeOrientation, .tableRow = tableRow, .upsideDownArt = upsideDown}; - CardInfoPtr newCard = - CardInfo::newInstance(name, text, isToken, properties, relatedCards, reverseRelatedCards, _sets, - attributes, std::move(localizedNames), std::move(localizedTexts)); + CardInfoPtr newCard = CardInfo::newInstance(name, text, isToken, LazyPropertiesHash::serialize(properties), + relatedCards, reverseRelatedCards, _sets, attributes, {}, {}, + true, std::move(localizedNames), std::move(localizedTexts)); if (targetData) { // Mirror CardDatabase::addCard: if a card with this name already // exists, merge the new printings into it instead of replacing. diff --git a/libcockatrice_card/libcockatrice/card/lazy_properties_hash.cpp b/libcockatrice_card/libcockatrice/card/lazy_properties_hash.cpp index 90628224a..3798bcd62 100644 --- a/libcockatrice_card/libcockatrice/card/lazy_properties_hash.cpp +++ b/libcockatrice_card/libcockatrice/card/lazy_properties_hash.cpp @@ -1,8 +1,168 @@ #include "lazy_properties_hash.h" #include +#include #include -#include + +namespace +{ +constexpr quint32 NULL_STRING_LENGTH = 0xFFFFFFFFu; + +/** + * @brief Reads a QDataStream-serialized QString length at @p pos, advancing past the length field. + * + * QDataStream writes a QString as a big-endian quint32 byte length followed by UTF-16 code units (also big-endian), + * or 0xFFFFFFFF for a null string. Parsing at the byte level lets us skip and compare properties without + * constructing a QString for every entry. + */ +bool readBlobLength(const QByteArray &blob, int &pos, quint32 &length) +{ + if (pos < 0 || static_cast(pos) + 4 > blob.size()) { + return false; + } + + const auto *bytes = reinterpret_cast(blob.constData()) + pos; + length = (quint32(bytes[0]) << 24) | (quint32(bytes[1]) << 16) | (quint32(bytes[2]) << 8) | quint32(bytes[3]); + pos += 4; + + if (length == NULL_STRING_LENGTH) { + length = 0; + return true; + } + + if (length > static_cast(blob.size() - pos)) { + return false; + } + + return true; +} + +/** + * @brief Compares the serialized bytes at @p pos with @p key without allocating a QString. + */ +bool blobKeyEquals(const QByteArray &blob, int pos, quint32 length, const QString &key) +{ + if (length != static_cast(key.size()) * 2) { + return false; + } + + const auto *bytes = reinterpret_cast(blob.constData()) + pos; + for (int i = 0; i < key.size(); ++i) { + const ushort codeUnit = static_cast((bytes[2 * i] << 8) | bytes[2 * i + 1]); + if (key.at(i).unicode() != codeUnit) { + return false; + } + } + + return true; +} + +/** + * @brief Builds a QString from the serialized UTF-16BE bytes at @p pos. + */ +QString blobString(const QByteArray &blob, int pos, quint32 length) +{ + const int codeUnits = static_cast(length / 2); + QString result; + result.resize(codeUnits); + + const auto *bytes = reinterpret_cast(blob.constData()) + pos; + for (int i = 0; i < codeUnits; ++i) { + result[i] = QChar(static_cast((bytes[2 * i] << 8) | bytes[2 * i + 1])); + } + + return result; +} +} // namespace + +LazyPropertiesHash::BlobReader::BlobReader(const QByteArray &_blob) : blob(_blob) +{ + if (blob.size() < 4) { + return; + } + + const auto *bytes = reinterpret_cast(blob.constData()); + remaining = (quint32(bytes[0]) << 24) | (quint32(bytes[1]) << 16) | (quint32(bytes[2]) << 8) | quint32(bytes[3]); + valid = true; +} + +bool LazyPropertiesHash::BlobReader::next(int &keyPos, quint32 &keyLength, int &valuePos, quint32 &valueLength) +{ + if (!valid || remaining == 0) { + return false; + } + + if (!readBlobLength(blob, pos, keyLength)) { + valid = false; + return false; + } + keyPos = pos; + pos += static_cast(keyLength); + + if (!readBlobLength(blob, pos, valueLength)) { + valid = false; + return false; + } + valuePos = pos; + pos += static_cast(valueLength); + + --remaining; + return true; +} + +quint32 LazyPropertiesHash::BlobReader::entryCount() const +{ + return valid ? remaining : 0; +} + +QString LazyPropertiesHash::blobValue(const QByteArray &blob, const QString &key) +{ + BlobReader reader(blob); + int keyPos = 0; + int valuePos = 0; + quint32 keyLength = 0; + quint32 valueLength = 0; + while (reader.next(keyPos, keyLength, valuePos, valueLength)) { + if (blobKeyEquals(blob, keyPos, keyLength, key)) { + return blobString(blob, valuePos, valueLength); + } + } + + return {}; +} + +bool LazyPropertiesHash::blobContains(const QByteArray &blob, const QString &key) +{ + BlobReader reader(blob); + int keyPos = 0; + int valuePos = 0; + quint32 keyLength = 0; + quint32 valueLength = 0; + while (reader.next(keyPos, keyLength, valuePos, valueLength)) { + if (blobKeyEquals(blob, keyPos, keyLength, key)) { + return true; + } + } + + return false; +} + +QStringList LazyPropertiesHash::blobKeys(const QByteArray &blob) +{ + BlobReader reader(blob); + QStringList result; + result.reserve(static_cast(reader.entryCount())); + + int keyPos = 0; + int valuePos = 0; + quint32 keyLength = 0; + quint32 valueLength = 0; + while (reader.next(keyPos, keyLength, valuePos, valueLength)) { + result.append(blobString(blob, keyPos, keyLength)); + } + + return result; +} LazyPropertiesHash::LazyPropertiesHash() : isMaterialized(true) { @@ -78,10 +238,57 @@ void LazyPropertiesHash::ensureMaterialized() const isMaterialized.store(true, std::memory_order_release); } +QByteArray LazyPropertiesHash::serialize(const QHash &properties) +{ + QByteArray result; + QDataStream out(&result, QIODevice::WriteOnly); + out.setVersion(QDataStream::Qt_6_4); + out << properties; + return result; +} + +LazyPropertiesHash LazyPropertiesHash::serializedFrom(const QHash &properties) +{ + return LazyPropertiesHash(serialize(properties)); +} + QString LazyPropertiesHash::value(const QString &key) const { - ensureMaterialized(); - return properties.value(key); + return read([&key](const QHash &materialized) { return materialized.value(key); }, + [&key](const QByteArray &blob) { return LazyPropertiesHash::blobValue(blob, key); }); +} + +bool LazyPropertiesHash::contains(const QString &key) const +{ + return read([&key](const QHash &materialized) { return materialized.contains(key); }, + [&key](const QByteArray &blob) { return LazyPropertiesHash::blobContains(blob, key); }); +} + +int LazyPropertiesHash::size() const +{ + return read([](const QHash &materialized) { return static_cast(materialized.size()); }, + [](const QByteArray &blob) { return static_cast(BlobReader(blob).entryCount()); }); +} + +bool LazyPropertiesHash::isEmpty() const +{ + return size() == 0; +} + +QStringList LazyPropertiesHash::keys() const +{ + return read([](const QHash &materialized) { return QStringList(materialized.keys()); }, + [](const QByteArray &blob) { return blobKeys(blob); }); +} + +QByteArray LazyPropertiesHash::serialized() const +{ + return read([](const QHash &materialized) { return serialize(materialized); }, + [](const QByteArray &blob) { + // An empty blob is the "unknown/absent" form used by failed cache reads; give callers a + // well-formed serialization of the (empty) property map instead. + return blob.isEmpty() ? serialize({}) : blob; + }); } bool LazyPropertiesHash::insert(const QString &key, const QString &value) diff --git a/libcockatrice_card/libcockatrice/card/lazy_properties_hash.h b/libcockatrice_card/libcockatrice/card/lazy_properties_hash.h index 9e34050e5..c8170b7c2 100644 --- a/libcockatrice_card/libcockatrice/card/lazy_properties_hash.h +++ b/libcockatrice_card/libcockatrice/card/lazy_properties_hash.h @@ -4,20 +4,62 @@ #include #include #include +#include #include +#include #include /** * @brief A property map that can lazily deserialize blobs to avoid loading overhead. * - * Properties are stored as a pre-serialized blob (cheap to load), and the QString is materialized on the first query, - * so the database load avoids constructing thousands of QString per card. + * Properties are stored as a pre-serialized blob (cheap to load), and individual values are read straight out of that + * blob, so the database load and ordinary queries avoid constructing thousands of QString per card. * - * Once the properties are materialized, it cannot be unmaterialized. - * If you want to reset the properties to an unmaterialized state, you should create a new LazyPropertiesHash. + * A full QHash is only built on the first insert() or getProperties() call. Once materialized, it + * cannot be unmaterialized; create a new LazyPropertiesHash to return to the blob form. */ class LazyPropertiesHash { + /** + * @brief Iterates over the key/value pairs of a serialized QHash blob without materializing it. + * + * The blob layout is the one produced by QDataStream for QHash: a quint32 entry count followed by each entry's + * serialized key and value. + */ + class BlobReader + { + public: + explicit BlobReader(const QByteArray &_blob); + + /** + * @brief Reads the next pair's offsets. + * @return False when there are no more pairs or the blob is malformed. + */ + bool next(int &keyPos, quint32 &keyLength, int &valuePos, quint32 &valueLength); + + [[nodiscard]] quint32 entryCount() const; + + private: + const QByteArray &blob; + int pos = 4; + quint32 remaining = 0; + bool valid = false; + }; + + /** + * @brief Looks up @p key in a serialized blob without materializing it. + */ + static QString blobValue(const QByteArray &blob, const QString &key); + + /** + * @brief Checks whether @p key is present in a serialized blob without materializing it. + */ + static bool blobContains(const QByteArray &blob, const QString &key); + + /** + * @brief Lists the keys of a serialized blob without materializing it. + */ + static QStringList blobKeys(const QByteArray &blob); mutable QByteArray blob; ///< Serialized properties (load form). mutable QHash properties; ///< Materialized properties (query form). @@ -30,6 +72,32 @@ class LazyPropertiesHash */ void ensureMaterialized() const; + /** + * @brief Runs the accessor for the current storage form. + * + * The common case skips the mutex once the properties are materialized. While they are + * still in blob form, the mutex is taken and @p onBlob reads the values straight out of + * the blob without building the hash, so ordinary queries stay allocation-light. + * + * @param onMaterialized Called with the materialized hash when it is available. + * @param onBlob Called with the blob when it is still the source of truth. + * @return Whatever the invoked accessor returns; both accessors must return the same type. + */ + template + auto read(MaterializedFn &&onMaterialized, BlobFn &&onBlob) const + { + if (isMaterialized.load(std::memory_order_acquire)) { + return onMaterialized(properties); + } + + QMutexLocker lock(&propertiesMutex); + if (isMaterialized.load(std::memory_order_relaxed)) { + return onMaterialized(properties); + } + + return onBlob(blob); + } + public: /** * @brief Default constructor. @@ -53,12 +121,60 @@ public: LazyPropertiesHash &operator=(const LazyPropertiesHash &other); /** - * @brief Gets the value from the materialized properties hash + * @brief Serializes a property map into the blob form understood by this class. + * @param properties The properties to serialize + * @return The serialized blob + */ + static QByteArray serialize(const QHash &properties); + + /** + * @brief Creates an unmaterialized LazyPropertiesHash from a property map. + * + * Serializes @p properties once so queries read them from the blob form instead of first materializing a second + * copy of the hash. + * + * @param properties The properties to serialize + * @return An unmaterialized LazyPropertiesHash holding the serialized properties + */ + static LazyPropertiesHash serializedFrom(const QHash &properties); + + /** + * @brief Gets the value for a key, reading it from the blob when possible. * @param key The key * @return The value, or an empty string if the key is not present */ QString value(const QString &key) const; + /** + * @brief Checks whether a key is present, reading the blob when possible. + * @param key The key + * @return True if the key is present + */ + bool contains(const QString &key) const; + + /** + * @brief Returns the number of properties. + */ + int size() const; + + /** + * @brief Returns whether there are no properties. + */ + bool isEmpty() const; + + /** + * @brief Returns the property names, reading them from the blob when possible. + */ + QStringList keys() const; + + /** + * @brief Returns the properties in serialized blob form. + * + * While the object is unmaterialized this is a cheap copy of the stored blob; after materialization the hash is + * serialized on demand. + */ + QByteArray serialized() const; + /** * @brief Inserts a value into the materialized properties hash * @param key The key @@ -68,7 +184,7 @@ public: bool insert(const QString &key, const QString &value); /** - * @brief Gets a view of the materialized properties hash. + * @brief Gets a view of the materialized properties hash, materializing it if needed. * @return The properties hash */ const QHash &getProperties() const; diff --git a/libcockatrice_card/libcockatrice/card/printing/printing_info.cpp b/libcockatrice_card/libcockatrice/card/printing/printing_info.cpp index 9440eb4b0..9bc62a07c 100644 --- a/libcockatrice_card/libcockatrice/card/printing/printing_info.cpp +++ b/libcockatrice_card/libcockatrice/card/printing/printing_info.cpp @@ -12,6 +12,23 @@ void PrintingInfo::setProperty(const QString &_name, const QString &_value) properties.insert(_name, _value); } +bool PrintingInfo::operator==(const PrintingInfo &other) const +{ + if (set != other.set || properties.size() != other.properties.size()) { + return false; + } + + // Both property maps are usually still in blob form here, so compare key by key instead of + // materializing two full hashes just to compare them. + for (const QString &key : properties.keys()) { + if (!other.properties.contains(key) || properties.value(key) != other.properties.value(key)) { + return false; + } + } + + return true; +} + /** * Gets the uuid property of the printing, or an empty string if the property isn't present */ diff --git a/libcockatrice_card/libcockatrice/card/printing/printing_info.h b/libcockatrice_card/libcockatrice/card/printing/printing_info.h index 75f634c3c..dcfcc846b 100644 --- a/libcockatrice_card/libcockatrice/card/printing/printing_info.h +++ b/libcockatrice_card/libcockatrice/card/printing/printing_info.h @@ -54,10 +54,7 @@ public: * @param other Another PrintingInfo to compare against. * @return True if both set and properties are equal, otherwise false. */ - bool operator==(const PrintingInfo &other) const - { - return this->set == other.set && this->getPropertiesHash() == other.getPropertiesHash(); - } + bool operator==(const PrintingInfo &other) const; /** * @brief check if the info is empty, as if default constructed. @@ -66,7 +63,7 @@ public: */ bool isEmpty() const { - return set == nullptr && getPropertiesHash().isEmpty(); + return set == nullptr && properties.isEmpty(); } private: @@ -91,9 +88,22 @@ public: */ [[nodiscard]] QStringList getProperties() const { - return getPropertiesHash().keys(); + return properties.keys(); } + /** + * @brief Returns the printing properties in their serialized blob form. + */ + [[nodiscard]] QByteArray getPropertiesBlob() const + { + return properties.serialized(); + } + + /** + * @brief Returns the materialized property hash, forcing deserialization of the blob. + * + * Prefer getProperty() or getPropertiesBlob(), which avoid building the hash. + */ [[nodiscard]] const QHash &getPropertiesHash() const { return properties.getProperties(); @@ -107,9 +117,7 @@ public: */ [[nodiscard]] QString getProperty(const QString &propertyName) const { - const auto &propertiesHash = getPropertiesHash(); - const auto it = propertiesHash.constFind(propertyName); - return it != propertiesHash.constEnd() ? it.value() : QString(); + return properties.value(propertyName); } /** diff --git a/oracle/src/oracleimporter.cpp b/oracle/src/oracleimporter.cpp index 3382e36d6..477c54d21 100644 --- a/oracle/src/oracleimporter.cpp +++ b/oracle/src/oracleimporter.cpp @@ -486,7 +486,7 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson } } - PrintingInfo printingInfo(currentSet, LazyPropertiesHash(printingProps)); + PrintingInfo printingInfo(currentSet, LazyPropertiesHash::serializedFrom(printingProps)); QString numComponent; const QString numProperty = printingInfo.getProperty("num"); diff --git a/tests/carddatabase/carddatabase_test.cpp b/tests/carddatabase/carddatabase_test.cpp index 2b216d5b6..1b68b8166 100644 --- a/tests/carddatabase/carddatabase_test.cpp +++ b/tests/carddatabase/carddatabase_test.cpp @@ -62,6 +62,43 @@ TEST(CardDatabaseTest, LoadXml) ASSERT_EQ(NotLoaded, db->getLoadStatus()) << "Incorrect status after clear"; } +TEST(CardDatabaseTest, LazyPropertiesHashStreamsBlobAccessors) +{ + QHash props; + props.insert("manacost", "1R"); + props.insert("colors", "R"); + props.insert("format-standard", "legal"); + + LazyPropertiesHash lazy = LazyPropertiesHash::serializedFrom(props); + const QByteArray blob = lazy.serialized(); + + EXPECT_EQ(3, lazy.size()); + EXPECT_FALSE(lazy.isEmpty()); + EXPECT_TRUE(lazy.contains("colors")); + EXPECT_FALSE(lazy.contains("missing")); + EXPECT_EQ("1R", lazy.value("manacost")); + EXPECT_EQ(QString(), lazy.value("missing")); + EXPECT_EQ(blob, lazy.serialized()) << "an untouched blob must be passed through unchanged"; + + QStringList keys = lazy.keys(); + keys.sort(); + QStringList expectedKeys = props.keys(); + expectedKeys.sort(); + EXPECT_EQ(expectedKeys, keys); + + // Mutating materializes the hash, after which every accessor keeps working. + EXPECT_TRUE(lazy.insert("loyalty", "3")); + EXPECT_FALSE(lazy.insert("loyalty", "3")); + EXPECT_EQ(4, lazy.size()); + EXPECT_EQ("3", lazy.value("loyalty")); + EXPECT_TRUE(lazy.contains("loyalty")); + + LazyPropertiesHash roundTrip(lazy.serialized()); + EXPECT_EQ(4, roundTrip.size()); + EXPECT_EQ("3", roundTrip.value("loyalty")); + EXPECT_EQ("legal", roundTrip.value("format-standard")); +} + TEST(CardDatabaseTest, GuessCardWithProviderIdOnUnknownCardReturnsEmpty) { // An unknown name plus a providerId used to dereference the missing card @@ -104,6 +141,8 @@ TEST(CardDatabaseTest, Xml4LocalizedDataRoundTrip) CardInfo::newInstance("Lightning Bolt", "Deal 3 damage.", false, {}, {}, {}, setsInfo, attributes); card->setLocalizedName("de", "Blitzschlag"); card->setLocalizedText("de", "Blitzschlag fügt 3 Schadenspunkte zu."); + card->setProperty("format-standard", "legal"); + card->setProperty("colors", "R"); SetNameMap sets; sets.insert("TST", set); @@ -127,6 +166,13 @@ TEST(CardDatabaseTest, Xml4LocalizedDataRoundTrip) ASSERT_EQ(loaded->getLocalizedName("de"), "Blitzschlag"); ASSERT_EQ(loaded->getLocalizedText("de"), "Blitzschlag fügt 3 Schadenspunkte zu."); ASSERT_EQ(loaded->getLocalizedText("fr"), "Deal 3 damage."); + ASSERT_EQ(loaded->getProperty("format-standard"), "legal"); + ASSERT_TRUE(loaded->hasProperty("colors")); + ASSERT_EQ(loaded->getProperties().size(), 2); + + const QList printings = loaded->getSets().value("TST"); + ASSERT_FALSE(printings.isEmpty()); + ASSERT_EQ(printings.first().getProperty("manacost"), "1R"); } /** diff --git a/tests/carddatabase/load_benchmark.cpp b/tests/carddatabase/load_benchmark.cpp index 5492b9531..da077a50f 100644 --- a/tests/carddatabase/load_benchmark.cpp +++ b/tests/carddatabase/load_benchmark.cpp @@ -125,7 +125,7 @@ RunResult runLoad(CardDatabase *db, ChurnCounters &counters) result.churn.modelResets = counters.modelResets - before.modelResets; for (const CardInfoPtr &card : db->getCardList()) { - result.propTotal += card->getPropertiesHash().size(); + result.propTotal += card->getProperties().size(); if (!card->getProperty("colors").isEmpty()) { ++result.propsQueried; } From bbc6003f0a739c8a655e172717ab0a0db7aa9d0a Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:46:54 +0200 Subject: [PATCH 008/108] [CrashReporting] Name the files and processes behind a failed installer purge (#7590) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [CrashReporting] Log which files and processes blocked the installer purge A purge that fails aborts the upgrade with a message that can only guess at the cause, and the log only listed which DLLs survived. Record what Windows reports for each survivor and which processes still have the install directory mapped, then repeat the holder list in the abort message. The diagnostics are additive and run only on the abort path: they open each survivor for DELETE access and close it again without deleting, so a failed upgrade can never end up half-removed. The purge itself is unchanged. * [CrashReporting] Name the holders of a failed purge with Restart Manager The process-and-module scan could only name a holder whose image lives in the install directory or that loaded one of its DLLs, so the case this diagnostic exists for - an antivirus scanner holding a plain handle - was reported as "no holder named, only a code". Reaching the Restart Manager from the NSIS script would have meant marshalling RM_PROCESS_INFO through System::Call as a table of hand-computed offsets, which cannot be checked without a Windows toolchain, and that is why it was dropped when this diagnostics was written. Move the call into tools/installer-holder-probe: a small executable the same Windows build compiles, linking nothing from this project so it still starts when the installed runtime is what the purge could not replace. It rides inside the installer and is extracted to $PLUGINSDIR only when the purge has already failed, so the successful path is unchanged and nothing new is installed. .ci/compile.sh runs it against a file held with FileShare.None on the Windows runners, so the lookup is exercised on real Windows instead of only compiled. * [CrashReporting] Fix the holder probe check and cap the holder line The probe block ran after the package steps, where the working directory is already the build dir, so its $BUILD_DIR-prefixed paths named build/build and the PowerShell file could not be written. Move it ahead of install/package, where the build dir is still the cwd, and name both files relative to it. Guard it on MAKE_NO_CLIENT, since the probe is only built with the client. Cap the rendered holder line at HOLDER_LINE_CHAR_MAX: the count limit alone does not keep long service names inside the NSIS string that also carries the abort text and the log path. Whole entries are kept and the rest counted, with the first holder always shown. Also run cmake-format on the probe's CMakeLists so the lint job accepts it. * [CrashReporting] Report a read-only purge survivor and bound the probe check --------- Co-authored-by: Lukas Brübach --- .ci/compile.sh | 69 +++++++ CMakeLists.txt | 7 + cmake/NSIS.template.in | 185 ++++++++++++++++++ format.sh | 3 +- tests/CMakeLists.txt | 11 ++ tests/holder_line_test.cpp | 102 ++++++++++ tools/installer-holder-probe/CMakeLists.txt | 21 ++ tools/installer-holder-probe/holder_line.cpp | 64 ++++++ tools/installer-holder-probe/holder_line.h | 59 ++++++ tools/installer-holder-probe/main.cpp | 78 ++++++++ .../restart_manager_holders.cpp | 160 +++++++++++++++ .../restart_manager_holders.h | 35 ++++ 12 files changed, 793 insertions(+), 1 deletion(-) create mode 100644 tests/holder_line_test.cpp create mode 100644 tools/installer-holder-probe/CMakeLists.txt create mode 100644 tools/installer-holder-probe/holder_line.cpp create mode 100644 tools/installer-holder-probe/holder_line.h create mode 100644 tools/installer-holder-probe/main.cpp create mode 100644 tools/installer-holder-probe/restart_manager_holders.cpp create mode 100644 tools/installer-holder-probe/restart_manager_holders.h diff --git a/.ci/compile.sh b/.ci/compile.sh index 63c027473..d60683b6e 100755 --- a/.ci/compile.sh +++ b/.ci/compile.sh @@ -459,6 +459,75 @@ if [[ $MAKE_COVERAGE ]]; then echo "::endgroup::" fi +if [[ $RUNNER_OS == Windows && ! $MAKE_NO_CLIENT ]]; then + echo "::group::Probe the installer's holder lookup" + # The working directory is still the build dir here: the package steps below are what + # leave and re-enter it, and that is what made a $BUILD_DIR-prefixed path name + # build/build. Both paths are relative to the dir the probe was built into. + probe="./installer-holder-probe.exe" + if [[ ! -f $probe ]]; then + echo "::error file=$0::installer-holder-probe.exe was not built" + exit 1 + fi + # The probe exists to name a holder that a process-and-module scan cannot see: one + # that has a file open without having loaded it. Holding a temporary DLL with + # FileShare.None is exactly that case, so this asserts the reason the probe was + # added instead of only that the Restart Manager answers at all. It needs no NSIS + # and no packaging, so it runs on every Windows build that gets this far. + probe_check="./holder-probe-check.ps1" + cat >"$probe_check" <<'POWERSHELL' +$ErrorActionPreference = "Stop" +$dir = Join-Path ([IO.Path]::GetTempPath()) "cockatrice-holder-probe-$PID" +Remove-Item -Recurse -Force $dir -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force $dir | Out-Null +$probe = (Resolve-Path -LiteralPath $env:PROBE_EXE).Path + +function Invoke-Probe([string] $guardDir) { + $start = New-Object System.Diagnostics.ProcessStartInfo + $start.FileName = $probe + $start.UseShellExecute = $false + $start.RedirectStandardOutput = $true + $start.EnvironmentVariables["COCKATRICE_GUARD_DIR"] = $guardDir + $process = [System.Diagnostics.Process]::Start($start) + $read = $process.StandardOutput.ReadToEndAsync() + if (-not $process.WaitForExit(20000)) { + try { $process.Kill() } catch { } + throw "the holder probe did not finish within 20 s for $guardDir" + } + $output = $read.Result + $code = $process.ExitCode + $process.Dispose() + return @{ Code = $code; Output = $output } +} + +try { + $file = Join-Path $dir "locked.dll" + [IO.File]::WriteAllText($file, "x") + $handle = [IO.File]::Open($file, "Open", "ReadWrite", "None") + try { + $locked = Invoke-Probe $dir + } finally { + $handle.Dispose() + } + if ($locked.Code -ne 1) { throw "expected exit 1 for a locked file, got $($locked.Code) : $($locked.Output)" } + if ($locked.Output -notmatch "pid\s+$PID\b") { throw "the lookup did not name this process: $($locked.Output)" } + $empty = Join-Path $dir "empty" + New-Item -ItemType Directory -Force $empty | Out-Null + $nothing = Invoke-Probe $empty + if ($nothing.Code -ne 0) { throw "expected exit 0 for a directory with nothing open, got $($nothing.Code) : $($nothing.Output)" } + Write-Host "holder lookup named pid $PID for a locked file and named nothing for an empty directory" +} finally { + Remove-Item -Recurse -Force $dir -ErrorAction SilentlyContinue +} +POWERSHELL + if ! PROBE_EXE="$probe" powershell -NoProfile -File "$probe_check"; then + rm -f "$probe_check" + exit 1 + fi + rm -f "$probe_check" + echo "::endgroup::" +fi + if [[ $MAKE_INSTALL ]]; then echo "::group::Install" cmake --build . --target install --config "$BUILDTYPE" diff --git a/CMakeLists.txt b/CMakeLists.txt index 97b87a1d8..41fe393e7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -431,6 +431,13 @@ if(WITH_CLIENT) set(CPACK_INSTALL_CMAKE_PROJECTS "Cockatrice;Cockatrice;ALL;/" ${CPACK_INSTALL_CMAKE_PROJECTS}) endif() +if(WIN32 AND WITH_CLIENT) + # Runs from the installer's $PLUGINSDIR when a purge fails, and is never installed + # (see LogProcessesUsingInstallDir in cmake/NSIS.template.in), so it has no install + # rules and is not part of the client's staged tree. + add_subdirectory(${CMAKE_SOURCE_DIR}/tools/installer-holder-probe ${CMAKE_BINARY_DIR}/tools/installer-holder-probe) +endif() + if(WITH_ORACLE) add_subdirectory(oracle) set(CPACK_INSTALL_CMAKE_PROJECTS "Oracle;Oracle;ALL;/" ${CPACK_INSTALL_CMAKE_PROJECTS}) diff --git a/cmake/NSIS.template.in b/cmake/NSIS.template.in index 0ef746430..ae156313d 100644 --- a/cmake/NSIS.template.in +++ b/cmake/NSIS.template.in @@ -28,6 +28,11 @@ Var DataBackupFindHandle Var DataBackupFindName Var IsUpgrade Var AbortReason +; Diagnostics for a failed purge: the holder probe in LogProcessesUsingInstallDir writes +; $HolderOutput (one log line) and sets $HolderFound when it named anything. +Var HolderExit +Var HolderOutput +Var HolderFound Var WaitPid Var LauncherExited Var InstallLogPath @@ -1171,6 +1176,154 @@ Function CheckStaleRuntimeMask ${EndIf} FunctionEnd +; Turns a Win32 error code into the wording that goes into the log. +; usage: set $0 to the code, result in $R0. +; +; These are Windows' own names for the codes and deliberately nothing more. A +; code on its own cannot say whether an upgrade was blocked by a running instance +; or by a permission: deleting a file whose image is mapped by another process +; surfaces as ACCESS_DENIED, exactly like a file whose ACL denies delete does. +; LogProcessesUsingInstallDir is what separates the two, which is why it runs +; right after this and why the two are read together. +Function GetWin32ErrorText + ${If} $0 = 2 + StrCpy $R0 "already gone" + ${ElseIf} $0 = 3 + StrCpy $R0 "path not found" + ${ElseIf} $0 = 5 + StrCpy $R0 "access denied" + ${ElseIf} $0 = 19 + StrCpy $R0 "write-protected media" + ${ElseIf} $0 = 32 + StrCpy $R0 "sharing violation" + ${ElseIf} $0 = 33 + StrCpy $R0 "lock violation" + ${ElseIf} $0 = 1224 + StrCpy $R0 "file is mapped in memory" + ${Else} + StrCpy $R0 "windows error" + ${EndIf} +FunctionEnd + +; Records which runtime files the purge left behind, what Windows said about each +; one, and which processes are using the install directory. +; +; Diagnostic only, and only on the abort path: it deletes nothing, so it can never +; turn a failed upgrade into a half-removed old install, and it decides nothing - +; the abort that called it is already decided. It exists because NSIS Delete/RMDir +; only set the error flag: without this, a report of "files of a previous version +; survived the purge" said which files were present but never which one was held, +; and never whether the cause was a lock or a permission. +; +; The probe opens each survivor for DELETE access and then closes it again +; without deleting anything. The code is read by the plugin right after that call +; (?e), so a failure reports what Windows refused; clearing LastError first is +; what makes a zero code mean "this open worked", i.e. the purge-time failure was +; transient (an antivirus scan that has finished, an instance still shutting +; down) rather than a standing condition. The plugin parses the call's arguments +; between the clear and the call, so a non-zero code is reported as what Windows +; said rather than as proof of a cause - the wording below never claims one. +Function LogStaleRuntimeDiagnostics + FindFirst $StaleFindHandle $StaleFindName "$INSTDIR\*.dll" + ${If} $StaleFindHandle != "" + stale_diag_loop: + ; ClearErrors tells a plugin failure apart from an API failure: the System + ; plugin sets the error flag only when it could not make the call at all, + ; in which case it pushes its own message instead of the call's value and + ; the pop below must not run. + ClearErrors + System::Call 'kernel32::SetLastError(i 0)' + System::Call 'kernel32::CreateFileW(w "$INSTDIR\$StaleFindName", i 0x00010000, i 0, p 0, i 3, i 0, p 0) p.R1 ?e' + ${If} ${Errors} + StrCpy $R0 "the delete probe could not be run" + ${Else} + Pop $R2 + ${If} $R2 = 0 + ; A valid handle: close it. The open proves the file is not held + ; open, but not that Delete would succeed: a read-only file opens + ; for DELETE and still fails DeleteFile until the attribute is + ; cleared, which the purge does not do. Report that standing + ; cause instead of calling the failure transient. + System::Call 'kernel32::CloseHandle(p R1)' + System::Call 'kernel32::GetFileAttributesW(w "$INSTDIR\$StaleFindName") i.R3' + ${If} $R3 = -1 + StrCpy $R0 "a delete-access open now succeeds, but its attributes could not be read" + ${Else} + IntOp $R3 $R3 & 1 + ${If} $R3 = 1 + StrCpy $R0 "a delete-access open now succeeds, but it is still read-only, which Delete does not clear" + ${Else} + StrCpy $R0 "a delete-access open now succeeds" + ${EndIf} + ${EndIf} + ${Else} + StrCpy $0 $R2 + Call GetWin32ErrorText + StrCpy $R0 "win32 error $R2: $R0" + ${EndIf} + ${EndIf} + !insertmacro CockLog "stale-runtime: '$StaleFindName' is still present: $R0" + ClearErrors + FindNext $StaleFindHandle $StaleFindName + IfErrors 0 stale_diag_loop + FindClose $StaleFindHandle + ${EndIf} + Call LogProcessesUsingInstallDir +FunctionEnd + +; Names the applications and services that still have the install directory's files +; open or mapped. Writes one line to the log and sets $HolderFound (1 when anything was +; named), so the abort message can repeat that line to the user. +; +; The lookup is a small executable built from tools/installer-holder-probe, extracted to +; $PLUGINSDIR only when the purge has already failed: the successful path pays nothing +; for it, and $PLUGINSDIR takes it away again when the installer exits. It is compiled by +; the same Windows build that compiles this installer, which is what lets it call the +; Restart Manager API directly. The same call made from this script would have to +; marshal RM_PROCESS_INFO through System::Call as a table of hand-computed offsets, and +; that table is precisely what cannot be checked without a Windows toolchain; in +; compiled code the layout belongs to the Windows SDK header. +; +; Restart Manager is what makes the answer complete. Walking processes can only name one +; whose image lives here or that loaded one of these libraries, while Restart Manager +; names a process that holds a plain handle on a file - an antivirus scanner - and names +; services as well as user programs. Its blind spot is a handle taken in kernel mode, +; where there is no process to name; then the codes logged above are the whole story, and +; the probe says so by naming nothing rather than by failing. +; +; $INSTDIR travels through the same COCKATRICE_GUARD_DIR environment variable the +; running-instance guard uses, so a path with spaces or apostrophes never has to be +; embedded in a command line. +Function LogProcessesUsingInstallDir + StrCpy $HolderExit 0 + StrCpy $HolderOutput "" + StrCpy $HolderFound 0 + InitPluginsDir + ; Embedded by the same relative path that resolves NSIS.definitions.nsh above (both + ; are built into the build root, which is three levels up from project.nsi). + File "/oname=$PLUGINSDIR\installer-holder-probe.exe" "..\..\..\installer-holder-probe.exe" + System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$INSTDIR")' + ; /TIMEOUT because this runs on the abort path, ahead of the message box: one call + ; into the Restart Manager cannot take the minute a walk over every process can, but + ; if that service stalls it must not leave the installer hanging with nothing on + ; screen. On a timeout nsExec reports it through the exit code like any other failure. + nsExec::ExecToStack /TIMEOUT=20000 '"$PLUGINSDIR\installer-holder-probe.exe"' + Pop $HolderExit + Pop $HolderOutput + ; The exit code, not the text, decides what happened: it cannot be mangled by what + ; nsExec leaves on the end of the captured output. 1 = the line below names what is + ; holding the files, 0 = nothing holds them any more, anything else = the probe could + ; not answer, and says why in its own output. + ${If} $HolderExit == "1" + StrCpy $HolderFound 1 + ${ElseIf} $HolderExit == "0" + StrCpy $HolderOutput "nothing is holding them any more" + ${Else} + StrCpy $HolderOutput "the holder probe could not run: $HolderOutput" + ${EndIf} + !insertmacro CockLog "holders: $HolderOutput" +FunctionEnd + ; Uninstaller copies of the same routines (the uninstaller gets its own ; function set compiled in, it cannot call the installer functions). Function un.IsAppRunning @@ -1512,6 +1665,26 @@ ${EndIf} ; Remove any runtime DLLs left over from older versions, so a mismatched ; Qt/OpenSSL/protobuf set cannot survive next to the new executables. +; +; A purge that fails is reported with evidence, not only as a survivor list: the +; abort below logs which files survived, what Windows said about each of them and +; which processes still have the install directory mapped (see +; LogStaleRuntimeDiagnostics). +; +; Re-running the running-instance check here was considered and dropped: it would only +; narrow the window the uninstaller ExecWait and the backup/restore copy open between +; .onInit and this point, and on /R it would now abort after the old version has already +; been removed - leaving no install at all. The surviving files are the authority on +; whether anything still holds the directory, and they are measured anyway. +; +; The holder list comes from Restart Manager (RmRegisterResources/RmGetList), the API +; that names every process and service with one of these files open or mapped. It is +; called from tools/installer-holder-probe rather than from this script, because the +; RM_PROCESS_INFO array is a table of hand-computed offsets when it is marshalled +; through the System plugin, and that table is the one thing here that cannot be +; checked without a Windows toolchain. Compiled by the same build that compiles this +; installer, the layout belongs to the Windows SDK, and .ci/compile.sh runs the probe +; on the Windows runners instead of trusting that it compiles. Call IsExistingCockatriceInstall !insertmacro CockLog "section: IsExistingCockatriceInstall=$R0" ; Remembered because the stale-runtime check below must only ever fire for a @@ -1525,6 +1698,11 @@ ${AndIf} $R0 = 1 !insertmacro CockLog "section: non-portable upgrade purge" !insertmacro CockLogListing "before-purge" "*.dll" RMDir /r "$INSTDIR\Plugins" + ; RMDir /r reports a failure only through the error flag, and only as a + ; boolean; the surviving file names come from the listing below. + ${If} ${FileExists} "$INSTDIR\Plugins\*.*" + !insertmacro CockLog "purge: '$INSTDIR\Plugins' could not be removed completely" + ${EndIf} Delete "$INSTDIR\*.dll" !insertmacro CockLogListing "after-purge" "*.dll" !insertmacro CockLogListing "after-plugins" "Plugins\*.*" @@ -1549,6 +1727,10 @@ ${EndIf} ${If} $R0 = 1 !insertmacro CockLog "section: aborting, files of a previous version survived the purge" DetailPrint "Files of a previous version could not be removed; aborting the installation." + ; Who is holding the payload is recorded before the abort, so the log that + ; reaches us answers the question the message can only guess at, and so the + ; message can name the processes to close whenever any were found. + Call LogStaleRuntimeDiagnostics ; The in-app updater runs this installer silent and detached and never looks ; at its exit code, so the user has to be told what happened. A /S install ; without /R is unattended (a package manager, for instance) and a message @@ -1556,6 +1738,9 @@ ${If} $R0 = 1 ; and the exit code. AbortInstall owns that decision. StrCpy $AbortReason \ "The installation could not remove all files of a previous version of Cockatrice, most likely because it is still running or was started as administrator, and was cancelled rather than leave a mismatched install behind.$\r$\n$\r$\nPlease close Cockatrice, Oracle and Servatrice and run the installer again." + ${If} $HolderFound = 1 + StrCpy $AbortReason "$AbortReason$\r$\n$\r$\nStill using the install directory:$\r$\n$HolderOutput" + ${EndIf} Call AbortInstall ${EndIf} diff --git a/format.sh b/format.sh index 9e3a6069b..b4c78543b 100755 --- a/format.sh +++ b/format.sh @@ -19,7 +19,8 @@ libcockatrice_* \ "oracle/src" \ "servatrice/src" \ "cmake/pch" \ -"tests") +"tests" \ +"tools/installer-holder-probe") exclude=("libcockatrice_rng/libcockatrice/rng/sfmt/" \ "libcockatrice_utility/libcockatrice/utility/peglib.h" \ "oracle/src/lzma/" \ diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index aac4d4818..5420c07f5 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -178,6 +178,17 @@ cockatrice_add_test(NAME user_data_backup_test SOURCES user_data_backup_test.cpp cockatrice_add_test(NAME password_hash_test SOURCES password_hash_test.cpp LIBS libcockatrice_utility) cockatrice_add_test(NAME report_categories_test SOURCES report_categories_test.cpp LIBS libcockatrice_utility) cockatrice_add_test(NAME warning_categories_test SOURCES warning_categories_test.cpp LIBS libcockatrice_utility) +# The installer's holder probe is Windows-only, but the wording it produces is plain C++ +# and is what a stuck user has to act on, so it is tested everywhere. +cockatrice_add_test( + NAME + holder_line_test + SOURCES + ${CMAKE_SOURCE_DIR}/tools/installer-holder-probe/holder_line.cpp + holder_line_test.cpp + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/tools/installer-holder-probe +) cockatrice_add_test( NAME playmat_resolver_test diff --git a/tests/holder_line_test.cpp b/tests/holder_line_test.cpp new file mode 100644 index 000000000..8588d8515 --- /dev/null +++ b/tests/holder_line_test.cpp @@ -0,0 +1,102 @@ +#include "holder_line.h" + +#include +#include + +namespace +{ +Holder runningProcess(const std::wstring &imageName, unsigned long processId) +{ + Holder holder; + holder.imageName = imageName; + holder.processId = processId; + return holder; +} + +Holder service(const std::wstring &appName, const std::wstring &shortName, unsigned long processId) +{ + Holder holder; + holder.appName = appName; + holder.serviceShortName = shortName; + holder.processId = processId; + holder.isService = true; + return holder; +} +} // namespace + +TEST(HolderLine, NamesARunningProcessTheWayTaskManagerDoes) +{ + EXPECT_EQ(formatHolder(runningProcess(L"cockatrice.exe", 24340)), + std::wstring(L"cockatrice.exe (pid 24340, running)")); +} + +TEST(HolderLine, NamesAServiceByTheNameTheUserCanSearchFor) +{ + // The image of a service is often a shared host process, so the image name would + // point at the wrong thing; both of the service's own names are kept. + Holder holder = service(L"Sophos Anti-Virus", L"SAVService", 4412); + holder.imageName = L"svchost.exe"; + EXPECT_EQ(formatHolder(holder), std::wstring(L"Sophos Anti-Virus (pid 4412, service SAVService)")); +} + +TEST(HolderLine, FallsBackFromTheImageToTheApplicationName) +{ + // Restart Manager can name a process whose image this one is not allowed to open. + Holder holder; + holder.appName = L"Cockatrice"; + holder.processId = 7; + EXPECT_EQ(formatHolder(holder), std::wstring(L"Cockatrice (pid 7, running)")); +} + +TEST(HolderLine, NamesAnEmptyHolderRatherThanPrintingAnEmptyParenthesis) +{ + Holder holder; + holder.processId = 9; + EXPECT_EQ(formatHolder(holder), std::wstring(L"an unnamed process (pid 9, running)")); +} + +TEST(HolderLine, JoinsHoldersIntoOneLine) +{ + const std::vector holders = { + runningProcess(L"cockatrice.exe", 1), + runningProcess(L"oracle.exe", 2), + }; + EXPECT_EQ(formatHolders(holders), std::wstring(L"cockatrice.exe (pid 1, running); oracle.exe (pid 2, running)")); +} + +TEST(HolderLine, CountsTheHoldersItLeavesOut) +{ + std::vector holders; + for (unsigned long processId = 1; processId <= HOLDER_LINE_MAX + 2; ++processId) { + holders.push_back(runningProcess(L"cockatrice.exe", processId)); + } + const std::wstring line = formatHolders(holders); + // The dialog has to stay readable, but a list that silently stopped at five would + // hide that there is more to close. + EXPECT_NE(line.find(L"pid 5, running"), std::wstring::npos); + EXPECT_EQ(line.find(L"pid 6, running"), std::wstring::npos); + EXPECT_NE(line.find(L"and 2 more"), std::wstring::npos); +} + +TEST(HolderLine, StopsNamingHoldersBeforeTheLineOutgrowsTheDialog) +{ + // A Windows service name can be 255 characters, and the abort dialog has to fit the + // list next to its own text and the log path in a 1024-character NSIS string. + std::vector holders; + for (unsigned long processId = 1; processId <= 3; ++processId) { + holders.push_back(service(std::wstring(255, L'S'), L"S", processId)); + } + const std::wstring line = formatHolders(holders); + // The first holder is still named in full; the others are counted rather than written + // out until the line overflows. + EXPECT_EQ(line.compare(0, 255, std::wstring(255, L'S')), 0); + EXPECT_NE(line.find(L"(pid 1, service S)"), std::wstring::npos); + EXPECT_EQ(line.find(L"pid 2"), std::wstring::npos); + EXPECT_NE(line.find(L"and 2 more"), std::wstring::npos); + EXPECT_LE(line.size(), HOLDER_LINE_CHAR_MAX + 20); +} + +TEST(HolderLine, FormatsNothingAsAnEmptyLine) +{ + EXPECT_EQ(formatHolders({}), std::wstring()); +} diff --git a/tools/installer-holder-probe/CMakeLists.txt b/tools/installer-holder-probe/CMakeLists.txt new file mode 100644 index 000000000..3098669cc --- /dev/null +++ b/tools/installer-holder-probe/CMakeLists.txt @@ -0,0 +1,21 @@ +# Windows-only. The installer runs this on the abort path of a failed purge to name the +# applications and services holding the install directory open (Restart Manager); see +# the LogProcessesUsingInstallDir function in cmake/NSIS.template.in. It ships inside +# the installer and is extracted to $PLUGINSDIR there, never installed, so it links +# nothing from this project: no Qt, and a static C runtime because the installed runtime +# is exactly what the purge may have failed to replace. +add_executable(installer-holder-probe holder_line.cpp main.cpp restart_manager_holders.cpp) + +target_link_libraries(installer-holder-probe PRIVATE rstrtmgr) + +# The NSIS template embeds the built file by the same relative path that resolves +# NSIS.definitions.nsh, and that path points at the build root. +set_target_properties( + installer-holder-probe + PROPERTIES MSVC_RUNTIME_LIBRARY "MultiThreaded" + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}" + RUNTIME_OUTPUT_DIRECTORY_DEBUG "${CMAKE_BINARY_DIR}" + RUNTIME_OUTPUT_DIRECTORY_MINSIZEREL "${CMAKE_BINARY_DIR}" + RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_BINARY_DIR}" + RUNTIME_OUTPUT_DIRECTORY_RELWITHDEBINFO "${CMAKE_BINARY_DIR}" +) diff --git a/tools/installer-holder-probe/holder_line.cpp b/tools/installer-holder-probe/holder_line.cpp new file mode 100644 index 000000000..8d438ca17 --- /dev/null +++ b/tools/installer-holder-probe/holder_line.cpp @@ -0,0 +1,64 @@ +#include "holder_line.h" + +#include + +namespace +{ +std::wstring displayNameOf(const Holder &holder) +{ + // A service's application name is its long name, which is what the user can + // search for; its image name is usually a shared host process (svchost.exe) and + // would name the wrong thing. + if (holder.isService && !holder.appName.empty()) { + return holder.appName; + } + if (!holder.imageName.empty()) { + return holder.imageName; + } + if (!holder.appName.empty()) { + return holder.appName; + } + return L"an unnamed process"; +} +} // namespace + +std::wstring formatHolder(const Holder &holder) +{ + std::wstring line = displayNameOf(holder); + line += L" (pid "; + line += std::to_wstring(holder.processId); + if (holder.isService) { + line += L", service "; + line += holder.serviceShortName.empty() ? L"without a short name" : holder.serviceShortName; + } else { + line += L", running"; + } + line += L")"; + return line; +} + +std::wstring formatHolders(const std::vector &holders) +{ + const std::size_t wanted = std::min(holders.size(), HOLDER_LINE_MAX); + std::wstring line; + std::size_t listed = 0; + for (; listed < wanted; ++listed) { + const std::wstring entry = formatHolder(holders[listed]); + const std::size_t separator = line.empty() ? 0 : 2; + // The first holder is always named, however long its name: it is the answer the + // dialog exists to show, and Windows bounds one entry below the whole budget. + if (!line.empty() && line.size() + separator + entry.size() > HOLDER_LINE_CHAR_MAX) { + break; + } + if (!line.empty()) { + line += L"; "; + } + line += entry; + } + if (holders.size() > listed) { + line += L"; and "; + line += std::to_wstring(holders.size() - listed); + line += L" more"; + } + return line; +} diff --git a/tools/installer-holder-probe/holder_line.h b/tools/installer-holder-probe/holder_line.h new file mode 100644 index 000000000..7fcc92375 --- /dev/null +++ b/tools/installer-holder-probe/holder_line.h @@ -0,0 +1,59 @@ +#ifndef HOLDER_LINE_H +#define HOLDER_LINE_H + +#include +#include +#include + +/** + * @brief One application or service that has an installer file open or mapped. + * + * Deliberately free of Windows types: the wording is what the abort dialog shows, + * and it is the only part of the probe that can be exercised off Windows. + */ +struct Holder +{ + std::wstring imageName; ///< "cockatrice.exe", empty when the image could not be read + std::wstring appName; ///< Restart Manager's name for the application or service + std::wstring serviceShortName; ///< service short name, empty when this is not a service + unsigned long processId = 0; + bool isService = false; +}; + +/// How many holders the single-line summary names before it says how many were left out. +inline constexpr std::size_t HOLDER_LINE_MAX = 5; + +/** + * @brief How many characters the summary may spend on the holders it names. + * + * The line is not the whole string: the abort dialog puts it after a fixed paragraph and + * before the path of the installer log, inside an NSIS string of 1024 characters. Windows + * caps one application or service name well below this budget, so it bounds the list + * without ever cutting a single holder short. + */ +inline constexpr std::size_t HOLDER_LINE_CHAR_MAX = 400; + +/** + * @brief Renders one holder the way the user has to recognise it, e.g. + * "cockatrice.exe (pid 24340, running)". + * + * The image name is preferred over the application name because it is what Task + * Manager shows, and the application name is what remains for a process whose + * image cannot be opened (another account, a protected process). A service is + * named by Restart Manager's long name and identified by its short name, because + * both are how the user finds it again in services.msc. + */ +std::wstring formatHolder(const Holder &holder); + +/** + * @brief Renders every holder as a single "; "-joined line. + * + * One line because it is written to the installer log as one and repeated in a + * message box, where nsExec hands the text back as one string. Only the first + * HOLDER_LINE_MAX are named, and only while they fit in HOLDER_LINE_CHAR_MAX + * characters; the rest are counted, so a machine with a dozen open handles cannot + * push the real answer off the dialog. + */ +std::wstring formatHolders(const std::vector &holders); + +#endif diff --git a/tools/installer-holder-probe/main.cpp b/tools/installer-holder-probe/main.cpp new file mode 100644 index 000000000..3b5bb5d22 --- /dev/null +++ b/tools/installer-holder-probe/main.cpp @@ -0,0 +1,78 @@ +#include "holder_line.h" +#include "restart_manager_holders.h" + +#include +#include +#include + +namespace +{ +/// The installer sets this, the same variable the running-instance guard uses, instead +/// of passing the directory on the command line: a path with spaces, quotes or a +/// trailing backslash then never has to survive NSIS quoting or a command line. +constexpr wchar_t INSTALL_DIR_VARIABLE[] = L"COCKATRICE_GUARD_DIR"; + +/// Exit codes the installer reads. "Something was found" is deliberately not 0, so an +/// answer of "nothing holds the files" stays distinguishable from a probe that failed. +enum ExitCode +{ + EXIT_NO_HOLDERS = 0, + EXIT_HOLDERS_FOUND = 1, + EXIT_NOT_CONFIGURED = 2, + EXIT_PROBE_FAILED = 3, +}; + +/// Writes one line to the pipe nsExec reads. Narrowed to ASCII on purpose: the bytes +/// come back to the installer as an ANSI string, so a non-ASCII process name would be +/// mojibake in the log; a '?' at least stays honest. Control characters are dropped +/// too, so a name can never forge a second log line. +void writeLine(const std::wstring &text) +{ + std::string narrow; + narrow.reserve(text.size() + 2); + for (const wchar_t character : text) { + narrow.push_back(character >= 0x20 && character < 0x7F ? static_cast(character) : '?'); + } + narrow += "\r\n"; + DWORD written = 0; + WriteFile(GetStdHandle(STD_OUTPUT_HANDLE), narrow.data(), static_cast(narrow.size()), &written, nullptr); +} + +std::wstring installDirFromEnvironment() +{ + for (DWORD size = MAX_PATH; size <= 32768U; size *= 2) { + std::vector buffer(size); + const DWORD length = GetEnvironmentVariableW(INSTALL_DIR_VARIABLE, buffer.data(), size); + if (length == 0) { + return {}; + } + if (length < size) { + return std::wstring(buffer.data(), length); + } + } + return {}; +} +} // namespace + +int main() +{ + const std::wstring installDir = installDirFromEnvironment(); + if (installDir.empty()) { + writeLine(L"the probe was not given the install directory"); + return EXIT_NOT_CONFIGURED; + } + + std::vector holders; + const unsigned long error = findProcessesUsingFiles(collectProbeFiles(installDir), holders); + if (error != ERROR_SUCCESS) { + writeLine(L"the Restart Manager lookup failed with win32 error " + std::to_wstring(error)); + return EXIT_PROBE_FAILED; + } + if (holders.empty()) { + // No output: the installer words the "nothing is holding them" case, and it + // owns the message either way. + return EXIT_NO_HOLDERS; + } + writeLine(formatHolders(holders)); + return EXIT_HOLDERS_FOUND; +} diff --git a/tools/installer-holder-probe/restart_manager_holders.cpp b/tools/installer-holder-probe/restart_manager_holders.cpp new file mode 100644 index 000000000..39f6a0049 --- /dev/null +++ b/tools/installer-holder-probe/restart_manager_holders.cpp @@ -0,0 +1,160 @@ +#include "restart_manager_holders.h" + +// clang-format off +// restartmanager.h uses the types from windows.h without including it, so the order +// here is a requirement; Regroup would sort them the other way round. +#include +#include +// clang-format on + +#include +#include + +namespace +{ +/// Stop registering after this many files. The install directory carries around 150 +/// files in practice; the cap only exists so a directory someone has filled with files +/// cannot turn the abort path into a long Restart Manager call. +constexpr std::size_t MAX_PROBED_FILES = 2000; + +/// How deep the Plugins walk goes. Its real shape is Plugins\\.dll. +constexpr int MAX_PLUGIN_DEPTH = 4; + +void collectFilesOf(const std::wstring &directory, int depth, int maxDepth, std::vector &files) +{ + if (files.size() >= MAX_PROBED_FILES) { + return; + } + WIN32_FIND_DATAW entry{}; + const HANDLE find = FindFirstFileW((directory + L"\\*").c_str(), &entry); + if (find == INVALID_HANDLE_VALUE) { + return; + } + do { + if (files.size() >= MAX_PROBED_FILES) { + break; + } + const std::wstring name = entry.cFileName; + if (name == L"." || name == L".." || (entry.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) { + continue; + } + const std::wstring path = directory + L"\\" + name; + if ((entry.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0) { + if (depth < maxDepth) { + collectFilesOf(path, depth + 1, maxDepth, files); + } + } else { + files.push_back(path); + } + } while (FindNextFileW(find, &entry) != 0); + FindClose(find); +} + +std::wstring imagePathOf(unsigned long processId) +{ + // PROCESS_QUERY_LIMITED_INFORMATION is the right to ask, not to debug: it reads the + // path of a process this one does not own, which is what keeps a SYSTEM-owned holder + // from turning the whole list into an error. One that refuses even this is still + // named, by whatever Restart Manager called it. + const HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, processId); + if (process == nullptr) { + return {}; + } + wchar_t path[MAX_PATH * 2] = {}; + DWORD length = static_cast(std::size(path)); + std::wstring imagePath; + if (QueryFullProcessImageNameW(process, 0, path, &length) != 0) { + imagePath.assign(path, length); + } + CloseHandle(process); + return imagePath; +} + +std::wstring fileNameOf(const std::wstring &path) +{ + const std::size_t separator = path.find_last_of(L"\\/"); + return separator == std::wstring::npos ? path : path.substr(separator + 1); +} + +/// Copies a fixed-size, NUL-terminated WCHAR array the API filled in. +template std::wstring fixedString(const wchar_t (&text)[N]) +{ + return std::wstring(text, std::find(text, text + N, L'\0')); +} + +Holder holderOf(const RM_PROCESS_INFO &info) +{ + Holder holder; + holder.processId = info.Process.dwProcessId; + holder.isService = info.ApplicationType == RmService; + holder.appName = fixedString(info.strAppName); + if (holder.isService) { + holder.serviceShortName = fixedString(info.strServiceShortName); + } + holder.imageName = fileNameOf(imagePathOf(holder.processId)); + return holder; +} +} // namespace + +std::vector collectProbeFiles(const std::wstring &installDir) +{ + std::vector files; + // depth 0, maxDepth 0: the install root's files and nothing below it. Its data + // directory holds downloaded pictures, which the purge does not touch and which + // would turn this into a walk over thousands of files. + collectFilesOf(installDir, 0, 0, files); + if (files.size() < MAX_PROBED_FILES) { + collectFilesOf(installDir + L"\\Plugins", 0, MAX_PLUGIN_DEPTH, files); + } + return files; +} + +unsigned long findProcessesUsingFiles(const std::vector &files, std::vector &holders) +{ + holders.clear(); + if (files.empty()) { + return ERROR_SUCCESS; + } + + DWORD session = 0; + wchar_t sessionKey[CCH_RM_SESSION_KEY + 1] = {}; + DWORD error = RmStartSession(&session, 0, sessionKey); + if (error != ERROR_SUCCESS) { + return error; + } + + // One call for the whole set, as the API asks: RmRegisterResources does registry + // work per file and is documented as too expensive to call once per file. + std::vector names; + names.reserve(files.size()); + for (const std::wstring &file : files) { + names.push_back(file.c_str()); + } + error = RmRegisterResources(session, static_cast(names.size()), names.data(), 0, nullptr, 0, nullptr); + + if (error == ERROR_SUCCESS) { + UINT needed = 0; + UINT count = 0; + DWORD rebootReasons = 0; + // The first call asks how large the answer is; Restart Manager reports + // ERROR_MORE_DATA rather than truncating a list that does not fit. + error = RmGetList(session, &needed, &count, nullptr, &rebootReasons); + // Bounded: each retry more than covers the list growing between the two calls, + // and an abort path cannot chase a process that keeps opening files. Giving up + // reports the code, which is a truthful answer instead of a truncated list. + for (int attempt = 0; attempt < 3 && error == ERROR_MORE_DATA; ++attempt) { + count = needed; + std::vector infos(count); + error = RmGetList(session, &needed, &count, infos.data(), &rebootReasons); + if (error == ERROR_SUCCESS) { + holders.reserve(count); + for (UINT index = 0; index < count; ++index) { + holders.push_back(holderOf(infos[index])); + } + } + } + } + + RmEndSession(session); + return error; +} diff --git a/tools/installer-holder-probe/restart_manager_holders.h b/tools/installer-holder-probe/restart_manager_holders.h new file mode 100644 index 000000000..d69fc0259 --- /dev/null +++ b/tools/installer-holder-probe/restart_manager_holders.h @@ -0,0 +1,35 @@ +#ifndef RESTART_MANAGER_HOLDERS_H +#define RESTART_MANAGER_HOLDERS_H + +#include "holder_line.h" + +#include +#include + +/** + * @brief Lists the files a failed purge can be holding up, as full paths. + * + * Everything directly in @p installDir plus everything below its Plugins directory: + * those are the two things the purge removes, so they are the two sets of files a + * holder can be holding. Restart Manager refuses a directory with ERROR_ACCESS_DENIED, + * which is why the probe registers files and this has to name them first. + * + * Missing or unreadable directories are skipped rather than reported, and the list is + * capped: this runs on the abort path and must not walk a tree of downloaded pictures. + */ +std::vector collectProbeFiles(const std::wstring &installDir); + +/** + * @brief Names the applications and services that have one of @p files open or mapped. + * + * Restart Manager reports both a process that loaded one of the files as a module and + * one that merely holds a handle to it, and it reports services, which is the whole + * reason it is used instead of walking every process's module list. + * + * @param holders replaced with one entry per holder, in the order Windows reported them. + * @return 0 when the lookup ran, even if nothing is holding the files; otherwise the + * Win32 code of the Restart Manager call that failed. + */ +unsigned long findProcessesUsingFiles(const std::vector &files, std::vector &holders); + +#endif From 1e0fc35d94aff4a0f89229b80f0b11df4b35809f Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 24 Oct 2026 08:26:02 +0200 Subject: [PATCH 009/108] [QtUtils] Add parent lookup, layout clearing, scroll area and monospace helpers (#7617) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- libcockatrice_utility/CMakeLists.txt | 1 + .../libcockatrice/utility/qt_utils.h | 88 +++++++++++++++++-- 2 files changed, 82 insertions(+), 7 deletions(-) diff --git a/libcockatrice_utility/CMakeLists.txt b/libcockatrice_utility/CMakeLists.txt index 9b21a071f..cc0168cb8 100644 --- a/libcockatrice_utility/CMakeLists.txt +++ b/libcockatrice_utility/CMakeLists.txt @@ -27,6 +27,7 @@ set(UTILITY_HEADERS libcockatrice/utility/macros.h libcockatrice/utility/passwordhasher.h libcockatrice/utility/playmat_params.h + libcockatrice/utility/qt_utils.h libcockatrice/utility/report_categories.h libcockatrice/utility/string_limits.h libcockatrice/utility/dice_limits.h diff --git a/libcockatrice_utility/libcockatrice/utility/qt_utils.h b/libcockatrice_utility/libcockatrice/utility/qt_utils.h index 8e5212031..728f1d99e 100644 --- a/libcockatrice_utility/libcockatrice/utility/qt_utils.h +++ b/libcockatrice_utility/libcockatrice/utility/qt_utils.h @@ -1,13 +1,29 @@ #ifndef COCKATRICE_QT_UTILS_H #define COCKATRICE_QT_UTILS_H +#include +#include #include #include +#include +#include +#include namespace QtUtils { -template T *findParentOfType(const QObject *obj) +/** + * @brief Returns the nearest ancestor of @p obj that casts to @c T. + * + * Walks the QObject parent chain. QWidget::parentWidget() is the same pointer as + * QObject::parent() for widgets, so this also covers the common "find the parent tab, + * splitter or scroll area" lookups. + * + * @param obj Object to start from. May be null. + * @param includeSelf Also test @p obj itself before walking upwards. + * @return The first matching ancestor, or nullptr if there is none. + */ +template T *findParentOfType(const QObject *obj, bool includeSelf = false) { - const QObject *p = obj ? obj->parent() : nullptr; + const QObject *p = includeSelf ? obj : (obj ? obj->parent() : nullptr); while (p) { if (auto casted = qobject_cast(const_cast(p))) { return casted; @@ -17,22 +33,80 @@ template T *findParentOfType(const QObject *obj) return nullptr; } -static inline void clearLayoutRec(QLayout *l) +/** + * @brief How clearLayoutRec() disposes of the widgets it removes. + * + * Deferred is the safe default: deleteLater() lets a widget finish handling the event that + * triggered the rebuild. Use Immediate when the caller rebuilds the same layout right away + * and a lingering widget would be visible or otherwise in the way until the next event loop + * turn. + */ +enum class DeletePolicy +{ + Deferred, + Immediate +}; + +/** + * @brief Removes and deletes every item in @p l, recursing into nested layouts. + * + * Widgets are disposed of according to @p policy; spacers and nested layouts are simply + * deleted. + */ +static inline void clearLayoutRec(QLayout *l, DeletePolicy policy = DeletePolicy::Deferred) { if (!l) { return; } - QLayoutItem *it; - while ((it = l->takeAt(0)) != nullptr) { + while (QLayoutItem *it = l->takeAt(0)) { if (QWidget *w = it->widget()) { - w->deleteLater(); + if (policy == DeletePolicy::Immediate) { + delete w; + } else { + w->deleteLater(); + } } if (QLayout *sub = it->layout()) { - clearLayoutRec(sub); + clearLayoutRec(sub, policy); } delete it; } } + +/** + * @brief Creates a QScrollArea using the conventions the client applies nearly everywhere: + * the inner widget resizes with the viewport and the horizontal bar is disabled. + * + * @param parent Parent widget, or nullptr for a parentless scroll area. + * @param verticalPolicy Vertical scroll bar policy. + * @param horizontalPolicy Horizontal scroll bar policy. + */ +static inline QScrollArea *makeScrollArea(QWidget *parent = nullptr, + Qt::ScrollBarPolicy verticalPolicy = Qt::ScrollBarAsNeeded, + Qt::ScrollBarPolicy horizontalPolicy = Qt::ScrollBarAlwaysOff) +{ + auto *scrollArea = new QScrollArea(parent); + scrollArea->setWidgetResizable(true); + scrollArea->setVerticalScrollBarPolicy(verticalPolicy); + scrollArea->setHorizontalScrollBarPolicy(horizontalPolicy); + return scrollArea; +} + +/** + * @brief A monospace font at the system UI font's point size. + * + * Used for log dumps, deck codes and other text that has to line up. + */ +static inline QFont monospaceFont() +{ + QFont font(QStringLiteral("monospace")); + font.setStyleHint(QFont::Monospace); + const int systemPointSize = QFontDatabase::systemFont(QFontDatabase::GeneralFont).pointSize(); + if (systemPointSize > 0) { + font.setPointSize(systemPointSize); + } + return font; +} } // namespace QtUtils #endif // COCKATRICE_QT_UTILS_H From ab13a6748f499c576b9972b48e5a4d2e5e1dd013 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 24 Oct 2026 08:26:21 +0200 Subject: [PATCH 010/108] [QtUtils] Adopt shared Qt helpers across the client (#7618) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [QtUtils] Add parent lookup, layout clearing, scroll area and monospace helpers * [QtUtils] Adopt shared Qt helpers across the client --------- Co-authored-by: Lukas Brübach --- .../game_graphics/zones/view_zone_widget.cpp | 9 +++------ .../widgets/cards/card_info_text_widget.cpp | 5 ++--- .../cards/deck_card_zone_display_widget.cpp | 11 ++--------- .../mana_base/mana_base_widget.cpp | 8 ++------ .../mana_devotion/mana_devotion_widget.cpp | 8 ++------ .../widgets/dialogs/dlg_my_reports.cpp | 11 ++--------- .../widgets/dialogs/dlg_report_user.cpp | 11 ++--------- .../dialogs/dlg_select_set_for_cards.cpp | 16 ++++------------ .../widgets/dialogs/settings_view.cpp | 14 ++++---------- .../general/layout_containers/flow_widget.cpp | 15 +++------------ .../layout_containers/overlap_widget.cpp | 7 ++----- .../quick_settings/settings_popup_widget.cpp | 6 ++---- ...hidekt_api_response_deck_display_widget.cpp | 6 ++---- .../tabs/api/archidekt/tab_archidekt.cpp | 18 ++++-------------- ...pi_response_card_details_display_widget.cpp | 11 ++--------- ...sponse_commander_details_display_widget.cpp | 11 ++--------- ...c_commander_api_response_display_widget.cpp | 6 ++---- ...ommander_api_response_navigation_widget.cpp | 11 ++--------- ...c_top_cards_api_response_display_widget.cpp | 6 ++---- ..._commanders_api_response_display_widget.cpp | 6 ++---- ...ec_top_tags_api_response_display_widget.cpp | 6 ++---- .../src/interface/widgets/tabs/tab_report.cpp | 11 ++--------- .../visual_deck_editor_widget.cpp | 8 ++------ .../deck_preview/deck_preview_widget.h | 11 ++++------- .../visual_deck_storage_widget.cpp | 6 ++---- 25 files changed, 60 insertions(+), 178 deletions(-) diff --git a/cockatrice/src/game_graphics/zones/view_zone_widget.cpp b/cockatrice/src/game_graphics/zones/view_zone_widget.cpp index 49d9955e0..8c82d6401 100644 --- a/cockatrice/src/game_graphics/zones/view_zone_widget.cpp +++ b/cockatrice/src/game_graphics/zones/view_zone_widget.cpp @@ -12,6 +12,7 @@ #include "../card_dimensions.h" #include "../z_values.h" #include "libcockatrice/utility/macros.h" +#include "libcockatrice/utility/qt_utils.h" #include "view_zone.h" #include @@ -356,12 +357,8 @@ QRectF ZoneViewWidget::closeButtonRect(QWidget *styleWidget) const QGraphicsView *ZoneViewWidget::findDragView(QWidget *eventWidget) const { - QWidget *current = eventWidget; - while (current) { - if (auto *view = qobject_cast(current)) { - return view; - } - current = current->parentWidget(); + if (auto *view = QtUtils::findParentOfType(eventWidget, true)) { + return view; } if (scene() && !scene()->views().isEmpty()) { diff --git a/cockatrice/src/interface/widgets/cards/card_info_text_widget.cpp b/cockatrice/src/interface/widgets/cards/card_info_text_widget.cpp index fa6cde311..218d0e964 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_text_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_info_text_widget.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include @@ -33,9 +34,7 @@ CardInfoTextWidget::CardInfoTextWidget(QWidget *parent) : QFrame(parent) textLabel->setReadOnly(true); textLabel->setMinimumSize(35, 35); - propsScroll = new QScrollArea(this); - propsScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - propsScroll->setWidgetResizable(true); + propsScroll = QtUtils::makeScrollArea(this); propsScroll->setAlignment(Qt::AlignLeft | Qt::AlignTop); propsScroll->setContentsMargins(0, 0, 0, 0); propsScroll->setFrameStyle(QFrame::NoFrame); diff --git a/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp b/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp index 911e35285..d7eef6c18 100644 --- a/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp @@ -29,6 +29,7 @@ #include #include #include +#include #include class CardSizeWidget; @@ -179,15 +180,7 @@ void DeckCardZoneDisplayWidget::displayCards() void DeckCardZoneDisplayWidget::refreshDisplayType(const DisplayType &_displayType) { displayType = _displayType; - QLayoutItem *item; - while ((item = cardGroupLayout->takeAt(0)) != nullptr) { - if (item->widget()) { - item->widget()->deleteLater(); - } else if (item->layout()) { - item->layout()->deleteLater(); - } - delete item; - } + QtUtils::clearLayoutRec(cardGroupLayout); indexToWidgetMap.clear(); diff --git a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_base/mana_base_widget.cpp b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_base/mana_base_widget.cpp index fc7914b94..8215cbdad 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_base/mana_base_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_base/mana_base_widget.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include @@ -47,12 +48,7 @@ ManaBaseWidget::ManaBaseWidget(QWidget *parent, DeckListStatisticsAnalyzer *anal void ManaBaseWidget::updateDisplay() { // Clear previous widgets - while (QLayoutItem *item = barLayout->takeAt(0)) { - if (item->widget()) { - item->widget()->deleteLater(); - } - delete item; - } + QtUtils::clearLayoutRec(barLayout); auto &pipCount = analyzer->getProductionPipCount(); auto &cardCount = analyzer->getProductionCardCount(); diff --git a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_devotion/mana_devotion_widget.cpp b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_devotion/mana_devotion_widget.cpp index 7e1f0c211..b958a733f 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_devotion/mana_devotion_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_devotion/mana_devotion_widget.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include @@ -47,12 +48,7 @@ ManaDevotionWidget::ManaDevotionWidget(QWidget *parent, DeckListStatisticsAnalyz void ManaDevotionWidget::updateDisplay() { // Clear previous widgets - while (QLayoutItem *item = barLayout->takeAt(0)) { - if (item->widget()) { - item->widget()->deleteLater(); - } - delete item; - } + QtUtils::clearLayoutRec(barLayout); auto &pipCount = analyzer->getDevotionPipCount(); auto &cardCount = analyzer->getDevotionCardCount(); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp index 52c9eaef0..2a2758ba5 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp @@ -6,8 +6,6 @@ #include "libcockatrice/protocol/pb/serverinfo_report.pb.h" #include -#include -#include #include #include #include @@ -26,6 +24,7 @@ #include #include #include +#include #include #include @@ -70,13 +69,7 @@ DlgMyReports::DlgMyReports(AbstractClient *_client, QWidget *parent) auto *chatGroup = new QGroupBox(tr("Chat Log Context")); chatLogEdit = new QTextEdit; chatLogEdit->setReadOnly(true); - QFont monoFont("monospace"); - monoFont.setStyleHint(QFont::Monospace); - const int systemPointSize = QFontDatabase::systemFont(QFontDatabase::GeneralFont).pointSize(); - if (systemPointSize > 0) { - monoFont.setPointSize(systemPointSize); - } - chatLogEdit->setFont(monoFont); + chatLogEdit->setFont(QtUtils::monospaceFont()); auto *chatLayout = new QVBoxLayout(chatGroup); chatLayout->setContentsMargins(4, 4, 4, 4); chatLayout->addWidget(chatLogEdit); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_report_user.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_report_user.cpp index b1ae479fe..c3bd7c0f1 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_report_user.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_report_user.cpp @@ -5,8 +5,6 @@ #include #include -#include -#include #include #include #include @@ -21,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -92,13 +91,7 @@ DlgReportUser::DlgReportUser(AbstractClient *_client, auto *chatLayout = new QVBoxLayout(chatGroup); chatLogEdit = new QTextEdit; chatLogEdit->setReadOnly(true); - QFont monoFont("monospace"); - monoFont.setStyleHint(QFont::Monospace); - const int systemPointSize = QFontDatabase::systemFont(QFontDatabase::GeneralFont).pointSize(); - if (systemPointSize > 0) { - monoFont.setPointSize(systemPointSize); - } - chatLogEdit->setFont(monoFont); + chatLogEdit->setFont(QtUtils::monospaceFont()); if (!_autoChatLog.isEmpty()) { chatLogEdit->setPlainText(_autoChatLog); } else { diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_select_set_for_cards.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_select_set_for_cards.cpp index 57ea09628..660074f94 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_select_set_for_cards.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_select_set_for_cards.cpp @@ -50,6 +50,7 @@ #include #include #include +#include #include #include #include @@ -71,10 +72,7 @@ DlgSelectSetForCards::DlgSelectSetForCards(QWidget *parent, DeckStateManager *de QSplitter *splitter = new QSplitter(Qt::Vertical, this); // Top scroll area - scrollArea = new QScrollArea(this); - scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); - scrollArea->setWidgetResizable(true); + scrollArea = QtUtils::makeScrollArea(this); listContainer = new QWidget(scrollArea); listLayout = new QVBoxLayout(listContainer); @@ -90,10 +88,7 @@ DlgSelectSetForCards::DlgSelectSetForCards(QWidget *parent, DeckStateManager *de leftLayout->setContentsMargins(0, 0, 0, 0); uneditedCardsLabel = new QLabel(this); - uneditedCardsArea = new QScrollArea(this); - uneditedCardsArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - uneditedCardsArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); - uneditedCardsArea->setWidgetResizable(true); + uneditedCardsArea = QtUtils::makeScrollArea(this); uneditedCardsFlowWidget = new FlowWidget(uneditedCardsArea, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded); @@ -109,10 +104,7 @@ DlgSelectSetForCards::DlgSelectSetForCards(QWidget *parent, DeckStateManager *de rightLayout->setContentsMargins(0, 0, 0, 0); modifiedCardsLabel = new QLabel(this); - modifiedCardsArea = new QScrollArea(this); - modifiedCardsArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - modifiedCardsArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); - modifiedCardsArea->setWidgetResizable(true); + modifiedCardsArea = QtUtils::makeScrollArea(this); modifiedCardsFlowWidget = new FlowWidget(modifiedCardsArea, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded); diff --git a/cockatrice/src/interface/widgets/dialogs/settings_view.cpp b/cockatrice/src/interface/widgets/dialogs/settings_view.cpp index ebac4e22f..d22a8d0b3 100644 --- a/cockatrice/src/interface/widgets/dialogs/settings_view.cpp +++ b/cockatrice/src/interface/widgets/dialogs/settings_view.cpp @@ -60,6 +60,7 @@ #include #include #include +#include #include /** @@ -71,10 +72,8 @@ static QScrollArea *makeScrollable(QWidget *widget) { widget->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Maximum); - auto *scrollArea = new QScrollArea; - scrollArea->setWidgetResizable(true); + auto *scrollArea = QtUtils::makeScrollArea(); scrollArea->setContentsMargins(0, 0, 0, 0); - scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); scrollArea->horizontalScrollBar()->setEnabled(false); scrollArea->setWidget(widget); return scrollArea; @@ -625,13 +624,8 @@ void SettingsView::scrollToAndFlash(QWidget *target) if (!target) { return; } - QWidget *widget = target; - while (widget) { - if (auto *scrollArea = qobject_cast(widget)) { - scrollArea->ensureWidgetVisible(target); - break; - } - widget = widget->parentWidget(); + if (auto *scrollArea = QtUtils::findParentOfType(target, true)) { + scrollArea->ensureWidgetVisible(target); } flashWidget(target); } diff --git a/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.cpp b/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.cpp index caeecad86..6b884f890 100644 --- a/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.cpp +++ b/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.cpp @@ -19,6 +19,7 @@ #include #include #include +#include /** * @brief Constructs a FlowWidget. @@ -55,13 +56,9 @@ FlowWidget::FlowWidget(QWidget *parent, // Scroll area (optional) if (useScrollArea) { - scrollArea = new QScrollArea(this); - scrollArea->setWidgetResizable(true); + scrollArea = QtUtils::makeScrollArea(this, verticalPolicy, horizontalPolicy); scrollArea->setMinimumSize(0, 0); scrollArea->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); - // Set scrollbar policies - scrollArea->setHorizontalScrollBarPolicy(horizontalPolicy); - scrollArea->setVerticalScrollBarPolicy(verticalPolicy); } else { scrollArea = nullptr; } @@ -136,13 +133,7 @@ void FlowWidget::removeWidget(QWidget *widgetToRemove) const void FlowWidget::clearLayout() { if (flowLayout) { - QLayoutItem *item; - while ((item = flowLayout->takeAt(0))) { - if (item->widget()) { - item->widget()->deleteLater(); - } - delete item; - } + QtUtils::clearLayoutRec(flowLayout); } else { // Defensive fallback: recreate the layout if it was deleted externally. flowLayout = new FlowLayout(container, flowDirection); diff --git a/cockatrice/src/interface/widgets/general/layout_containers/overlap_widget.cpp b/cockatrice/src/interface/widgets/general/layout_containers/overlap_widget.cpp index 03240211c..bc2d1cc4d 100644 --- a/cockatrice/src/interface/widgets/general/layout_containers/overlap_widget.cpp +++ b/cockatrice/src/interface/widgets/general/layout_containers/overlap_widget.cpp @@ -4,6 +4,7 @@ #include #include +#include /** * @class OverlapWidget @@ -80,11 +81,7 @@ void OverlapWidget::removeWidget(QWidget *widgetToRemove) const void OverlapWidget::clearLayout() { if (overlapLayout != nullptr) { - QLayoutItem *item; - while ((item = overlapLayout->takeAt(0)) != nullptr) { - item->widget()->deleteLater(); - delete item; - } + QtUtils::clearLayoutRec(overlapLayout); } // If layout is null, create a new layout; otherwise, reuse the existing one diff --git a/cockatrice/src/interface/widgets/quick_settings/settings_popup_widget.cpp b/cockatrice/src/interface/widgets/quick_settings/settings_popup_widget.cpp index 7ace155a7..351e3732c 100644 --- a/cockatrice/src/interface/widgets/quick_settings/settings_popup_widget.cpp +++ b/cockatrice/src/interface/widgets/quick_settings/settings_popup_widget.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include SettingsPopupWidget::SettingsPopupWidget(QWidget *parent) : QWidget(parent, Qt::Popup | Qt::FramelessWindowHint) @@ -54,11 +55,8 @@ void SettingsPopupWidget::adjustSizeToFitScreen() if (contentHeight > maxHeight) { // Create a scroll area and add the container widget to it if not already created if (!scrollArea) { - scrollArea = new QScrollArea(this); - scrollArea->setWidgetResizable(true); + scrollArea = QtUtils::makeScrollArea(this, Qt::ScrollBarAlwaysOn); scrollArea->setFrameShape(QFrame::NoFrame); - scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); // No horizontal scrollbar - scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOn); // Enable vertical scrollbar when needed } // Set maximum height for the scroll area and show vertical scrollbar diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_display_widget.cpp index 555ec7ec5..f24f35507 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_display_widget.cpp @@ -31,6 +31,7 @@ #include #include #include +#include #include #include @@ -66,10 +67,7 @@ ArchidektApiResponseDeckDisplayWidget::ArchidektApiResponseDeckDisplayWidget(QWi connect(displayOptionsWidget, &VisualDeckDisplayOptionsWidget::groupCriteriaChanged, this, &ArchidektApiResponseDeckDisplayWidget::onGroupCriteriaChange); - scrollArea = new QScrollArea(this); - scrollArea->setWidgetResizable(true); - scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + scrollArea = QtUtils::makeScrollArea(this); layout->addWidget(scrollArea); diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp index 02badf1aa..2cd6c78cb 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp @@ -49,6 +49,7 @@ #include #include #include +#include #include #include #include @@ -186,10 +187,7 @@ void TabArchidekt::initializeUi() secondaryToolbarLayout->setSpacing(6); // Scrollable results area - scrollArea = new QScrollArea(container); - scrollArea->setWidgetResizable(true); - scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + scrollArea = QtUtils::makeScrollArea(container); resultsContainer = new QWidget(); resultsLayout = new QVBoxLayout(resultsContainer); @@ -599,11 +597,7 @@ void TabArchidekt::processTopDecksResponse(QJsonObject reply) // New search → clear everything if (currentPage == 1) { - QLayoutItem *item; - while ((item = resultsLayout->takeAt(0)) != nullptr) { - delete item->widget(); - delete item; - } + QtUtils::clearLayoutRec(resultsLayout, QtUtils::DeletePolicy::Immediate); listingsWidget = new ArchidektApiResponseDeckListingsDisplayWidget(resultsContainer, deckData, cardSizeSlider); @@ -629,11 +623,7 @@ void TabArchidekt::processDeckResponse(QJsonObject reply) isListMode = false; // Clear existing results for single deck view - QLayoutItem *item; - while ((item = resultsLayout->takeAt(0)) != nullptr) { - delete item->widget(); - delete item; - } + QtUtils::clearLayoutRec(resultsLayout, QtUtils::DeletePolicy::Immediate); auto display = new ArchidektApiResponseDeckDisplayWidget(resultsContainer, deckData, cardSizeSlider); connect(display, &ArchidektApiResponseDeckDisplayWidget::requestNavigation, this, &TabArchidekt::actNavigatePage); diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.cpp index 0728b3154..b2ddace01 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include EdhrecApiResponseCardDetailsDisplayWidget::EdhrecApiResponseCardDetailsDisplayWidget( @@ -47,15 +48,7 @@ EdhrecApiResponseCardDetailsDisplayWidget::EdhrecApiResponseCardDetailsDisplayWi layout->addWidget(backgroundPlateWidget); - QWidget *currentParent = parentWidget(); - TabEdhRecMain *parentTab = nullptr; - - while (currentParent) { - if ((parentTab = qobject_cast(currentParent))) { - break; - } - currentParent = currentParent->parentWidget(); - } + auto *parentTab = QtUtils::findParentOfType(this); if (parentTab) { cardPictureWidget->setScaleFactor(parentTab->getCardSizeSlider()->getSlider()->value()); diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_api_response_commander_details_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_api_response_commander_details_display_widget.cpp index 3da050edd..2ff22f2b3 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_api_response_commander_details_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_api_response_commander_details_display_widget.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include EdhrecCommanderResponseCommanderDetailsDisplayWidget::EdhrecCommanderResponseCommanderDetailsDisplayWidget( @@ -33,15 +34,7 @@ EdhrecCommanderResponseCommanderDetailsDisplayWidget::EdhrecCommanderResponseCom commanderPicture = new CardInfoPictureWidget(this); commanderPicture->setCard(CardDatabaseManager::query()->getCard({commanderDetails.getName()})); - QWidget *currentParent = parentWidget(); - TabEdhRecMain *parentTab = nullptr; - - while (currentParent) { - if ((parentTab = qobject_cast(currentParent))) { - break; - } - currentParent = currentParent->parentWidget(); - } + auto *parentTab = QtUtils::findParentOfType(this); if (parentTab) { connect(parentTab->getCardSizeSlider()->getSlider(), &QSlider::valueChanged, commanderPicture, diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_display_widget.cpp index 111d91b91..2a23ff2a0 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_display_widget.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include @@ -61,10 +62,7 @@ EdhrecCommanderApiResponseDisplayWidget::EdhrecCommanderApiResponseDisplayWidget } // Create a QScrollArea to hold the card display widgets - scrollArea = new QScrollArea(splitter); - scrollArea->setWidgetResizable(true); - scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); - scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + scrollArea = QtUtils::makeScrollArea(splitter); // Set the cardDisplayLayout inside the scroll area auto scrollWidget = new QWidget(scrollArea); diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_navigation_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_navigation_widget.cpp index fd02c4261..220dcfbda 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_navigation_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_navigation_widget.cpp @@ -7,6 +7,7 @@ #include #include #include +#include EdhrecCommanderApiResponseNavigationWidget::EdhrecCommanderApiResponseNavigationWidget( QWidget *parent, @@ -40,15 +41,7 @@ EdhrecCommanderApiResponseNavigationWidget::EdhrecCommanderApiResponseNavigation layout->addWidget(bracketNavigationWidget, 1, 0, 1, 2); layout->addWidget(budgetNavigationWidget, 2, 0, 1, 2); - QWidget *currentParent = parentWidget(); - TabEdhRecMain *parentTab = nullptr; - - while (currentParent) { - if ((parentTab = qobject_cast(currentParent))) { - break; - } - currentParent = currentParent->parentWidget(); - } + auto *parentTab = QtUtils::findParentOfType(this); if (parentTab) { connect(comboPushButton, &QPushButton::clicked, this, diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_cards/edhrec_top_cards_api_response_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_cards/edhrec_top_cards_api_response_display_widget.cpp index 6f32db102..3120b7792 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_cards/edhrec_top_cards_api_response_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_cards/edhrec_top_cards_api_response_display_widget.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include EdhrecTopCardsApiResponseDisplayWidget::EdhrecTopCardsApiResponseDisplayWidget(QWidget *parent, @@ -29,10 +30,7 @@ EdhrecTopCardsApiResponseDisplayWidget::EdhrecTopCardsApiResponseDisplayWidget(Q } // Create a QScrollArea to hold the card display widgets - scrollArea = new QScrollArea(this); - scrollArea->setWidgetResizable(true); - scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); - scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + scrollArea = QtUtils::makeScrollArea(this); // Set the cardDisplayLayout inside the scroll area auto scrollWidget = new QWidget(scrollArea); diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_commander/edhrec_top_commanders_api_response_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_commander/edhrec_top_commanders_api_response_display_widget.cpp index 4ab333fd4..5076cd56f 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_commander/edhrec_top_commanders_api_response_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_commander/edhrec_top_commanders_api_response_display_widget.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -33,10 +34,7 @@ EdhrecTopCommandersApiResponseDisplayWidget::EdhrecTopCommandersApiResponseDispl } // Create a QScrollArea to hold the card display widgets - scrollArea = new QScrollArea(this); - scrollArea->setWidgetResizable(true); - scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); - scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + scrollArea = QtUtils::makeScrollArea(this); // Set the cardDisplayLayout inside the scroll area auto scrollWidget = new QWidget(scrollArea); diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_tags/edhrec_top_tags_api_response_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_tags/edhrec_top_tags_api_response_display_widget.cpp index 9c7ce8d06..f679b10e6 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_tags/edhrec_top_tags_api_response_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_tags/edhrec_top_tags_api_response_display_widget.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include EdhrecTopTagsApiResponseDisplayWidget::EdhrecTopTagsApiResponseDisplayWidget(QWidget *parent, @@ -29,10 +30,7 @@ EdhrecTopTagsApiResponseDisplayWidget::EdhrecTopTagsApiResponseDisplayWidget(QWi } // Create a QScrollArea to hold the card display widgets - scrollArea = new QScrollArea(this); - scrollArea->setWidgetResizable(true); - scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); - scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + scrollArea = QtUtils::makeScrollArea(this); // Set the cardDisplayLayout inside the scroll area auto scrollWidget = new QWidget(scrollArea); diff --git a/cockatrice/src/interface/widgets/tabs/tab_report.cpp b/cockatrice/src/interface/widgets/tabs/tab_report.cpp index 9018e2477..a98dfc242 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_report.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_report.cpp @@ -12,8 +12,6 @@ #include #include #include -#include -#include #include #include #include @@ -52,6 +50,7 @@ #include #include #include +#include #include #include @@ -138,13 +137,7 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T chatGroup = new QGroupBox; chatLogEdit = new QTextEdit; chatLogEdit->setReadOnly(true); - QFont monoFont("monospace"); - monoFont.setStyleHint(QFont::Monospace); - const int systemPointSize = QFontDatabase::systemFont(QFontDatabase::GeneralFont).pointSize(); - if (systemPointSize > 0) { - monoFont.setPointSize(systemPointSize); - } - chatLogEdit->setFont(monoFont); + chatLogEdit->setFont(QtUtils::monospaceFont()); auto *chatLayout = new QVBoxLayout(chatGroup); chatLayout->setContentsMargins(4, 4, 4, 4); chatLayout->addWidget(chatLogEdit); diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.cpp index 74b940586..506ffa2b4 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.cpp @@ -42,6 +42,7 @@ #include #include #include +#include #include VisualDeckEditorWidget::VisualDeckEditorWidget(QWidget *parent, @@ -188,14 +189,9 @@ void VisualDeckEditorWidget::initializeDisplayOptionsAndSearchWidget() void VisualDeckEditorWidget::initializeScrollAreaAndZoneContainer() { - scrollArea = new QScrollArea(this); - scrollArea->setWidgetResizable(true); + scrollArea = QtUtils::makeScrollArea(this); scrollArea->setMinimumSize(0, 0); - // Set scrollbar policies - scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); - zoneContainer = new QWidget(scrollArea); zoneContainer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); zoneContainer->setObjectName("zoneContainer"); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.h index cdeed87c7..f3fc982ac 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.h @@ -16,6 +16,7 @@ #include #include #include +#include #include class QFrame; @@ -131,13 +132,9 @@ protected: // If popup is not open, forward event to parent scroll area if (!combo->view()->isVisible()) { // Try to find a scrollable parent and manually send the event - QWidget *parent = combo->parentWidget(); - while (parent) { - if (auto *scroll = qobject_cast(parent)) { - QApplication::sendEvent(scroll->viewport(), event); - return true; // Mark event as handled - } - parent = parent->parentWidget(); + if (auto *scroll = QtUtils::findParentOfType(combo)) { + QApplication::sendEvent(scroll->viewport(), event); + return true; // Mark event as handled } // If no scrollable parent found, just block return true; diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp index 51e9c70e7..efcf19e58 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp @@ -33,6 +33,7 @@ #include #include #include +#include #include VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(parent) @@ -98,10 +99,7 @@ VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(pare &VisualDeckStorageWidget::updateSelectionAnimationEnabled); // deck area - scrollArea = new QScrollArea(this); - scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); - scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - scrollArea->setWidgetResizable(true); + scrollArea = QtUtils::makeScrollArea(this); // putting everything together layout->addWidget(searchAndSortContainer); From e73dd2b52059f38da7a33138485ebfea9dd15091 Mon Sep 17 00:00:00 2001 From: Neel Jogani Date: Sat, 24 Oct 2026 12:04:19 +0530 Subject: [PATCH 011/108] =?UTF-8?q?Refactor=20display=20type=20toggler=20a?= =?UTF-8?q?nd=20add=20language=20change=20handling=20in=20Vis=E2=80=A6=20(?= =?UTF-8?q?#7581)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Refactor display type toggler and add language change handling in VisualDeckDisplayOptionsWidget * Address review: extract updateDisplayTypeButton(), document changeEvent * Restore trailing newline in visual_deck_display_options_widget.cpp --- .../visual_deck_display_options_widget.cpp | 39 ++++++++++++------- .../visual_deck_display_options_widget.h | 8 ++++ 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp index 25ba41aa9..5d7979e15 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -110,12 +111,33 @@ void VisualDeckDisplayOptionsWidget::retranslateUi() sortByLabel->setText(tr("Sort by:")); sortLabel->setText(tr("Click and drag to change the sort order within the groups")); sortCriteriaButton->setToolTip(tr("Configure how cards are sorted within their groups")); - displayTypeButton->setButtonText(tr("Toggle Layout: Overlap")); - displayTypeButton->setButtonIcon(themePixmap(QStringLiteral("icons/scales"))); + updateDisplayTypeButton(); displayTypeButton->setToolTip( tr("Change how cards are displayed within zones (i.e. overlapped or fully visible.)")); } +void VisualDeckDisplayOptionsWidget::updateDisplayTypeButton() +{ + switch (currentDisplayType) { + case DisplayType::Flat: + displayTypeButton->setButtonText(tr("Toggle Layout: Flat")); + displayTypeButton->setButtonIcon(themePixmap(QStringLiteral("icons/scroll"))); + break; + case DisplayType::Overlap: + displayTypeButton->setButtonText(tr("Toggle Layout: Overlap")); + displayTypeButton->setButtonIcon(themePixmap(QStringLiteral("icons/scales"))); + break; + } +} + +void VisualDeckDisplayOptionsWidget::changeEvent(QEvent *event) +{ + if (event->type() == QEvent::LanguageChange) { + retranslateUi(); + } + QWidget::changeEvent(event); +} + void VisualDeckDisplayOptionsWidget::onSortCriteriaChange() { QStringList selectedCriteria; @@ -132,17 +154,8 @@ void VisualDeckDisplayOptionsWidget::updateDisplayType() // Toggle the display type currentDisplayType = (currentDisplayType == DisplayType::Overlap) ? DisplayType::Flat : DisplayType::Overlap; - // Update UI and emit signal - switch (currentDisplayType) { - case DisplayType::Flat: - displayTypeButton->setButtonText(tr("Toggle Layout: Flat")); - displayTypeButton->setButtonIcon(themePixmap(QStringLiteral("icons/scroll"))); - break; - case DisplayType::Overlap: - displayTypeButton->setButtonText(tr("Toggle Layout: Overlap")); - displayTypeButton->setButtonIcon(themePixmap(QStringLiteral("icons/scales"))); - break; - } + // Update only the toggle button, not every string in the widget + updateDisplayTypeButton(); emit displayTypeChanged(currentDisplayType); } diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.h b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.h index 96600c587..c40b8ffeb 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.h @@ -11,6 +11,7 @@ class CompactPushButton; class QComboBox; +class QEvent; class QHBoxLayout; class QLabel; class QListWidget; @@ -57,6 +58,7 @@ public slots: * @brief Updates all UI text for retranslation/localization. * * Called when the application language changes. + * Also refreshes the layout toggle button text for the current display type. */ void retranslateUi(); void updateCompactMode(bool mode); @@ -94,6 +96,10 @@ public: return activeSortCriteria; } +protected: + /** @brief Retranslates on QEvent::LanguageChange. */ + void changeEvent(QEvent *event) override; + private slots: /** * @brief Slot triggered whenever the sort list is reordered. @@ -110,6 +116,8 @@ private slots: void updateDisplayType(); private: + /** @brief Updates the layout toggle button's text and icon for the current display type. */ + void updateDisplayTypeButton(); /** @brief Layout for grouping and sorting UI elements. */ QHBoxLayout *groupAndSortLayout; From 6e770eb7b2413081f5b5ef7d4f50edaa827e217a Mon Sep 17 00:00:00 2001 From: Neel Jogani Date: Sat, 24 Oct 2026 12:05:31 +0530 Subject: [PATCH 012/108] Retranslate Home tab when the language changes (#7579) * Retranslate Home tab when the language changes TabSupervisor::retranslateUi() never included the Home tab, and HomeWidget set its button text only once, when the buttons were created. Switching languages left the Home tab in the old language until it was reopened. Store the Home buttons as members, add HomeWidget::retranslateUi() to set their text, and call it from TabHome and TabSupervisor. The connect button text still only changes for the Connecting, Disconnected and LoggedIn statuses, as before. * Address review: plain ClientStatus, drop optional and placeholder --- .../interface/widgets/general/home_widget.cpp | 51 ++++++++++++++----- .../interface/widgets/general/home_widget.h | 11 +++- .../src/interface/widgets/tabs/tab_home.cpp | 1 + .../interface/widgets/tabs/tab_supervisor.cpp | 1 + 4 files changed, 50 insertions(+), 14 deletions(-) diff --git a/cockatrice/src/interface/widgets/general/home_widget.cpp b/cockatrice/src/interface/widgets/general/home_widget.cpp index c77fd76fd..7555d1f45 100644 --- a/cockatrice/src/interface/widgets/general/home_widget.cpp +++ b/cockatrice/src/interface/widgets/general/home_widget.cpp @@ -113,6 +113,32 @@ HomeWidget::HomeWidget(QWidget *parent, TabSupervisor *_tabSupervisor) &HomeWidget::updateButtonsToBackgroundColor); } +void HomeWidget::retranslateUi() +{ + visualDeckEditorButton->setText(tr("Create New Deck")); + visualDeckStorageButton->setText(tr("Browse Decks")); + visualDatabaseDisplayButton->setText(tr("Browse Card Database")); + edhrecButton->setText(tr("Browse EDHRec")); + archidektButton->setText(tr("Browse Archidekt")); + replayButton->setText(tr("View Replays")); + if (exitButton) { + exitButton->setText(tr("Quit")); + } + + switch (connectionStatus) { + case StatusDisconnected: + connectButton->setText(tr("Connect")); + break; + case StatusLoggedIn: + connectButton->setText(tr("Play")); + break; + default: + // Connecting, registering, activating, logging in, etc. + connectButton->setText(tr("Connecting...")); + break; + } +} + void HomeWidget::initializeBackgroundFromSource() { // The featured logo is theme/scheme-derived too; reload it alongside the @@ -284,32 +310,32 @@ QGroupBox *HomeWidget::createButtons() updateLogoOverlay(); boxLayout->addSpacing(25); - connectButton = new HomeStyledButton("Connect/Play", gradientColors); + connectButton = new HomeStyledButton(QString(), gradientColors); boxLayout->addWidget(connectButton); - auto visualDeckEditorButton = new HomeStyledButton(tr("Create New Deck"), gradientColors); + visualDeckEditorButton = new HomeStyledButton(QString(), gradientColors); connect(visualDeckEditorButton, &QPushButton::clicked, tabSupervisor, [this] { tabSupervisor->openDeckInNewTab(LoadedDeck()); }); boxLayout->addWidget(visualDeckEditorButton); - auto visualDeckStorageButton = new HomeStyledButton(tr("Browse Decks"), gradientColors); + visualDeckStorageButton = new HomeStyledButton(QString(), gradientColors); connect(visualDeckStorageButton, &QPushButton::clicked, tabSupervisor, [this] { tabSupervisor->actTabVisualDeckStorage(true); }); boxLayout->addWidget(visualDeckStorageButton); - auto visualDatabaseDisplayButton = new HomeStyledButton(tr("Browse Card Database"), gradientColors); + visualDatabaseDisplayButton = new HomeStyledButton(QString(), gradientColors); connect(visualDatabaseDisplayButton, &QPushButton::clicked, tabSupervisor, &TabSupervisor::addVisualDatabaseDisplayTab); boxLayout->addWidget(visualDatabaseDisplayButton); - auto edhrecButton = new HomeStyledButton(tr("Browse EDHRec"), gradientColors); + edhrecButton = new HomeStyledButton(QString(), gradientColors); connect(edhrecButton, &QPushButton::clicked, tabSupervisor, &TabSupervisor::addEdhrecMainTab); boxLayout->addWidget(edhrecButton); - auto archidektButton = new HomeStyledButton(tr("Browse Archidekt"), gradientColors); + archidektButton = new HomeStyledButton(QString(), gradientColors); connect(archidektButton, &QPushButton::clicked, tabSupervisor, &TabSupervisor::addArchidektTab); boxLayout->addWidget(archidektButton); - auto replaybutton = new HomeStyledButton(tr("View Replays"), gradientColors); - connect(replaybutton, &QPushButton::clicked, tabSupervisor, [this] { tabSupervisor->actTabReplays(true); }); - boxLayout->addWidget(replaybutton); + replayButton = new HomeStyledButton(QString(), gradientColors); + connect(replayButton, &QPushButton::clicked, tabSupervisor, [this] { tabSupervisor->actTabReplays(true); }); + boxLayout->addWidget(replayButton); if (qobject_cast(tabSupervisor->parentWidget())) { - auto exitButton = new HomeStyledButton(tr("Quit"), gradientColors); + exitButton = new HomeStyledButton(QString(), gradientColors); connect(exitButton, &QPushButton::clicked, qobject_cast(tabSupervisor->parentWidget()), &MainWindow::actExit); boxLayout->addWidget(exitButton); @@ -321,20 +347,18 @@ QGroupBox *HomeWidget::createButtons() void HomeWidget::updateConnectButton(const ClientStatus status) { + connectionStatus = status; disconnect(connectButton, &QPushButton::clicked, nullptr, nullptr); switch (status) { case StatusConnecting: - connectButton->setText(tr("Connecting...")); connectButton->setEnabled(false); break; case StatusDisconnected: - connectButton->setText(tr("Connect")); connectButton->setEnabled(true); connect(connectButton, &QPushButton::clicked, qobject_cast(tabSupervisor->parentWidget()), &MainWindow::actConnect); break; case StatusLoggedIn: - connectButton->setText(tr("Play")); connectButton->setEnabled(true); connect(connectButton, &QPushButton::clicked, tabSupervisor, &TabSupervisor::switchToFirstAvailableNetworkTab); @@ -342,6 +366,7 @@ void HomeWidget::updateConnectButton(const ClientStatus status) default: break; } + retranslateUi(); } QPair HomeWidget::extractDominantColors(const QPixmap &pixmap) diff --git a/cockatrice/src/interface/widgets/general/home_widget.h b/cockatrice/src/interface/widgets/general/home_widget.h index fb9566cad..aa4745c82 100644 --- a/cockatrice/src/interface/widgets/general/home_widget.h +++ b/cockatrice/src/interface/widgets/general/home_widget.h @@ -33,6 +33,7 @@ class HomeWidget : public QWidget public: HomeWidget(QWidget *parent, TabSupervisor *tabSupervisor); void updateRandomCard(); + void retranslateUi(); static QPair extractDominantColors(const QPixmap &pixmap); public slots: @@ -53,7 +54,15 @@ private: DeckList backgroundSourceDeck; QLabel *logoLabel = nullptr; QPair gradientColors; - HomeStyledButton *connectButton; + HomeStyledButton *connectButton = nullptr; + HomeStyledButton *visualDeckEditorButton = nullptr; + HomeStyledButton *visualDeckStorageButton = nullptr; + HomeStyledButton *visualDatabaseDisplayButton = nullptr; + HomeStyledButton *edhrecButton = nullptr; + HomeStyledButton *archidektButton = nullptr; + HomeStyledButton *replayButton = nullptr; + HomeStyledButton *exitButton = nullptr; + ClientStatus connectionStatus = StatusDisconnected; void setRandomCard(ExactCard &newCard); void loadBackgroundSourceDeck(); diff --git a/cockatrice/src/interface/widgets/tabs/tab_home.cpp b/cockatrice/src/interface/widgets/tabs/tab_home.cpp index 1358645d0..af9994fdd 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_home.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_home.cpp @@ -12,4 +12,5 @@ TabHome::TabHome(TabSupervisor *_tabSupervisor, AbstractClient *_client) : Tab(_ void TabHome::retranslateUi() { + homeWidget->retranslateUi(); } diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index 2352d7d7b..3d7ca0a98 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -292,6 +292,7 @@ void TabSupervisor::retranslateUi() // tabs QList tabs; + tabs.append(tabHome); tabs.append(tabServer); tabs.append(tabReplays); tabs.append(tabDeckStorage); From 4028606bd9128058a6ab2e957abe966d5778634f Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:28:24 +0200 Subject: [PATCH 013/108] [CardDB] Clear CardInfo relations before deferred deletion (#7615) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit removeCard() and swapInDatabaseData() schedule CardRelation deletion with deleteLater(), but the owning CardInfo could outlive them through an external shared pointer and kept returning the freed pointers from getAllRelatedCards(). Add CardInfo::clearDirectRelations() and call it after scheduling deletion so the UI can no longer dereference dangling relations. Co-authored-by: Lukas Brübach --- .../libcockatrice/card/card_info.cpp | 7 +++++ .../libcockatrice/card/card_info.h | 8 ++++++ .../card/database/card_database.cpp | 7 +++++ tests/carddatabase/carddatabase_test.cpp | 27 +++++++++++++++++++ 4 files changed, 49 insertions(+) diff --git a/libcockatrice_card/libcockatrice/card/card_info.cpp b/libcockatrice_card/libcockatrice/card/card_info.cpp index c90027442..388c9ab57 100644 --- a/libcockatrice_card/libcockatrice/card/card_info.cpp +++ b/libcockatrice_card/libcockatrice/card/card_info.cpp @@ -270,6 +270,13 @@ void CardInfo::resetReverseRelatedCards2Me() reverseRelatedCardsToMe = QList(); } +void CardInfo::clearDirectRelations() +{ + relatedCards = QList(); + reverseRelatedCards = QList(); + reverseRelatedCardsToMe = QList(); +} + // Back-compatibility methods. Remove ASAP QString CardInfo::getCardType() const { diff --git a/libcockatrice_card/libcockatrice/card/card_info.h b/libcockatrice_card/libcockatrice/card/card_info.h index 77ddce5ce..eb1250a1c 100644 --- a/libcockatrice_card/libcockatrice/card/card_info.h +++ b/libcockatrice_card/libcockatrice/card/card_info.h @@ -453,6 +453,14 @@ public: return result; } void resetReverseRelatedCards2Me(); + /** + * @brief Clears the raw relation lists without deleting the CardRelation objects. + * + * Callers that schedule the relations for deletion (e.g. CardDatabase::removeCard()) + * must use this to drop the now-dangling pointers from this CardInfo, which may + * outlive them through external shared pointers. + */ + void clearDirectRelations(); void addReverseRelatedCards2Me(CardRelation *cardRelation) { reverseRelatedCardsToMe.append(cardRelation); diff --git a/libcockatrice_card/libcockatrice/card/database/card_database.cpp b/libcockatrice_card/libcockatrice/card/database/card_database.cpp index 7b0d5b2f9..946412606 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database.cpp +++ b/libcockatrice_card/libcockatrice/card/database/card_database.cpp @@ -168,6 +168,10 @@ void CardDatabase::removeCard(const CardInfoPtr &card) cardRelation->deleteLater(); } + // The relations are only scheduled for deletion; the CardInfo may outlive + // them through an external shared pointer, so drop the raw pointers now. + card->clearDirectRelations(); + QMutexLocker locker(removeCardMutex); cards.remove(card->getName()); simpleNameCards.remove(card->getSimpleName()); @@ -271,6 +275,9 @@ void CardDatabase::swapInDatabaseData(CardDatabaseData data) for (auto *rel : card->getReverseRelatedCards2Me()) { rel->deleteLater(); } + // Replacement cards take over the database; old CardInfo objects may + // still be referenced by the UI, so clear their dangling relations. + card->clearDirectRelations(); } cards = std::move(data.cards); diff --git a/tests/carddatabase/carddatabase_test.cpp b/tests/carddatabase/carddatabase_test.cpp index 1b68b8166..eb522434d 100644 --- a/tests/carddatabase/carddatabase_test.cpp +++ b/tests/carddatabase/carddatabase_test.cpp @@ -26,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -124,6 +125,32 @@ TEST(CardDatabaseTest, FindPrintingWithIdOnNullCardInfoReturnsEmpty) EXPECT_TRUE(db.query()->findPrintingWithId(nullptr, "card_Not a Card_fixture").isEmpty()); } +TEST(CardInfoTest, ClearDirectRelationsDropsRawPointers) +{ + auto *forward = new CardRelation("Child"); + auto *reverse = new CardRelation("Parent"); + auto *toMe = new CardRelation("Child"); + + auto card = CardInfo::newInstance("Parent", "", false, QHash(), {forward}, {reverse}, + SetToPrintingsMap(), CardInfo::UiAttributes()); + card->addReverseRelatedCards2Me(toMe); + ASSERT_EQ(1, card->getRelatedCards().size()); + ASSERT_EQ(1, card->getReverseRelatedCards().size()); + ASSERT_EQ(1, card->getReverseRelatedCards2Me().size()); + ASSERT_EQ(2, card->getAllRelatedCards().size()); + + card->clearDirectRelations(); + + EXPECT_TRUE(card->getRelatedCards().isEmpty()); + EXPECT_TRUE(card->getReverseRelatedCards().isEmpty()); + EXPECT_TRUE(card->getReverseRelatedCards2Me().isEmpty()); + EXPECT_TRUE(card->getAllRelatedCards().isEmpty()); + + delete forward; + delete reverse; + delete toMe; +} + TEST(CardDatabaseTest, Xml4LocalizedDataRoundTrip) { NoopCardSetPriorityController controller; From 67a212d67705dd3c46ad3e3743b652274e6e7795 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:40:20 +0200 Subject: [PATCH 014/108] [Theme] Drop stale pixmap caches on palette-only preview (#7600) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A palette preview repaints widgets through applyStyleAndPalette without running themeChangedSlot, so generator-owned pixmaps stayed at the previous palette until the next full theme switch. Clear them where the palette is applied. Co-authored-by: Lukas Brübach --- cockatrice/src/interface/theme_manager.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cockatrice/src/interface/theme_manager.cpp b/cockatrice/src/interface/theme_manager.cpp index 9a96b3e45..893cdc795 100644 --- a/cockatrice/src/interface/theme_manager.cpp +++ b/cockatrice/src/interface/theme_manager.cpp @@ -558,6 +558,9 @@ void ThemeManager::applyStyleAndPalette(const QString &themeName, widget->update(); } + // Palette-only previews do not run themeChangedSlot(), so drop snapshots + // generated under the previous palette here as well. + clearPixmapGeneratorCaches(); emit paletteChanged(); } From 06b9ca368f44539c08a5630111227b86fb1c6750 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 24 Oct 2026 19:01:56 +0200 Subject: [PATCH 015/108] [QtUtils] Add layout creation and relayout helpers (#7619) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [QtUtils] Add layout creation and relayout helpers * Swap parameter order (spacing before margin) and remove static from relayout() --------- Co-authored-by: Lukas Brübach --- .../libcockatrice/utility/qt_utils.h | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/libcockatrice_utility/libcockatrice/utility/qt_utils.h b/libcockatrice_utility/libcockatrice/utility/qt_utils.h index 728f1d99e..39a9e01e6 100644 --- a/libcockatrice_utility/libcockatrice/utility/qt_utils.h +++ b/libcockatrice_utility/libcockatrice/utility/qt_utils.h @@ -73,6 +73,40 @@ static inline void clearLayoutRec(QLayout *l, DeletePolicy policy = DeletePolicy } } +/** + * @brief Creates a layout of type @c LayoutT with zero margins. + * + * @param parent Parent widget, or nullptr when the layout is added later. + * @param spacing Optional spacing. Values below zero leave the style's default spacing + * untouched; pass a non-negative value to call setSpacing(). + * @param margin Uniform margin. Defaults to zero. + */ +template LayoutT *makeLayout(QWidget *parent = nullptr, int spacing = -1, int margin = 0) +{ + auto *layout = new LayoutT(parent); + layout->setContentsMargins(margin, margin, margin, margin); + if (spacing >= 0) { + layout->setSpacing(spacing); + } + return layout; +} + +/** + * @brief Forces @p layout to recompute and repaint immediately. + * + * Convenience for the invalidate/activate/update sequence used after changing a layout's + * contents outside of a resize event. + */ +inline void relayout(QLayout *layout) +{ + if (!layout) { + return; + } + layout->invalidate(); + layout->activate(); + layout->update(); +} + /** * @brief Creates a QScrollArea using the conventions the client applies nearly everywhere: * the inner widget resizes with the viewport and the horizontal bar is disabled. From e8060856811791a11f63e0570a77bd392f58d2fa Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 24 Oct 2026 19:06:18 +0200 Subject: [PATCH 016/108] [QtUtils] Use makeLayout() for zero-margin layouts (#7620) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [QtUtils] Add parent lookup, layout clearing, scroll area and monospace helpers * [QtUtils] Adopt shared Qt helpers across the client * [QtUtils] Add layout creation and relayout helpers * Swap parameter order (spacing before margin) and remove static from relayout() * [QtUtils] Use relayout() after rebuilding dynamic layouts * [QtUtils] Use makeLayout() for zero-margin layouts * Consolidate setSpacing() calls into makeLayout() using new parameter order --------- Co-authored-by: Lukas Brübach --- cockatrice/src/client/latency_status_widget.cpp | 5 ++--- cockatrice/src/filters/filter_builder.cpp | 4 ++-- .../deckview/deck_view_container.cpp | 4 ++-- .../card_database_update_status_bar.cpp | 4 ++-- .../palette_editor/palette_editor_dialog.cpp | 5 ++--- .../palette_editor/palette_grid_widget.cpp | 4 ++-- .../additional_info/color_identity_widget.cpp | 4 +--- .../cards/additional_info/mana_cost_widget.cpp | 5 ++--- .../widgets/cards/card_info_display_widget.cpp | 4 ++-- .../widgets/cards/card_info_frame_widget.cpp | 10 ++++------ .../mana_curve/mana_curve_category_widget.cpp | 9 +++------ .../widgets/deck_analytics/resizable_panel.cpp | 13 ++++--------- .../deck_editor_filter_dock_widget.cpp | 4 ++-- .../deck_share/shared_deck_preview_widget.cpp | 4 ++-- .../widgets/dialogs/dlg_default_tags_editor.cpp | 4 ++-- .../dialogs/dlg_select_set_for_cards.cpp | 6 ++---- .../general/layout_containers/flow_widget.cpp | 3 +-- .../widgets/onboarding/first_run_wizard.cpp | 4 ++-- .../widgets/onboarding/shader_banner_widget.cpp | 4 ++-- .../printing_selector/card_amount_widget.cpp | 5 ++--- .../printing_selector/printing_selector.cpp | 5 ++--- .../printing_selector_card_overlay_widget.cpp | 5 ++--- ...ame_and_collectors_number_display_widget.cpp | 4 ++-- .../quick_settings/settings_button_widget.cpp | 4 ++-- .../replay/replay_quick_settings_widget.cpp | 4 ++-- .../game_selector_quick_filter_toolbar.cpp | 4 ++-- .../widgets/server/user/user_info_box.cpp | 4 ++-- .../widgets/server/user/user_info_popup.cpp | 5 ++--- .../server/user/user_list_panel_widget.cpp | 5 ++--- .../user_interface_settings_page.cpp | 4 ++-- ...chidekt_api_response_deck_display_widget.cpp | 4 +--- ...t_api_response_deck_entry_display_widget.cpp | 5 ++--- ...pi_response_deck_listings_display_widget.cpp | 8 +++----- .../tabs/api/archidekt/tab_archidekt.cpp | 12 +++--------- .../commander_bracket_widget.cpp | 7 +++---- ...ec_commander_api_response_display_widget.cpp | 4 +--- ...ec_top_cards_api_response_display_widget.cpp | 4 +--- ...p_commanders_api_response_display_widget.cpp | 4 +--- ...rec_top_tags_api_response_display_widget.cpp | 4 +--- .../src/interface/widgets/tabs/tab_game.cpp | 17 ++++++----------- .../src/interface/widgets/tabs/tab_report.cpp | 3 +-- .../tab_deck_storage_visual.cpp | 5 ++--- .../interface/widgets/utility/sequence_edit.cpp | 5 ++--- ...l_database_display_filter_toolbar_widget.cpp | 9 +++------ ...se_display_format_legality_filter_widget.cpp | 4 ++-- ...database_display_main_type_filter_widget.cpp | 4 ++-- ...isual_database_display_set_filter_widget.cpp | 4 ++-- ..._database_display_sub_type_filter_widget.cpp | 4 ++-- .../visual_database_display_widget.cpp | 5 ++--- .../visual_deck_display_options_widget.cpp | 4 +--- .../visual_deck_editor_sample_hand_widget.cpp | 5 ++--- .../deck_preview_deck_tags_display_widget.cpp | 4 ++-- .../deck_preview_tag_item_widget.cpp | 5 ++--- .../deck_preview/public_deck_preview_widget.cpp | 4 ++-- .../visual_deck_storage_search_widget.cpp | 4 ++-- .../visual_deck_storage_sort_widget.cpp | 4 ++-- 56 files changed, 116 insertions(+), 175 deletions(-) diff --git a/cockatrice/src/client/latency_status_widget.cpp b/cockatrice/src/client/latency_status_widget.cpp index 940074581..907a50668 100644 --- a/cockatrice/src/client/latency_status_widget.cpp +++ b/cockatrice/src/client/latency_status_widget.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include LatencyStatusWidget::LatencyStatusWidget(QWidget *parent) : QWidget(parent) @@ -24,9 +25,7 @@ LatencyStatusWidget::LatencyStatusWidget(QWidget *parent) : QWidget(parent) latencyGraph = new LatencyGraphWidget(this); latencyGraph->setFixedSize(90, 14); - auto *layout = new QHBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(4); + auto *layout = QtUtils::makeLayout(this, 4); layout->addWidget(latencyGraph); layout->addWidget(pingLabel); diff --git a/cockatrice/src/filters/filter_builder.cpp b/cockatrice/src/filters/filter_builder.cpp index 6cc893224..22679abb1 100644 --- a/cockatrice/src/filters/filter_builder.cpp +++ b/cockatrice/src/filters/filter_builder.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include FilterBuilder::FilterBuilder(QWidget *parent) : QWidget(parent) @@ -39,9 +40,8 @@ FilterBuilder::FilterBuilder(QWidget *parent) : QWidget(parent) edit->setClearButtonEnabled(true); edit->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); - QGridLayout *layout = new QGridLayout; + QGridLayout *layout = QtUtils::makeLayout(); layout->setObjectName("layout"); - layout->setContentsMargins(0, 0, 0, 0); layout->addWidget(typeCombo, 0, 0, 1, 2); layout->addWidget(filterCombo, 0, 2, 1, 2); diff --git a/cockatrice/src/game_graphics/deckview/deck_view_container.cpp b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp index a6e541fca..bd5a0271c 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view_container.cpp +++ b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp @@ -43,6 +43,7 @@ #include #include #include +#include #include #include #include @@ -111,10 +112,9 @@ DeckViewContainer::DeckViewContainer(int _playerId, TabGame *parent) connect(deckView, &DeckView::newCardAdded, this, &DeckViewContainer::newCardAdded); connect(deckView, &DeckView::sideboardPlanChanged, this, &DeckViewContainer::sideboardPlanChanged); - deckViewLayout = new QVBoxLayout; + deckViewLayout = QtUtils::makeLayout(); deckViewLayout->addLayout(buttonHBox); deckViewLayout->addWidget(deckView); - deckViewLayout->setContentsMargins(0, 0, 0, 0); setLayout(deckViewLayout); retranslateUi(); diff --git a/cockatrice/src/interface/card_database_update/card_database_update_status_bar.cpp b/cockatrice/src/interface/card_database_update/card_database_update_status_bar.cpp index 721fdd6c8..21e26d073 100644 --- a/cockatrice/src/interface/card_database_update/card_database_update_status_bar.cpp +++ b/cockatrice/src/interface/card_database_update/card_database_update_status_bar.cpp @@ -7,6 +7,7 @@ #include #include #include +#include namespace { @@ -32,8 +33,7 @@ QString stageName(CardUpdateStage stage, const QString &fallback) CardDatabaseUpdateStatusBar::CardDatabaseUpdateStatusBar(QWidget *parent) : QWidget(parent) { - layout = new QHBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); + layout = QtUtils::makeLayout(this); stageLabel = new QLabel(this); progressBar = new QProgressBar(this); diff --git a/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp b/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp index 20e16614a..b6271291d 100644 --- a/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp +++ b/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp @@ -25,6 +25,7 @@ #include #include #include +#include #include PaletteEditorDialog::PaletteEditorDialog(const QString &_themeDirPath, const QString &_themeName, QWidget *parent) @@ -57,9 +58,7 @@ PaletteEditorDialog::PaletteEditorDialog(const QString &_themeDirPath, const QSt void PaletteEditorDialog::setupUi() { - auto *root = new QVBoxLayout(this); - root->setSpacing(0); - root->setContentsMargins(0, 0, 0, 0); + auto *root = QtUtils::makeLayout(this, 0, 0); // Header header = new QWidget; diff --git a/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp b/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp index de069bc80..9546e0e7e 100644 --- a/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp +++ b/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include @@ -74,8 +75,7 @@ PaletteGridWidget::PaletteGridWidget(QWidget *parent) : QWidget(parent) refreshChromePalettes(); scroll->setWidget(gridHost); - layout = new QVBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); + layout = QtUtils::makeLayout(this); layout->addWidget(scroll); } diff --git a/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.cpp b/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.cpp index ef7fb0e0e..0f68f3626 100644 --- a/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.cpp @@ -23,9 +23,7 @@ ColorIdentityWidget::ColorIdentityWidget(QWidget *parent, const QString &_colorIdentity) : QWidget(parent), colorIdentity(_colorIdentity) { - layout = new QHBoxLayout(this); - layout->setSpacing(5); // Small spacing between icons - layout->setContentsMargins(0, 0, 0, 0); + layout = QtUtils::makeLayout(this, 5); layout->setAlignment(Qt::AlignCenter); // Ensure icons are centered setLayout(layout); diff --git a/cockatrice/src/interface/widgets/cards/additional_info/mana_cost_widget.cpp b/cockatrice/src/interface/widgets/cards/additional_info/mana_cost_widget.cpp index 7da97f88c..f6efd267e 100644 --- a/cockatrice/src/interface/widgets/cards/additional_info/mana_cost_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/additional_info/mana_cost_widget.cpp @@ -11,12 +11,11 @@ #include #include #include +#include ManaCostWidget::ManaCostWidget(QWidget *parent, CardInfoPtr _card) : QWidget(parent), card(_card) { - layout = new QHBoxLayout(this); - layout->setSpacing(5); // Small spacing between icons - layout->setContentsMargins(0, 0, 0, 0); + layout = QtUtils::makeLayout(this, 5); setLayout(layout); setFixedHeight(50); // Fixed height diff --git a/cockatrice/src/interface/widgets/cards/card_info_display_widget.cpp b/cockatrice/src/interface/widgets/cards/card_info_display_widget.cpp index 7ec2ee38e..038f08612 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_display_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_info_display_widget.cpp @@ -17,6 +17,7 @@ #include #include #include +#include class QWidget; @@ -30,9 +31,8 @@ CardInfoDisplayWidget::CardInfoDisplayWidget(const CardRef &cardRef, QWidget *pa text->setObjectName("text"); connect(text, &CardInfoTextWidget::linkActivated, this, [this](const QString &card) { setCard({card}); }); - auto *layout = new QVBoxLayout(); + auto *layout = QtUtils::makeLayout(); layout->setObjectName("layout"); - layout->setContentsMargins(0, 0, 0, 0); layout->setSpacing(0); layout->addWidget(pic, 0, Qt::AlignCenter); layout->addWidget(text, 0, Qt::AlignCenter); diff --git a/cockatrice/src/interface/widgets/cards/card_info_frame_widget.cpp b/cockatrice/src/interface/widgets/cards/card_info_frame_widget.cpp index 0f66eb209..80bc4b9d0 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_frame_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_info_frame_widget.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include struct CardRef; @@ -49,15 +50,13 @@ CardInfoFrameWidget::CardInfoFrameWidget(QWidget *parent) insertTab(ImageAndTextView, tab3, QString()); connect(this, &CardInfoFrameWidget::currentChanged, this, &CardInfoFrameWidget::setViewMode); - tab1Layout = new QVBoxLayout(); + tab1Layout = QtUtils::makeLayout(); tab1Layout->setObjectName("tab1Layout"); - tab1Layout->setContentsMargins(0, 0, 0, 0); tab1Layout->setSpacing(0); tab1->setLayout(tab1Layout); - tab2Layout = new QVBoxLayout(); + tab2Layout = QtUtils::makeLayout(); tab2Layout->setObjectName("tab2Layout"); - tab2Layout->setContentsMargins(0, 0, 0, 0); tab2Layout->setSpacing(0); tab2->setLayout(tab2Layout); @@ -65,9 +64,8 @@ CardInfoFrameWidget::CardInfoFrameWidget(QWidget *parent) splitter->setObjectName("splitter"); splitter->setOrientation(Qt::Vertical); - tab3Layout = new QVBoxLayout(); + tab3Layout = QtUtils::makeLayout(); tab3Layout->setObjectName("tab3Layout"); - tab3Layout->setContentsMargins(0, 0, 0, 0); tab3Layout->setSpacing(0); tab3Layout->addWidget(splitter); tab3->setLayout(tab3Layout); diff --git a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_category_widget.cpp b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_category_widget.cpp index 7e15058a4..be81ab97a 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_category_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_category_widget.cpp @@ -16,14 +16,13 @@ #include #include #include +#include constexpr int MIN_ROW_HEIGHT = 100; // Minimum readable height per row ManaCurveCategoryWidget::ManaCurveCategoryWidget(QWidget *parent) : QWidget(parent) { - layout = new QVBoxLayout(this); - layout->setSpacing(4); - layout->setContentsMargins(0, 0, 0, 0); + layout = QtUtils::makeLayout(this, 0, 4); setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); } @@ -95,9 +94,7 @@ void ManaCurveCategoryWidget::updateDisplay(int minCmc, row->setFixedHeight(MIN_ROW_HEIGHT); - QHBoxLayout *rowLayout = new QHBoxLayout(row); - rowLayout->setContentsMargins(0, 0, 0, 0); - rowLayout->setSpacing(4); + QHBoxLayout *rowLayout = QtUtils::makeLayout(row, 4); QLabel *categoryLabel = new QLabel(cat, row); categoryLabel->setFixedWidth(80); diff --git a/cockatrice/src/interface/widgets/deck_analytics/resizable_panel.cpp b/cockatrice/src/interface/widgets/deck_analytics/resizable_panel.cpp index f46679406..85dba6468 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/resizable_panel.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/resizable_panel.cpp @@ -28,6 +28,7 @@ #include #include #include +#include #include ResizablePanel::ResizablePanel(const QString &_typeId, AbstractAnalyticsPanelWidget *analyticsPanel, QWidget *parent) @@ -35,18 +36,14 @@ ResizablePanel::ResizablePanel(const QString &_typeId, AbstractAnalyticsPanelWid { setAcceptDrops(true); - auto *mainLayout = new QVBoxLayout(this); - mainLayout->setContentsMargins(0, 0, 0, 0); - mainLayout->setSpacing(0); + auto *mainLayout = QtUtils::makeLayout(this, 0); // Frame for selection highlight frame = new QFrame(this); frame->setFrameShape(QFrame::Box); frame->setLineWidth(2); - auto *frameLayout = new QVBoxLayout(frame); - frameLayout->setContentsMargins(0, 0, 0, 0); - frameLayout->setSpacing(0); + auto *frameLayout = QtUtils::makeLayout(frame, 0); // Add the analytics panel frameLayout->addWidget(analyticsPanel); @@ -63,9 +60,7 @@ ResizablePanel::ResizablePanel(const QString &_typeId, AbstractAnalyticsPanelWid // Bottom bar with drag button and resize handle auto *bottomBar = new QWidget(frame); - auto *bottomLayout = new QHBoxLayout(bottomBar); - bottomLayout->setContentsMargins(0, 0, 0, 0); - bottomLayout->setSpacing(0); + auto *bottomLayout = QtUtils::makeLayout(bottomBar, 0); // Drag button on the left dragButton = new QPushButton("☰", bottomBar); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp index 0a47333fa..adeb5c3e1 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp @@ -28,6 +28,7 @@ #include #include #include +#include #include DeckEditorFilterDockWidget::DeckEditorFilterDockWidget(AbstractTabDeckEditor *parent) @@ -80,9 +81,8 @@ void DeckEditorFilterDockWidget::createFiltersDock() filterDelAll->setDefaultAction(aClearFilterAll); filterDelAll->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); - auto *filterLayout = new QGridLayout; + auto *filterLayout = QtUtils::makeLayout(); filterLayout->setObjectName("filterLayout"); - filterLayout->setContentsMargins(0, 0, 0, 0); filterLayout->addWidget(filterBuilder, 0, 0, 1, 3); filterLayout->addWidget(filterView, 1, 0, 1, 3); filterLayout->addWidget(filterDelOne, 2, 0, 1, 1); diff --git a/cockatrice/src/interface/widgets/deck_share/shared_deck_preview_widget.cpp b/cockatrice/src/interface/widgets/deck_share/shared_deck_preview_widget.cpp index 3bb829089..ae95ce95c 100644 --- a/cockatrice/src/interface/widgets/deck_share/shared_deck_preview_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_share/shared_deck_preview_widget.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include SharedDeckPreviewWidget::SharedDeckPreviewWidget(QWidget *parent, @@ -62,8 +63,7 @@ SharedDeckPreviewWidget::SharedDeckPreviewWidget(QWidget *parent, selectionRow->addWidget(selectionCheckBox); selectionRow->addStretch(1); - auto *layout = new QVBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); + auto *layout = QtUtils::makeLayout(this); layout->addLayout(selectionRow); layout->addWidget(bannerCardDisplayWidget, 0, Qt::AlignHCenter); layout->addWidget(colorIdentityWidget, 0, Qt::AlignHCenter); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_default_tags_editor.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_default_tags_editor.cpp index 9eb064beb..ee71699d2 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_default_tags_editor.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_default_tags_editor.cpp @@ -17,6 +17,7 @@ #include #include #include +#include DlgDefaultTagsEditor::DlgDefaultTagsEditor(QWidget *parent) : QDialog(parent) { @@ -120,8 +121,7 @@ void DlgDefaultTagsEditor::addItem() // Add new tag auto *item = new QListWidgetItem(listWidget); QWidget *widget = new QWidget(this); - QHBoxLayout *layout = new QHBoxLayout(widget); - layout->setContentsMargins(0, 0, 0, 0); + QHBoxLayout *layout = QtUtils::makeLayout(widget); // Editable tag label QLineEdit *lineEdit = new QLineEdit(newTag, this); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_select_set_for_cards.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_select_set_for_cards.cpp index 660074f94..1a4f0aeb9 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_select_set_for_cards.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_select_set_for_cards.cpp @@ -84,8 +84,7 @@ DlgSelectSetForCards::DlgSelectSetForCards(QWidget *parent, DeckStateManager *de // Left container QWidget *leftContainer = new QWidget(this); - QVBoxLayout *leftLayout = new QVBoxLayout(leftContainer); - leftLayout->setContentsMargins(0, 0, 0, 0); + QVBoxLayout *leftLayout = QtUtils::makeLayout(leftContainer); uneditedCardsLabel = new QLabel(this); uneditedCardsArea = QtUtils::makeScrollArea(this); @@ -100,8 +99,7 @@ DlgSelectSetForCards::DlgSelectSetForCards(QWidget *parent, DeckStateManager *de // Right container QWidget *rightContainer = new QWidget(this); - QVBoxLayout *rightLayout = new QVBoxLayout(rightContainer); - rightLayout->setContentsMargins(0, 0, 0, 0); + QVBoxLayout *rightLayout = QtUtils::makeLayout(rightContainer); modifiedCardsLabel = new QLabel(this); modifiedCardsArea = QtUtils::makeScrollArea(this); diff --git a/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.cpp b/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.cpp index 6b884f890..d7260912f 100644 --- a/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.cpp +++ b/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.cpp @@ -48,8 +48,7 @@ FlowWidget::FlowWidget(QWidget *parent, setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Expanding); } - mainLayout = new QHBoxLayout(this); - mainLayout->setContentsMargins(0, 0, 0, 0); + mainLayout = QtUtils::makeLayout(this); setLayout(mainLayout); const bool useScrollArea = (horizontalPolicy != Qt::ScrollBarAlwaysOff || verticalPolicy != Qt::ScrollBarAlwaysOff); diff --git a/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp index 426811ec2..39606d167 100644 --- a/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp +++ b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include @@ -51,8 +52,7 @@ FirstRunWizard::FirstRunWizard(QWidget *parent) : QDialog(parent) connect(skipButton, &QPushButton::clicked, this, &FirstRunWizard::skip); connect(nextButton, &QPushButton::clicked, this, &FirstRunWizard::goNext); - auto *headerLayout = new QVBoxLayout; - headerLayout->setContentsMargins(0, 0, 0, 0); + auto *headerLayout = QtUtils::makeLayout(); headerLayout->addWidget(bannerHost); headerLayout->addSpacing(12); headerLayout->addWidget(titleLabel); diff --git a/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.cpp b/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.cpp index c17399957..1074ff6e5 100644 --- a/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.cpp +++ b/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -151,8 +152,7 @@ BannerHost::BannerHost(QWidget *parent) : QWidget(parent) { setFixedHeight(150); - stack = new QStackedLayout(this); - stack->setContentsMargins(0, 0, 0, 0); + stack = QtUtils::makeLayout(this); fallback = new GradientFallbackWidget(this); stack->addWidget(fallback); diff --git a/cockatrice/src/interface/widgets/printing_selector/card_amount_widget.cpp b/cockatrice/src/interface/widgets/printing_selector/card_amount_widget.cpp index 4b5040741..eae49d7ef 100644 --- a/cockatrice/src/interface/widgets/printing_selector/card_amount_widget.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/card_amount_widget.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include class QModelIndex; @@ -42,9 +43,7 @@ CardAmountWidget::CardAmountWidget(QWidget *parent, : QWidget(parent), deckStateManager(deckStateManager), cardSizeSlider(cardSizeSlider), rootCard(rootCard), zoneName(zoneName), hovered(false) { - layout = new QHBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(10); + layout = QtUtils::makeLayout(this, 10); this->setLayout(layout); this->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); layout->setAlignment(Qt::AlignHCenter); diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector.cpp b/cockatrice/src/interface/widgets/printing_selector/printing_selector.cpp index 983d6ba32..b8438134c 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector.cpp @@ -32,6 +32,7 @@ #include #include #include +#include #include /** @@ -84,9 +85,7 @@ PrintingSelector::PrintingSelector(QWidget *parent, AbstractTabDeckEditor *_deck displayOptionsWidget->addSettingsWidget(cardSizeWidget); sortAndOptionsContainer = new QWidget(this); - sortAndOptionsLayout = new QHBoxLayout(sortAndOptionsContainer); - sortAndOptionsLayout->setSpacing(3); - sortAndOptionsLayout->setContentsMargins(0, 0, 0, 0); + sortAndOptionsLayout = QtUtils::makeLayout(sortAndOptionsContainer, 0, 3); sortAndOptionsContainer->setLayout(sortAndOptionsLayout); searchBar = new PrintingSelectorCardSearchWidget(this); diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp index bec9100a6..94acdbdce 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp @@ -40,6 +40,7 @@ #include #include #include +#include #include class DeckStateManager; @@ -108,9 +109,7 @@ PrintingSelectorCardOverlayWidget::PrintingSelectorCardOverlayWidget(QWidget *pa : QWidget(parent), deckEditor(_deckEditor), rootCard(_rootCard) { // Set up the main layout - auto *mainLayout = new QVBoxLayout(this); - mainLayout->setContentsMargins(0, 0, 0, 0); - mainLayout->setSpacing(0); + auto *mainLayout = QtUtils::makeLayout(this, 0); setLayout(mainLayout); // Add CardInfoPictureWidget diff --git a/cockatrice/src/interface/widgets/printing_selector/set_name_and_collectors_number_display_widget.cpp b/cockatrice/src/interface/widgets/printing_selector/set_name_and_collectors_number_display_widget.cpp index 9bc92ad90..d8c67c2a9 100644 --- a/cockatrice/src/interface/widgets/printing_selector/set_name_and_collectors_number_display_widget.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/set_name_and_collectors_number_display_widget.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include /** @@ -19,8 +20,7 @@ SetNameAndCollectorsNumberDisplayWidget::SetNameAndCollectorsNumberDisplayWidget : QWidget(parent) { // Set up the layout for the widget - layout = new QVBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); + layout = QtUtils::makeLayout(this); layout->setSpacing(1); setLayout(layout); diff --git a/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.cpp b/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.cpp index 981ed843c..ea0d2db5a 100644 --- a/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.cpp +++ b/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include SettingsButtonWidget::SettingsButtonWidget(QWidget *parent) @@ -26,9 +27,8 @@ SettingsButtonWidget::SettingsButtonWidget(QWidget *parent) connect(button, &QToolButton::clicked, this, &SettingsButtonWidget::togglePopup); connect(popup, &SettingsPopupWidget::aboutToClose, this, &SettingsButtonWidget::onPopupClosed); - layout = new QHBoxLayout(this); + layout = QtUtils::makeLayout(this); layout->addWidget(button); - layout->setContentsMargins(0, 0, 0, 0); setLayout(layout); } diff --git a/cockatrice/src/interface/widgets/replay/replay_quick_settings_widget.cpp b/cockatrice/src/interface/widgets/replay/replay_quick_settings_widget.cpp index 5b3829af2..0a64fb4d2 100644 --- a/cockatrice/src/interface/widgets/replay/replay_quick_settings_widget.cpp +++ b/cockatrice/src/interface/widgets/replay/replay_quick_settings_widget.cpp @@ -11,6 +11,7 @@ #include #include #include +#include ReplayQuickSettingsWidget::ReplayQuickSettingsWidget(QWidget *parent) : SettingsButtonWidget(parent) { @@ -28,8 +29,7 @@ ReplayQuickSettingsWidget::ReplayQuickSettingsWidget(QWidget *parent) : Settings // putting it all together auto *widget = new QWidget; - auto *grid = new QGridLayout(widget); - grid->setContentsMargins(0, 0, 0, 0); + auto *grid = QtUtils::makeLayout(widget); grid->addWidget(&fastForwardSpeedLabel, 0, 0, 1, 1); grid->addWidget(&fastForwardSpeedBox, 0, 1, 1, 1); grid->addWidget(&skipEmptyCheckBox, 1, 0, 1, 2); diff --git a/cockatrice/src/interface/widgets/server/game_selector_quick_filter_toolbar.cpp b/cockatrice/src/interface/widgets/server/game_selector_quick_filter_toolbar.cpp index 0fc3d879c..58f9bb8a9 100644 --- a/cockatrice/src/interface/widgets/server/game_selector_quick_filter_toolbar.cpp +++ b/cockatrice/src/interface/widgets/server/game_selector_quick_filter_toolbar.cpp @@ -14,6 +14,7 @@ #include #include #include +#include class TabSupervisor; template class QMap; @@ -24,8 +25,7 @@ GameSelectorQuickFilterToolBar::GameSelectorQuickFilterToolBar(QWidget *parent, const QMap &allGameTypes) : QWidget(parent), tabSupervisor(_tabSupervisor), model(_model) { - mainLayout = new QHBoxLayout(this); - mainLayout->setContentsMargins(0, 0, 0, 0); + mainLayout = QtUtils::makeLayout(this); mainLayout->setSpacing(5); const GameFilterConfigs &filters = model->getFilters(); diff --git a/cockatrice/src/interface/widgets/server/user/user_info_box.cpp b/cockatrice/src/interface/widgets/server/user/user_info_box.cpp index 60bc97edc..4feb73538 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_box.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_info_box.cpp @@ -33,6 +33,7 @@ #include #include #include +#include #include #include @@ -53,8 +54,7 @@ UserInfoBox::UserInfoBox(AbstractClient *_client, bool _editable, QWidget *paren avatarPic.setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); avatarPic.setAlignment(Qt::AlignCenter); - auto *avatarLayout = new QHBoxLayout; - avatarLayout->setContentsMargins(0, 0, 0, 0); + auto *avatarLayout = QtUtils::makeLayout(); avatarLayout->addStretch(1); avatarLayout->addWidget(&avatarPic, 3); avatarLayout->addStretch(1); diff --git a/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp b/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp index c3e880084..efb3b2e31 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp @@ -55,6 +55,7 @@ #include #include #include +#include #include #include @@ -402,9 +403,7 @@ UserInfoPopup::UserInfoPopup(TabSupervisor *_ts, void UserInfoPopup::buildUi() { - auto *root = new QVBoxLayout(this); - root->setContentsMargins(0, 0, 0, 0); - root->setSpacing(0); + auto *root = QtUtils::makeLayout(this, 0); // Header header = new UserInfoHeaderWidget(this); diff --git a/cockatrice/src/interface/widgets/server/user/user_list_panel_widget.cpp b/cockatrice/src/interface/widgets/server/user/user_list_panel_widget.cpp index 78d45d120..5a34c852b 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_panel_widget.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_panel_widget.cpp @@ -9,6 +9,7 @@ #include #include #include +#include namespace { @@ -31,9 +32,7 @@ QString sectionKey(UserListWidget::Section section) UserListPanelWidget::UserListPanelWidget(TabSupervisor *_tabSupervisor, AbstractClient *_client, QWidget *parent) : QWidget(parent) { - auto *mainLayout = new QVBoxLayout(this); - mainLayout->setContentsMargins(0, 0, 0, 0); - mainLayout->setSpacing(2); + auto *mainLayout = QtUtils::makeLayout(this, 2); searchBar = new QLineEdit(this); searchBar->setClearButtonEnabled(true); diff --git a/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp index 0a5f57e1f..44ce01aba 100644 --- a/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include enum visualDeckStoragePromptForConversionIndex @@ -248,8 +249,7 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage() updateCommanderSpellbookUiState(); - auto *labelLayout = new QHBoxLayout; - labelLayout->setContentsMargins(0, 0, 0, 0); + auto *labelLayout = QtUtils::makeLayout(); labelLayout->addWidget(&commanderSpellbookIntegrationUseOfficialBracketNamesLabel); labelLayout->addWidget(&commanderSpellbookIntegrationUseOfficialBracketNamesExplainer); diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_display_widget.cpp index f24f35507..3abf3f9aa 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_display_widget.cpp @@ -238,7 +238,5 @@ void ArchidektApiResponseDeckDisplayWidget::constructZoneWidgetsFromDeckListMode void ArchidektApiResponseDeckDisplayWidget::resizeEvent(QResizeEvent *event) { QWidget::resizeEvent(event); - layout->invalidate(); - layout->activate(); - layout->update(); + QtUtils::relayout(layout); } diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_entry_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_entry_display_widget.cpp index b5e28fcf5..b22dac8de 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_entry_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_entry_display_widget.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include #include @@ -241,9 +242,7 @@ void ArchidektApiResponseDeckEntryDisplayWidget::updateScaledPreview() void ArchidektApiResponseDeckEntryDisplayWidget::resizeEvent(QResizeEvent *event) { QWidget::resizeEvent(event); - layout->invalidate(); - layout->activate(); - layout->update(); + QtUtils::relayout(layout); } void ArchidektApiResponseDeckEntryDisplayWidget::actRequestNavigationToDeck() diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_listings_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_listings_display_widget.cpp index 9c4ea59ab..81619bdcc 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_listings_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_listings_display_widget.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include ArchidektApiResponseDeckListingsDisplayWidget::ArchidektApiResponseDeckListingsDisplayWidget( @@ -19,8 +20,7 @@ ArchidektApiResponseDeckListingsDisplayWidget::ArchidektApiResponseDeckListingsD CardSizeWidget *_cardSizeSlider) : QWidget(parent), cardSizeSlider(_cardSizeSlider) { - layout = new QHBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); + layout = QtUtils::makeLayout(this); setLayout(layout); flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded); @@ -64,7 +64,5 @@ void ArchidektApiResponseDeckListingsDisplayWidget::append(const ArchidektDeckLi void ArchidektApiResponseDeckListingsDisplayWidget::resizeEvent(QResizeEvent *event) { QWidget::resizeEvent(event); - layout->invalidate(); - layout->activate(); - layout->update(); + QtUtils::relayout(layout); } diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp index 2cd6c78cb..60bf00c0a 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp @@ -81,9 +81,7 @@ void TabArchidekt::initializeUi() { // Main container container = new QWidget(this); - mainLayout = new QVBoxLayout(container); - mainLayout->setContentsMargins(0, 0, 0, 0); - mainLayout->setSpacing(0); + mainLayout = QtUtils::makeLayout(container, 0); // Primary toolbar (most important filters) primaryToolbar = new QWidget(container); @@ -106,9 +104,7 @@ void TabArchidekt::initializeUi() // Color filter (inline) QWidget *colorWidget = new QWidget(primaryToolbar); - QHBoxLayout *colorLayout = new QHBoxLayout(colorWidget); - colorLayout->setContentsMargins(0, 0, 0, 0); - colorLayout->setSpacing(2); + QHBoxLayout *colorLayout = QtUtils::makeLayout(colorWidget, 2); QString colorIdentity = "WUBRG"; for (const QChar &color : colorIdentity) { @@ -190,9 +186,7 @@ void TabArchidekt::initializeUi() scrollArea = QtUtils::makeScrollArea(container); resultsContainer = new QWidget(); - resultsLayout = new QVBoxLayout(resultsContainer); - resultsLayout->setContentsMargins(0, 0, 0, 0); - resultsLayout->setSpacing(0); + resultsLayout = QtUtils::makeLayout(resultsContainer, 0); scrollArea->setWidget(resultsContainer); diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp index 06a76dd19..5279165af 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include template class QSharedPointer; @@ -44,8 +45,7 @@ CommanderBracketWidget::CommanderBracketWidget(QWidget *parent) : QWidget(parent connect(bracketRefreshButton, &QToolButton::clicked, this, &CommanderBracketWidget::requestBracketEstimate); - auto *layout = new QGridLayout(this); - layout->setContentsMargins(0, 0, 0, 0); + auto *layout = QtUtils::makeLayout(this); layout->addWidget(bracketLabel, 0, 0); auto *bracketRow = new QHBoxLayout; @@ -110,8 +110,7 @@ bool CommanderBracketWidget::promptCommanderSpellbookIntegration() // Create label + explainer button auto *labelWidget = new QWidget(&dialog); - auto *labelLayout = new QHBoxLayout(labelWidget); - labelLayout->setContentsMargins(0, 0, 0, 0); + auto *labelLayout = QtUtils::makeLayout(labelWidget); auto *namingLabel = new QLabel(tr("Bracket naming:"), labelWidget); auto *explainerButton = new QToolButton(labelWidget); diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_display_widget.cpp index 2a23ff2a0..6dc987b76 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_display_widget.cpp @@ -107,9 +107,7 @@ void EdhrecCommanderApiResponseDisplayWidget::resizeEvent(QResizeEvent *event) { QWidget::resizeEvent(event); qDebug() << event->size(); - layout->invalidate(); - layout->activate(); - layout->update(); + QtUtils::relayout(layout); if (scrollArea && scrollArea->widget()) { scrollArea->widget()->resize(event->size()); } diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_cards/edhrec_top_cards_api_response_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_cards/edhrec_top_cards_api_response_display_widget.cpp index 3120b7792..680926ef4 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_cards/edhrec_top_cards_api_response_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_cards/edhrec_top_cards_api_response_display_widget.cpp @@ -43,9 +43,7 @@ EdhrecTopCardsApiResponseDisplayWidget::EdhrecTopCardsApiResponseDisplayWidget(Q void EdhrecTopCardsApiResponseDisplayWidget::resizeEvent(QResizeEvent *event) { QWidget::resizeEvent(event); - layout->invalidate(); - layout->activate(); - layout->update(); + QtUtils::relayout(layout); if (scrollArea && scrollArea->widget()) { scrollArea->widget()->resize(event->size()); } diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_commander/edhrec_top_commanders_api_response_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_commander/edhrec_top_commanders_api_response_display_widget.cpp index 5076cd56f..105386597 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_commander/edhrec_top_commanders_api_response_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_commander/edhrec_top_commanders_api_response_display_widget.cpp @@ -48,9 +48,7 @@ void EdhrecTopCommandersApiResponseDisplayWidget::resizeEvent(QResizeEvent *even { QWidget::resizeEvent(event); qDebug() << event->size(); - layout->invalidate(); - layout->activate(); - layout->update(); + QtUtils::relayout(layout); if (scrollArea && scrollArea->widget()) { scrollArea->widget()->resize(event->size()); } diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_tags/edhrec_top_tags_api_response_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_tags/edhrec_top_tags_api_response_display_widget.cpp index f679b10e6..ae8686f3a 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_tags/edhrec_top_tags_api_response_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_tags/edhrec_top_tags_api_response_display_widget.cpp @@ -43,9 +43,7 @@ EdhrecTopTagsApiResponseDisplayWidget::EdhrecTopTagsApiResponseDisplayWidget(QWi void EdhrecTopTagsApiResponseDisplayWidget::resizeEvent(QResizeEvent *event) { QWidget::resizeEvent(event); - layout->invalidate(); - layout->activate(); - layout->update(); + QtUtils::relayout(layout); if (scrollArea && scrollArea->widget()) { scrollArea->widget()->resize(event->size()); } diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.cpp b/cockatrice/src/interface/widgets/tabs/tab_game.cpp index 4cf5919ba..309720bca 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_game.cpp @@ -78,6 +78,7 @@ #include #include #include +#include #include #include #include @@ -1287,8 +1288,7 @@ void TabGame::createPlayAreaWidget(bool bReplay) connect(game->getGameEventHandler(), &GameEventHandler::arrowDeleted, scene, &GameScene::deleteArrow); gameView = new GameView(scene); - auto gamePlayAreaVBox = new QVBoxLayout; - gamePlayAreaVBox->setContentsMargins(0, 0, 0, 0); + auto gamePlayAreaVBox = QtUtils::makeLayout(); gamePlayAreaVBox->addWidget(gameView); gamePlayAreaWidget = new QWidget; @@ -1320,8 +1320,7 @@ void TabGame::createDeckViewContainerWidget(bool bReplay) deckViewContainerWidget = new QWidget(); deckViewContainerWidget->setObjectName("deckViewContainerWidget"); - deckViewContainerLayout = new QVBoxLayout; - deckViewContainerLayout->setContentsMargins(0, 0, 0, 0); + deckViewContainerLayout = QtUtils::makeLayout(); deckViewContainerWidget->setLayout(deckViewContainerLayout); } @@ -1336,8 +1335,7 @@ void TabGame::createCardInfoDock(bool bReplay) cardInfoFrameWidget = new CardInfoFrameWidget(); auto cardHInfoLayout = new QHBoxLayout; - auto cardVInfoLayout = new QVBoxLayout; - cardVInfoLayout->setContentsMargins(0, 0, 0, 0); + auto cardVInfoLayout = QtUtils::makeLayout(); cardVInfoLayout->addWidget(cardInfoFrameWidget); cardVInfoLayout->addLayout(cardHInfoLayout); @@ -1364,9 +1362,7 @@ void TabGame::createPlayerListDock(bool bReplay) playerListWidget->setFocusPolicy(Qt::NoFocus); auto *playerListBox = new QWidget(this); - auto *vbox = new QVBoxLayout(playerListBox); - vbox->setContentsMargins(0, 0, 0, 0); - vbox->setSpacing(0); + auto *vbox = QtUtils::makeLayout(playerListBox, 0); vbox->addWidget(playerListWidget); @@ -1391,8 +1387,7 @@ void TabGame::createPlayerListDock(bool bReplay) void TabGame::createMessageDock(bool bReplay) { - auto messageLogLayout = new QVBoxLayout; - messageLogLayout->setContentsMargins(0, 0, 0, 0); + auto messageLogLayout = QtUtils::makeLayout(); // clock if (!bReplay) { diff --git a/cockatrice/src/interface/widgets/tabs/tab_report.cpp b/cockatrice/src/interface/widgets/tabs/tab_report.cpp index a98dfc242..988c3e7d9 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_report.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_report.cpp @@ -210,8 +210,7 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T sgLayout->addWidget(statsTrendLabel); sgLayout->addWidget(statsCategoriesLabel); sgLayout->addWidget(statsDetailText); - auto *statsGroupLayout = new QVBoxLayout(statsGroup); - statsGroupLayout->setContentsMargins(0, 0, 0, 0); + auto *statsGroupLayout = QtUtils::makeLayout(statsGroup); statsGroupLayout->addWidget(statsContent); statsGroup->setChecked(true); connect(statsGroup, &QGroupBox::toggled, this, [this, statsContent](bool checked) { diff --git a/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.cpp b/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.cpp index cb6dfab9c..74c149b2d 100644 --- a/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.cpp +++ b/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.cpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include @@ -46,9 +47,7 @@ TabDeckStorageVisual::TabDeckStorageVisual(TabSupervisor *_tabSupervisor, Abstra }); auto *widget = new QWidget(this); - auto *layout = new QVBoxLayout(widget); - layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(0); + auto *layout = QtUtils::makeLayout(widget, 0); widget->setLayout(layout); this->setCentralWidget(widget); layout->addWidget(visualDeckStorageWidget); diff --git a/cockatrice/src/interface/widgets/utility/sequence_edit.cpp b/cockatrice/src/interface/widgets/utility/sequence_edit.cpp index e92287c9a..44e6581e9 100644 --- a/cockatrice/src/interface/widgets/utility/sequence_edit.cpp +++ b/cockatrice/src/interface/widgets/utility/sequence_edit.cpp @@ -15,6 +15,7 @@ #include #include #include +#include SequenceEdit::SequenceEdit(const QString &_shortcutName, QWidget *parent) : QWidget(parent) { @@ -26,9 +27,7 @@ SequenceEdit::SequenceEdit(const QString &_shortcutName, QWidget *parent) : QWid clearButton->setIcon(themePixmap(QStringLiteral("icons/clearsearch"))); defaultButton->setIcon(themePixmap(QStringLiteral("icons/update"))); - auto *layout = new QHBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(1); + auto *layout = QtUtils::makeLayout(this, 1); layout->addWidget(lineEdit); layout->addWidget(clearButton); layout->addWidget(defaultButton); diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp index 3e9d9e525..5063cce14 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp @@ -29,6 +29,7 @@ #include #include #include +#include #include class DeckListModel; @@ -110,14 +111,10 @@ VisualDatabaseDisplayFilterToolbarWidget::VisualDatabaseDisplayFilterToolbarWidg void VisualDatabaseDisplayFilterToolbarWidget::initialize() { - auto sortLayout = new QHBoxLayout(sortGroupBox); - sortLayout->setContentsMargins(0, 0, 0, 0); - sortLayout->setSpacing(0); + auto sortLayout = QtUtils::makeLayout(sortGroupBox, 0); sortLayout->setAlignment(Qt::AlignLeft); - auto filterLayout = new QHBoxLayout(filterGroupBox); - filterLayout->setContentsMargins(0, 0, 0, 0); - filterLayout->setSpacing(2); + auto filterLayout = QtUtils::makeLayout(filterGroupBox, 2); filterLayout->setAlignment(Qt::AlignLeft); // create settings widgets diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_format_legality_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_format_legality_filter_widget.cpp index a5725ff3f..3d6a6e56c 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_format_legality_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_format_legality_filter_widget.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include @@ -48,8 +49,7 @@ VisualDatabaseDisplayFormatLegalityFilterWidget::VisualDatabaseDisplayFormatLega layout->addWidget(flowWidget); // Create a container for the threshold control - auto *thresholdLayout = new QHBoxLayout(); - thresholdLayout->setContentsMargins(0, 0, 0, 0); + auto *thresholdLayout = QtUtils::makeLayout(); thresholdLabel = new QLabel(this); thresholdLayout->addWidget(thresholdLabel); diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_main_type_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_main_type_filter_widget.cpp index eff0fe10b..1f63a6b01 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_main_type_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_main_type_filter_widget.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include VisualDatabaseDisplayMainTypeFilterWidget::VisualDatabaseDisplayMainTypeFilterWidget(QWidget *parent, @@ -42,8 +43,7 @@ VisualDatabaseDisplayMainTypeFilterWidget::VisualDatabaseDisplayMainTypeFilterWi layout->addWidget(flowWidget); // Create a container for the threshold control - auto *thresholdLayout = new QHBoxLayout(); - thresholdLayout->setContentsMargins(0, 0, 0, 0); + auto *thresholdLayout = QtUtils::makeLayout(); thresholdLabel = new QLabel(this); thresholdLayout->addWidget(thresholdLabel); diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_set_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_set_filter_widget.cpp index 3d76fb78a..d15600564 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_set_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_set_filter_widget.cpp @@ -30,13 +30,13 @@ #include #include #include +#include #include VisualDatabaseDisplayRecentSetFilterSettingsWidget::VisualDatabaseDisplayRecentSetFilterSettingsWidget(QWidget *parent) : QWidget(parent) { - layout = new QHBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); + layout = QtUtils::makeLayout(this); setLayout(layout); diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_sub_type_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_sub_type_filter_widget.cpp index 705bb8efa..26fdce64f 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_sub_type_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_sub_type_filter_widget.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include VisualDatabaseDisplaySubTypeFilterWidget::VisualDatabaseDisplaySubTypeFilterWidget(QWidget *parent, @@ -47,8 +48,7 @@ VisualDatabaseDisplaySubTypeFilterWidget::VisualDatabaseDisplaySubTypeFilterWidg layout->addWidget(flowWidget); // Create a container for the threshold control - auto *thresholdLayout = new QHBoxLayout(); - thresholdLayout->setContentsMargins(0, 0, 0, 0); + auto *thresholdLayout = QtUtils::makeLayout(); thresholdLabel = new QLabel(this); thresholdLayout->addWidget(thresholdLabel); diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp index 1a54ff0f5..ecf59cfc0 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp @@ -54,6 +54,7 @@ #include #include #include +#include #include class QMouseEvent; @@ -92,10 +93,8 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, // Set up main layout and widgets setMinimumSize(0, 0); setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); - mainLayout = new QVBoxLayout(this); + mainLayout = QtUtils::makeLayout(this, 0, 1); setLayout(mainLayout); - mainLayout->setSpacing(1); - mainLayout->setContentsMargins(0, 0, 0, 0); flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarPolicy::ScrollBarAsNeeded); cardSizeWidget = new CardSizeWidget(this, flowWidget, diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp index 5d7979e15..63ef07835 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp @@ -26,9 +26,7 @@ VisualDeckDisplayOptionsWidget::VisualDeckDisplayOptionsWidget(QWidget *parent) : QWidget(parent) { - groupAndSortLayout = new QHBoxLayout(this); - groupAndSortLayout->setContentsMargins(0, 0, 0, 0); - groupAndSortLayout->setSpacing(3); + groupAndSortLayout = QtUtils::makeLayout(this, 3); groupAndSortLayout->setAlignment(Qt::AlignLeft); this->setLayout(groupAndSortLayout); diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_sample_hand_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_sample_hand_widget.cpp index 1554dceb8..91ad63433 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_sample_hand_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_sample_hand_widget.cpp @@ -25,6 +25,7 @@ #include #include #include +#include #include #include #include @@ -41,9 +42,7 @@ VisualDeckEditorSampleHandWidget::VisualDeckEditorSampleHandWidget(QWidget *pare layout->setSpacing(0); setLayout(layout); - auto upperLayout = new QVBoxLayout(); - upperLayout->setContentsMargins(0, 0, 0, 0); - upperLayout->setSpacing(0); + auto upperLayout = QtUtils::makeLayout(nullptr, 0); resetAndHandSizeContainerWidget = new QWidget(this); resetAndHandSizeLayout = new QHBoxLayout(resetAndHandSizeContainerWidget); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.cpp index dc619e9fa..4e6e7cc05 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include @@ -26,8 +27,7 @@ DeckPreviewDeckTagsDisplayWidget::DeckPreviewDeckTagsDisplayWidget(QWidget *_par setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum); // Create layout - auto *layout = new QHBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); + auto *layout = QtUtils::makeLayout(this); setFixedHeight(100); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_item_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_item_widget.cpp index 7796f893b..8850060ea 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_item_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_item_widget.cpp @@ -2,13 +2,12 @@ #include #include +#include DeckPreviewTagItemWidget::DeckPreviewTagItemWidget(const QString &tagName, bool isChecked, QWidget *parent) : QWidget(parent), checkBox_(new QCheckBox(this)) { - auto *layout = new QHBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(5); + auto *layout = QtUtils::makeLayout(this, 5); checkBox_->setText(tagName); // Set the tag name as the checkbox label checkBox_->setChecked(isChecked); // Set the initial state of the checkbox diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/public_deck_preview_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/public_deck_preview_widget.cpp index 361747423..11a9da66f 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/public_deck_preview_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/public_deck_preview_widget.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include PublicDeckPreviewWidget::PublicDeckPreviewWidget(QWidget *parent, const RemotePublicDecksModel::DeckEntry &entry) @@ -36,8 +37,7 @@ PublicDeckPreviewWidget::PublicDeckPreviewWidget(QWidget *parent, const RemotePu tagsFlowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded); tagsFlowWidget->setSpacing(3, 3); - auto *layout = new QVBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); + auto *layout = QtUtils::makeLayout(this); layout->addWidget(bannerCardDisplayWidget); layout->addWidget(uploadTimeLabel); layout->addWidget(colorIdentityWidget); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp index 2c03666c1..f2669f55d 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp @@ -9,6 +9,7 @@ #include #include #include +#include /** * @brief Constructs a search bar for filtering decks in the Visual Deck Storage. @@ -20,8 +21,7 @@ */ VisualDeckStorageSearchWidget::VisualDeckStorageSearchWidget(QWidget *parent) : QWidget(parent) { - layout = new QHBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); + layout = QtUtils::makeLayout(this); setLayout(layout); searchBar = new QLineEdit(this); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_widget.cpp index f798a72a9..e4285e360 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_widget.cpp @@ -7,11 +7,11 @@ #include #include #include +#include VisualDeckStorageSortWidget::VisualDeckStorageSortWidget(VisualDeckStorageWidget *parent) : QWidget(parent) { - layout = new QHBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); + layout = QtUtils::makeLayout(this); setLayout(layout); // Initialize the ComboBox From eac149656b1fa2a7072ebf13ab6fafedefd63a82 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:38:54 +0200 Subject: [PATCH 017/108] [QtUtils] Add window fade, path opening, bold font and confirmation helpers (#7621) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- .../libcockatrice/utility/qt_utils.h | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/libcockatrice_utility/libcockatrice/utility/qt_utils.h b/libcockatrice_utility/libcockatrice/utility/qt_utils.h index 39a9e01e6..1d06c4d72 100644 --- a/libcockatrice_utility/libcockatrice/utility/qt_utils.h +++ b/libcockatrice_utility/libcockatrice/utility/qt_utils.h @@ -1,11 +1,15 @@ #ifndef COCKATRICE_QT_UTILS_H #define COCKATRICE_QT_UTILS_H +#include #include #include #include +#include #include +#include #include #include +#include #include namespace QtUtils @@ -141,6 +145,64 @@ static inline QFont monospaceFont() } return font; } + +/** + * @brief Fades @p window between two opacity values. + * + * @param window Window to animate. + * @param startOpacity Starting opacity. + * @param endOpacity Ending opacity. + * @param durationMs Duration in milliseconds. + * @param hideWhenFinished Hide the window when the animation finishes. + * @return The running animation. It is owned by @p window and deletes itself when stopped. + */ +static inline QPropertyAnimation * +fadeWindow(QWidget *window, qreal startOpacity, qreal endOpacity, int durationMs, bool hideWhenFinished = false) +{ + auto *animation = new QPropertyAnimation(window, "windowOpacity", window); + animation->setDuration(durationMs); + animation->setStartValue(startOpacity); + animation->setEndValue(endOpacity); + if (hideWhenFinished) { + QObject::connect(animation, &QPropertyAnimation::finished, window, &QWidget::hide); + } + animation->start(QAbstractAnimation::DeleteWhenStopped); + return animation; +} + +/** + * @brief Opens @p path with the platform's file manager or default handler. + */ +static inline bool openPathInFileManager(const QString &path) +{ + return QDesktopServices::openUrl(QUrl::fromLocalFile(path)); +} + +/** + * @brief Returns @p font with bold styling enabled. + */ +static inline QFont boldFont(QFont font) +{ + font.setBold(true); + return font; +} + +/** + * @brief Shows a Yes/No confirmation dialog and returns true when the user confirms. + * + * @param parent Parent widget. + * @param title Dialog title. + * @param text Dialog text. + * @param defaultButton Button focused by default. NoButton lets Qt choose. + */ +static inline bool confirm(QWidget *parent, + const QString &title, + const QString &text, + QMessageBox::StandardButton defaultButton = QMessageBox::NoButton) +{ + return QMessageBox::question(parent, title, text, QMessageBox::Yes | QMessageBox::No, defaultButton) == + QMessageBox::Yes; +} } // namespace QtUtils #endif // COCKATRICE_QT_UTILS_H From 11e2bb0d2f93317d8371d3191b784a398e110e45 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:43:50 +0200 Subject: [PATCH 018/108] [QtUtils] Adopt window fade, path, bold font and confirmation helpers (#7622) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- .../remote_connection_controller.cpp | 6 +++--- .../deckview/deck_view_container.cpp | 4 +--- .../intents/intent_open_shared_deck.cpp | 13 ++++++------- cockatrice/src/interface/intents/url_parser.cpp | 14 ++++++-------- .../palette_editor/palette_grid_widget.cpp | 8 ++------ .../dialogs/dlg_convert_deck_to_cod_format.cpp | 8 +++----- .../widgets/dialogs/dlg_report_user.cpp | 8 ++------ .../widgets/dialogs/dlg_shared_decks_preview.cpp | 4 ++-- .../src/interface/widgets/dialogs/dlg_update.cpp | 16 ++++++---------- .../dialogs/override_printing_warning.cpp | 6 ++---- .../widgets/onboarding/first_run_wizard.cpp | 3 +-- .../interface/widgets/server/game_selector.cpp | 7 +++---- .../widgets/server/user/user_context_menu.cpp | 7 +++---- .../widgets/server/user/user_list_widget.cpp | 16 +++------------- .../settings_page/appearance_settings_page.cpp | 5 ++--- .../settings_page/general_settings_page.cpp | 6 ++---- .../settings_page/shortcut_settings_page.cpp | 8 ++++---- .../interface/widgets/tabs/tab_deck_storage.cpp | 5 ++--- .../src/interface/widgets/tabs/tab_game.cpp | 13 ++++++------- .../interface/widgets/tabs/tab_public_decks.cpp | 5 ++--- .../src/interface/widgets/tabs/tab_replays.cpp | 5 ++--- .../interface/widgets/tabs/tab_supervisor.cpp | 6 +++--- .../widgets/utility/card_completer_delegate.cpp | 4 ++-- .../widgets/utility/card_completer_styler.cpp | 8 ++------ .../visual_database_filter_display_widget.cpp | 7 +++---- cockatrice/src/interface/window_main.cpp | 11 +++++------ 26 files changed, 78 insertions(+), 125 deletions(-) diff --git a/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp b/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp index 037565c6a..e23e91b46 100644 --- a/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp +++ b/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include class QWidget; @@ -300,9 +301,8 @@ void ConnectionController::onLoginError(int r, } case Response::RespRegistrationRequired: { - if (QMessageBox::question(dialogParent, tr("Error"), - tr("This server requires user registration. Do you want to register now?"), - QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { + if (QtUtils::confirm(dialogParent, tr("Error"), + tr("This server requires user registration. Do you want to register now?"))) { registerToServer(); } return; // don't re-prompt connect diff --git a/cockatrice/src/game_graphics/deckview/deck_view_container.cpp b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp index bd5a0271c..90b3bea11 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view_container.cpp +++ b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp @@ -442,9 +442,7 @@ void DeckViewContainer::readyStart() void DeckViewContainer::forceStart() { const auto msg = tr("Are you sure you want to force start?\nThis will kick all non-ready players from the game."); - const auto res = - QMessageBox::question(this, tr("Cockatrice"), msg, QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes); - if (res == QMessageBox::No) { + if (!QtUtils::confirm(this, tr("Cockatrice"), msg, QMessageBox::Yes)) { return; } diff --git a/cockatrice/src/interface/intents/intent_open_shared_deck.cpp b/cockatrice/src/interface/intents/intent_open_shared_deck.cpp index 8abad3d88..2998ff78e 100644 --- a/cockatrice/src/interface/intents/intent_open_shared_deck.cpp +++ b/cockatrice/src/interface/intents/intent_open_shared_deck.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -184,13 +185,11 @@ void IntentOpenSharedDeck::onItemFailure(const QString &reason) } const int downloadedCount = loadedDecks.size(); - const QMessageBox::StandardButton answer = QMessageBox::question( - previewDialog, tr("Open shared decks"), - tr("Could not download the deck \"%1\".\n\n%n deck(s) were already downloaded. Open them?", "", downloadedCount) - .arg(itemNames.value(currentItemId, tr("Unknown deck"))), - QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes); - - if (answer == QMessageBox::Yes) { + if (QtUtils::confirm(previewDialog, tr("Open shared decks"), + tr("Could not download the deck \"%1\".\n\n%n deck(s) were already downloaded. Open them?", "", + downloadedCount) + .arg(itemNames.value(currentItemId, tr("Unknown deck"))), + QMessageBox::Yes)) { finishAll(); } else { previewDialog->deleteLater(); diff --git a/cockatrice/src/interface/intents/url_parser.cpp b/cockatrice/src/interface/intents/url_parser.cpp index 966fd1213..1e482a4f1 100644 --- a/cockatrice/src/interface/intents/url_parser.cpp +++ b/cockatrice/src/interface/intents/url_parser.cpp @@ -28,6 +28,7 @@ #include #include #include +#include #include #include #include @@ -115,9 +116,7 @@ Intent *IntentUrlParser::createJoinGameIntent(const QUrlQuery &query, PendingInt const QString gameDescription = query.queryItemValue("game", QUrl::FullyDecoded); const QString message = generateJoinGameMessage(*ctx, gameDescription); - const QMessageBox::StandardButton answer = QMessageBox::question( - mainWindow, tr("Join game"), message, QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes); - if (answer != QMessageBox::Yes) { + if (!QtUtils::confirm(mainWindow, tr("Join game"), message, QMessageBox::Yes)) { return nullptr; } @@ -231,11 +230,10 @@ Intent *IntentUrlParser::createOpenDeckIntent(const QUrlQuery &query, PendingInt if (client->getStatus() == StatusLoggedIn) { const QString current = QStringLiteral("%1:%2").arg(client->serverName(), QString::number(client->serverPort())); - const QMessageBox::StandardButton answer = QMessageBox::question( - mainWindow, tr("Open shared deck"), - tr("Opening this share link connects you to %1 instead of %2.\n\nContinue?").arg(target, current), - QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes); - if (answer != QMessageBox::Yes) { + if (!QtUtils::confirm( + mainWindow, tr("Open shared deck"), + tr("Opening this share link connects you to %1 instead of %2.\n\nContinue?").arg(target, current), + QMessageBox::Yes)) { return nullptr; } chain.migrationTargetHost = ctx->serverContext.hostname; diff --git a/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp b/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp index 9546e0e7e..dc61a0099 100644 --- a/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp +++ b/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp @@ -102,9 +102,7 @@ void PaletteGridWidget::buildGrid(QWidget *host) auto *label = new QLabel(groupHeaders[col], host); label->setAlignment(Qt::AlignCenter); label->setToolTip(groupTips[col]); - QFont f = label->font(); - f.setBold(true); - label->setFont(f); + label->setFont(QtUtils::boldFont(label->font())); label->setAutoFillBackground(true); label->setContentsMargins(4, 4, 4, 4); grid->addWidget(label, 0, col + 1); @@ -149,9 +147,7 @@ void PaletteGridWidget::buildGrid(QWidget *host) auto *appHeader = new QLabel(tr("App colors"), host); appHeader->setToolTip(tr("Application-specific colors layered on top of the Qt palette")); - QFont appHeaderFont = appHeader->font(); - appHeaderFont.setBold(true); - appHeader->setFont(appHeaderFont); + appHeader->setFont(QtUtils::boldFont(appHeader->font())); appHeader->setAutoFillBackground(true); appHeader->setContentsMargins(4, 4, 4, 4); grid->addWidget(appHeader, appHeaderRow, 0, 1, 4); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp index f2dd8c1e4..31841b9cd 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp @@ -13,6 +13,7 @@ #include #include #include +#include DialogConvertDeckToCodFormat::DialogConvertDeckToCodFormat(QWidget *parent) : QDialog(parent) { @@ -57,11 +58,8 @@ bool confirmOverwriteIfExists(QWidget *parent, const QString &filePath) QString newFileName = QDir::toNativeSeparators(fileInfo.path() + "/" + fileInfo.completeBaseName() + ".cod"); if (QFile::exists(newFileName)) { - QMessageBox::StandardButton reply = - QMessageBox::question(parent, QObject::tr("Overwrite Existing File?"), - QObject::tr("A .cod version of this deck already exists. Overwrite it?"), - QMessageBox::Yes | QMessageBox::No); - return reply == QMessageBox::Yes; + return QtUtils::confirm(parent, QObject::tr("Overwrite Existing File?"), + QObject::tr("A .cod version of this deck already exists. Overwrite it?")); } return true; // Safe to proceed } diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_report_user.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_report_user.cpp index c3bd7c0f1..3ef45020b 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_report_user.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_report_user.cpp @@ -128,12 +128,8 @@ void DlgReportUser::actSubmit() return; } - QMessageBox::StandardButton reply = - QMessageBox::question(this, tr("Confirm Report"), - tr("Submit report against %1 for %2?").arg(reportedUser, categoryBox->currentText()), - QMessageBox::Yes | QMessageBox::No); - - if (reply != QMessageBox::Yes) { + if (!QtUtils::confirm(this, tr("Confirm Report"), + tr("Submit report against %1 for %2?").arg(reportedUser, categoryBox->currentText()))) { return; } diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp index 8ad91dcaf..d29aee27f 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include class QWidget; @@ -34,8 +35,7 @@ DlgSharedDecksPreview::DlgSharedDecksPreview(QWidget *parent, // QLabels render AutoText and markup would otherwise be shown as rich text. auto *titleLabel = new QLabel(tr("Share: %1").arg((shareName.isEmpty() ? tr("Untitled") : shareName).toHtmlEscaped()), this); - QFont titleFont = titleLabel->font(); - titleFont.setBold(true); + QFont titleFont = QtUtils::boldFont(titleLabel->font()); titleFont.setPointSize(titleFont.pointSize() + 2); titleLabel->setFont(titleFont); mainLayout->addWidget(titleLabel); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp index 76bb13248..5ec8dcf16 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp @@ -30,6 +30,7 @@ #include #include #include +#include #include #include @@ -170,16 +171,11 @@ void DlgUpdate::finishedUpdateCheck(bool needToUpdate, bool isCompatible, Releas publishDate = release->getPublishDate().toString(QLocale().dateFormat(QLocale::LongFormat)); if (isCompatible) { - int reply; - reply = QMessageBox::question( - window(), tr("Update Available"), - tr("A new version of Cockatrice is available!") + "

" + "" + tr("New version") + - QString(": %1
").arg(release->getName()) + "" + tr("Released") + - QString(": %1 (").arg(publishDate, release->getDescriptionUrl()) + tr("Changelog") + - ")

" + tr("Do you want to update now?"), - QMessageBox::Yes | QMessageBox::No); - - if (reply == QMessageBox::Yes) { + if (QtUtils::confirm(window(), tr("Update Available"), + tr("A new version of Cockatrice is available!") + "

" + "" + tr("New version") + + QString(": %1
").arg(release->getName()) + "" + tr("Released") + + QString(": %1 (").arg(publishDate, release->getDescriptionUrl()) + + tr("Changelog") + ")

" + tr("Do you want to update now?"))) { downloadUpdate(release->getName()); } else { closeDialog(); diff --git a/cockatrice/src/interface/widgets/dialogs/override_printing_warning.cpp b/cockatrice/src/interface/widgets/dialogs/override_printing_warning.cpp index 57f7ff7f5..8230dc3bc 100644 --- a/cockatrice/src/interface/widgets/dialogs/override_printing_warning.cpp +++ b/cockatrice/src/interface/widgets/dialogs/override_printing_warning.cpp @@ -7,6 +7,7 @@ #include #include #include +#include class QWidget; @@ -30,10 +31,7 @@ bool OverridePrintingWarning::execMessageBox(QWidget *parent, bool enable) "Are you sure you would like to disable this feature?"); } - QMessageBox::StandardButton result = - QMessageBox::question(parent, QObject::tr("Confirm Change"), message, QMessageBox::Yes | QMessageBox::No); - - if (result == QMessageBox::Yes) { + if (QtUtils::confirm(parent, QObject::tr("Confirm Change"), message)) { SettingsCache::instance().cardsDisplay().setOverrideAllCardArtWithPersonalPreference(enable); // Caches are now invalid. CardPictureLoader::clearPixmapCache(); diff --git a/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp index 39606d167..c4298d6b6 100644 --- a/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp +++ b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp @@ -32,9 +32,8 @@ FirstRunWizard::FirstRunWizard(QWidget *parent) : QDialog(parent) bannerHost = new BannerHost(this); titleLabel = new QLabel(this); - QFont titleFont = titleLabel->font(); + QFont titleFont = QtUtils::boldFont(titleLabel->font()); titleFont.setPointSizeF(titleFont.pointSizeF() * 1.4); - titleFont.setBold(true); titleLabel->setFont(titleFont); subtitleLabel = new QLabel(this); diff --git a/cockatrice/src/interface/widgets/server/game_selector.cpp b/cockatrice/src/interface/widgets/server/game_selector.cpp index 6922bfe64..2702a527d 100644 --- a/cockatrice/src/interface/widgets/server/game_selector.cpp +++ b/cockatrice/src/interface/widgets/server/game_selector.cpp @@ -41,6 +41,7 @@ #include #include #include +#include #include #include #include @@ -420,10 +421,8 @@ void GameSelector::joinGame(const ServerInfo_Game &game, const bool asSpectator, // spectator join, so ask first instead of surprising the player. const bool gameFull = game.player_count() == game.max_players(); if (gameFull && !asSpectator && !asJudge && !overrideRestrictions) { - const QMessageBox::StandardButton answer = - QMessageBox::question(this, tr("Join game"), tr("The game is full. Join as a spectator instead?"), - QMessageBox::Yes | QMessageBox::No, QMessageBox::No); - if (answer != QMessageBox::Yes) { + if (!QtUtils::confirm(this, tr("Join game"), tr("The game is full. Join as a spectator instead?"), + QMessageBox::No)) { return; } } diff --git a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp index c8fe97cda..0b5523f67 100644 --- a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp @@ -49,6 +49,7 @@ #include #include #include +#include #include #include @@ -685,10 +686,8 @@ void UserContextMenu::execRemoveFromIgnore(const QString &userName) void UserContextMenu::execKick(int playerId) { - auto result = QMessageBox::question(static_cast(parent()), tr("Kick Player"), - tr("Are you sure you want to kick this player from the game?"), - QMessageBox::Yes | QMessageBox::No, QMessageBox::No); - if (result == QMessageBox::Yes) { + if (QtUtils::confirm(static_cast(parent()), tr("Kick Player"), + tr("Are you sure you want to kick this player from the game?"), QMessageBox::No)) { Command_KickFromGame cmd; cmd.set_player_id(playerId); diff --git a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp index b3fd1d1a9..efbdcfb23 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp @@ -13,7 +13,6 @@ #include "user_list_painter.h" #include "user_list_proxy.h" -#include #include #include #include @@ -41,7 +40,6 @@ #include #include #include -#include #include #include #include @@ -58,6 +56,7 @@ #include #include #include +#include #include class QPixmap; @@ -895,11 +894,7 @@ void UserListWidget::showPopupForUser(UserListTWI *item) return; } - auto *fade = new QPropertyAnimation(userInfoPopup, "windowOpacity", userInfoPopup); - fade->setDuration(120); - fade->setStartValue(0.0); - fade->setEndValue(1.0); - fade->start(QAbstractAnimation::DeleteWhenStopped); + QtUtils::fadeWindow(userInfoPopup, 0.0, 1.0, 120); } void UserListWidget::positionPopup(UserListTWI *item) @@ -976,12 +971,7 @@ void UserListWidget::hidePopup(bool immediate) } // Fade out - auto *fade = new QPropertyAnimation(userInfoPopup, "windowOpacity", userInfoPopup); - fade->setDuration(100); - fade->setStartValue(userInfoPopup->windowOpacity()); - fade->setEndValue(0.0); - connect(fade, &QPropertyAnimation::finished, userInfoPopup, &QWidget::hide); - fade->start(QAbstractAnimation::DeleteWhenStopped); + QtUtils::fadeWindow(userInfoPopup, userInfoPopup->windowOpacity(), 0.0, 100, true); } bool UserListWidget::isPressInsideListUi(const QWidget *widget) const diff --git a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp index be2d5735e..de5f67b32 100644 --- a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp @@ -17,7 +17,6 @@ #include #include #include -#include #include #include #include @@ -30,7 +29,6 @@ #include #include #include -#include #include #include #include @@ -39,6 +37,7 @@ #include #include #include +#include #include AppearanceSettingsPage::AppearanceSettingsPage() @@ -446,7 +445,7 @@ void AppearanceSettingsPage::openThemeLocation() dirDir.cdUp(); // open if dir exists, create if parent dir does exist if (dirDir.exists() && dirDir.mkpath(dir)) { - QDesktopServices::openUrl(QUrl::fromLocalFile(dir)); + QtUtils::openPathInFileManager(dir); } else { QMessageBox::critical(this, tr("Error"), tr("Could not create themes directory at '%1'.").arg(dir)); } diff --git a/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp index a19417661..2b885237b 100644 --- a/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp @@ -35,6 +35,7 @@ #include #include #include +#include #include #include #include @@ -547,12 +548,9 @@ void GeneralSettingsPage::cardLanguageBoxChanged(int index) } message += tr("

Run Oracle now?

"); - const QMessageBox::StandardButton answer = QMessageBox::question( - this, tr("Card text & images language changed"), message, QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes); - // The answer only controls whether Oracle starts right away; the caches stay // cleared so the next import or launch rebuilds them in the new language. - if (answer == QMessageBox::Yes) { + if (QtUtils::confirm(this, tr("Card text & images language changed"), message, QMessageBox::Yes)) { emit cardDatabaseUpdateRequested(); } } diff --git a/cockatrice/src/interface/widgets/settings_page/shortcut_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/shortcut_settings_page.cpp index ea7ed745c..a77c0c2c3 100644 --- a/cockatrice/src/interface/widgets/settings_page/shortcut_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/shortcut_settings_page.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include ShortcutSettingsPage::ShortcutSettingsPage() @@ -112,16 +113,15 @@ void ShortcutSettingsPage::currentItemChanged(const QString &key) void ShortcutSettingsPage::resetShortcuts() { - if (QMessageBox::question(this, tr("Restore all default shortcuts"), - tr("Do you really want to restore all default shortcuts?")) == QMessageBox::Yes) { + if (QtUtils::confirm(this, tr("Restore all default shortcuts"), + tr("Do you really want to restore all default shortcuts?"))) { SettingsCache::instance().shortcuts().resetAllShortcuts(); } } void ShortcutSettingsPage::clearShortcuts() { - if (QMessageBox::question(this, tr("Clear all default shortcuts"), - tr("Do you really want to clear all shortcuts?")) == QMessageBox::Yes) { + if (QtUtils::confirm(this, tr("Clear all default shortcuts"), tr("Do you really want to clear all shortcuts?"))) { SettingsCache::instance().shortcuts().clearAllShortcuts(); } } diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp index 44d04bd24..9db1952f1 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp @@ -20,7 +20,6 @@ #include #include #include -#include #include #include #include @@ -45,7 +44,6 @@ #include #include #include -#include #include #include #include @@ -65,6 +63,7 @@ #include #include #include +#include #include #include #include @@ -524,7 +523,7 @@ void TabDeckStorage::actDeleteLocalDeck() void TabDeckStorage::actOpenDecksFolder() { QString dir = localDirModel->rootPath(); - QDesktopServices::openUrl(QUrl::fromLocalFile(dir)); + QtUtils::openPathInFileManager(dir); } void TabDeckStorage::actRemoteDoubleClick(const QModelIndex &curRight) diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.cpp b/cockatrice/src/interface/widgets/tabs/tab_game.cpp index 309720bca..11813cdf4 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_game.cpp @@ -606,15 +606,14 @@ void TabGame::actConcede() return; } if (!player->getConceded()) { - if (QMessageBox::question(this, tr("Concede"), tr("Are you sure you want to concede this game?"), - QMessageBox::Yes | QMessageBox::No, QMessageBox::No) != QMessageBox::Yes) { + if (!QtUtils::confirm(this, tr("Concede"), tr("Are you sure you want to concede this game?"), + QMessageBox::No)) { return; } emit game->getPlayerManager()->activeLocalPlayerConceded(); } else { - if (QMessageBox::question(this, tr("Unconcede"), - tr("You have already conceded. Do you want to return to this game?"), - QMessageBox::Yes | QMessageBox::No, QMessageBox::No) != QMessageBox::Yes) { + if (!QtUtils::confirm(this, tr("Unconcede"), + tr("You have already conceded. Do you want to return to this game?"), QMessageBox::No)) { return; } emit game->getPlayerManager()->activeLocalPlayerUnconceded(); @@ -631,8 +630,8 @@ bool TabGame::leaveGame() if (!game->getGameState()->isGameClosed()) { if (!game->getPlayerManager()->isSpectator()) { tabSupervisor->setCurrentWidget(this); - if (QMessageBox::question(this, tr("Leave game"), tr("Are you sure you want to leave this game?"), - QMessageBox::Yes | QMessageBox::No, QMessageBox::No) != QMessageBox::Yes) { + if (!QtUtils::confirm(this, tr("Leave game"), tr("Are you sure you want to leave this game?"), + QMessageBox::No)) { return false; } } diff --git a/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp b/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp index 6bf3f6be7..9c28abd0b 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp @@ -34,6 +34,7 @@ #include #include #include +#include #include #include @@ -44,9 +45,7 @@ TabPublicDecks::TabPublicDecks(TabSupervisor *_tabSupervisor, AbstractClient *_c cardSize = SettingsCache::instance().cardsDisplay().getVisualDeckStorageCardSize(); titleLabel = new QLabel(tr("Public decks of %1").arg(userName.toHtmlEscaped()), this); - QFont titleFont = titleLabel->font(); - titleFont.setBold(true); - titleLabel->setFont(titleFont); + titleLabel->setFont(QtUtils::boldFont(titleLabel->font())); auto *headerLayout = new QHBoxLayout; headerLayout->addWidget(titleLabel); diff --git a/cockatrice/src/interface/widgets/tabs/tab_replays.cpp b/cockatrice/src/interface/widgets/tabs/tab_replays.cpp index 52bc4cd8f..d37bff3d8 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_replays.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_replays.cpp @@ -16,7 +16,6 @@ #include #include #include -#include #include #include #include @@ -45,7 +44,6 @@ #include #include #include -#include #include #include #include @@ -61,6 +59,7 @@ #include #include #include +#include #include #include #include @@ -400,7 +399,7 @@ void TabReplays::actDeleteLocalReplay() void TabReplays::actOpenReplaysFolder() { QString dir = localDirModel->rootPath(); - QDesktopServices::openUrl(QUrl::fromLocalFile(dir)); + QtUtils::openPathInFileManager(dir); } void TabReplays::actRemoteDoubleClick(const QModelIndex &curRight) diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index 3d7ca0a98..8ec76aaf0 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -79,6 +79,7 @@ #include #include #include +#include #include #include #include @@ -363,9 +364,8 @@ void TabSupervisor::closeEvent(QCloseEvent *event) QTabWidget::closeEvent(event); if (getGameCount()) { - if (QMessageBox::question(this, tr("Are you sure?"), - tr("There are still open games. Are you sure you want to quit?"), - QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::No) { + if (!QtUtils::confirm(this, tr("Are you sure?"), + tr("There are still open games. Are you sure you want to quit?"), QMessageBox::No)) { event->ignore(); return; } diff --git a/cockatrice/src/interface/widgets/utility/card_completer_delegate.cpp b/cockatrice/src/interface/widgets/utility/card_completer_delegate.cpp index af07590a8..a629df43c 100644 --- a/cockatrice/src/interface/widgets/utility/card_completer_delegate.cpp +++ b/cockatrice/src/interface/widgets/utility/card_completer_delegate.cpp @@ -34,6 +34,7 @@ #include #include #include +#include #include class QObject; @@ -181,9 +182,8 @@ void CardCompleterDelegate::drawManaSymbol(QPainter *p, QPoint centre, const QSt p->setBrush(Qt::NoBrush); p->drawEllipse(pip.adjusted(1, 1, -1, -1)); - QFont f = p->font(); + QFont f = QtUtils::boldFont(p->font()); f.setPixelSize(qMax(radius - 1, 7)); - f.setBold(true); p->setFont(f); p->setPen(mc.text); diff --git a/cockatrice/src/interface/widgets/utility/card_completer_styler.cpp b/cockatrice/src/interface/widgets/utility/card_completer_styler.cpp index 5b33303a0..8f1941412 100644 --- a/cockatrice/src/interface/widgets/utility/card_completer_styler.cpp +++ b/cockatrice/src/interface/widgets/utility/card_completer_styler.cpp @@ -31,6 +31,7 @@ #include #include #include +#include #include template class QSharedPointer; @@ -346,12 +347,7 @@ void CardCompleterStyler::showPreview() preview->show(); preview->raise(); - auto *fade = new QPropertyAnimation(preview, "windowOpacity", preview); - - fade->setDuration(FadeDuration); - fade->setStartValue(preview->windowOpacity()); - fade->setEndValue(1.0); - fade->start(QAbstractAnimation::DeleteWhenStopped); + QtUtils::fadeWindow(preview, preview->windowOpacity(), 1.0, FadeDuration); } // --------------------------------------------------------------------------- diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_filter_display_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_filter_display_widget.cpp index 0f6ac51ec..d13db1273 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_filter_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_filter_display_widget.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include FilterDisplayWidget::FilterDisplayWidget(QWidget *parent, const QString &filename, FilterTreeModel *_filterModel) @@ -69,10 +70,8 @@ void FilterDisplayWidget::loadFilter() void FilterDisplayWidget::deleteFilter() { // Show confirmation dialog before deleting the filter - QMessageBox::StandardButton reply = QMessageBox::question( - this, tr("Confirm Delete"), tr("Are you sure you want to delete the filter '%1'?").arg(filterFilename), - QMessageBox::Yes | QMessageBox::No); - if (reply == QMessageBox::Yes) { + if (QtUtils::confirm(this, tr("Confirm Delete"), + tr("Are you sure you want to delete the filter '%1'?").arg(filterFilename))) { // If confirmed, delete the filter QString filePath = SettingsCache::instance().paths().getFiltersPath() + QDir::separator() + filterFilename; QFile file(filePath); diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index 636cee281..a3446e42b 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -62,7 +62,6 @@ #include #include #include -#include #include #include #include @@ -112,6 +111,7 @@ #include #include #include +#include #include #include #include @@ -341,7 +341,7 @@ void MainWindow::actViewLog() void MainWindow::actOpenSettingsFolder() { QString dir = SettingsCache::instance().getSettingsPath(); - QDesktopServices::openUrl(QUrl::fromLocalFile(dir)); + QtUtils::openPathInFileManager(dir); } void MainWindow::retranslateUi() @@ -937,8 +937,7 @@ void MainWindow::closeEvent(QCloseEvent *event) : tr("A card database update is still running: %1.\nQuitting now will cancel it.\n" "Are you sure you want to quit?") .arg(runningFor); - if (QMessageBox::question(this, tr("Are you sure?"), message, QMessageBox::Yes | QMessageBox::No, - QMessageBox::No) == QMessageBox::No) { + if (!QtUtils::confirm(this, tr("Are you sure?"), message, QMessageBox::No)) { event->ignore(); bClosingDown = false; return; @@ -1491,13 +1490,13 @@ void MainWindow::refreshShortcuts() void MainWindow::actOpenCustomFolder() { QString dir = SettingsCache::instance().paths().getCustomPicsPath(); - QDesktopServices::openUrl(QUrl::fromLocalFile(dir)); + QtUtils::openPathInFileManager(dir); } void MainWindow::actOpenCustomsetsFolder() { QString dir = SettingsCache::instance().getCustomCardDatabasePath(); - QDesktopServices::openUrl(QUrl::fromLocalFile(dir)); + QtUtils::openPathInFileManager(dir); } void MainWindow::actAddCustomSet() From 28c8474f033fee859617ddb72c943c283634cf2d Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:43:10 +0200 Subject: [PATCH 019/108] [QtUtils] Add Debouncer helper (#7623) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [QtUtils] Add Debouncer helper * Extract long message to variable before confirm() call --------- Co-authored-by: Lukas Brübach --- .../interface/widgets/dialogs/dlg_update.cpp | 12 ++-- libcockatrice_utility/CMakeLists.txt | 1 + .../libcockatrice/utility/debouncer.h | 62 +++++++++++++++++++ 3 files changed, 70 insertions(+), 5 deletions(-) create mode 100644 libcockatrice_utility/libcockatrice/utility/debouncer.h diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp index 5ec8dcf16..ca55c7b41 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp @@ -171,11 +171,13 @@ void DlgUpdate::finishedUpdateCheck(bool needToUpdate, bool isCompatible, Releas publishDate = release->getPublishDate().toString(QLocale().dateFormat(QLocale::LongFormat)); if (isCompatible) { - if (QtUtils::confirm(window(), tr("Update Available"), - tr("A new version of Cockatrice is available!") + "

" + "" + tr("New version") + - QString(": %1
").arg(release->getName()) + "" + tr("Released") + - QString(": %1 (").arg(publishDate, release->getDescriptionUrl()) + - tr("Changelog") + ")

" + tr("Do you want to update now?"))) { + const QString message = tr("A new version of Cockatrice is available!") + "

" + "" + + tr("New version") + QString(": %1
").arg(release->getName()) + "" + + tr("Released") + + QString(": %1 (").arg(publishDate, release->getDescriptionUrl()) + + tr("Changelog") + ")

" + tr("Do you want to update now?"); + + if (QtUtils::confirm(window(), tr("Update Available"), message)) { downloadUpdate(release->getName()); } else { closeDialog(); diff --git a/libcockatrice_utility/CMakeLists.txt b/libcockatrice_utility/CMakeLists.txt index cc0168cb8..748f6b90f 100644 --- a/libcockatrice_utility/CMakeLists.txt +++ b/libcockatrice_utility/CMakeLists.txt @@ -21,6 +21,7 @@ set(UTILITY_HEADERS libcockatrice/utility/card_ref.h libcockatrice/utility/color.h libcockatrice/utility/cryptoutil.h + libcockatrice/utility/debouncer.h libcockatrice/utility/expression.h libcockatrice/utility/levenshtein.h libcockatrice/utility/local_server_name.h diff --git a/libcockatrice_utility/libcockatrice/utility/debouncer.h b/libcockatrice_utility/libcockatrice/utility/debouncer.h new file mode 100644 index 000000000..54635db4f --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/debouncer.h @@ -0,0 +1,62 @@ +#ifndef COCKATRICE_DEBOUNCER_H +#define COCKATRICE_DEBOUNCER_H + +#include +#include +#include +#include + +namespace QtUtils +{ +/** + * @brief Runs the most recent call() after a period without further calls. + * + * Typical use is coalescing rapid model or editor updates into one refresh: + * + * debouncer.call([this] { refresh(); }); + * + * The pending callback is owned by the debouncer and replaced when call() is invoked + * again. The debouncer must outlive any callback it schedules; parenting it to the + * owning widget is usually enough. + */ +class Debouncer : public QObject +{ +public: + explicit Debouncer(int intervalMs, QObject *parent = nullptr) : QObject(parent) + { + timer.setSingleShot(true); + timer.setInterval(intervalMs); + connect(&timer, &QTimer::timeout, this, [this] { + if (callback) { + auto pending = std::move(callback); + callback = nullptr; + pending(); + } + }); + } + + /** + * @brief Schedules @p newCallback, replacing any callback not run yet. + */ + void call(std::function newCallback) + { + callback = std::move(newCallback); + timer.start(); + } + + /** + * @brief Drops the pending callback and stops the timer. + */ + void cancel() + { + timer.stop(); + callback = nullptr; + } + +private: + QTimer timer; + std::function callback; +}; +} // namespace QtUtils + +#endif // COCKATRICE_DEBOUNCER_H From 77a3875c25e051ccfa3b1a320603307dcb45018d Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:48:11 +0200 Subject: [PATCH 020/108] [VisualDatabase] Debounce filter refreshes with QtUtils::Debouncer (#7624) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- .../visual_database_display_color_filter_widget.cpp | 3 +-- .../visual_database_display_color_filter_widget.h | 2 ++ ...isual_database_display_format_legality_filter_widget.cpp | 6 ++---- .../visual_database_display_format_legality_filter_widget.h | 2 ++ .../visual_database_display_main_type_filter_widget.cpp | 6 ++---- .../visual_database_display_main_type_filter_widget.h | 2 ++ .../visual_database_display_name_filter_widget.cpp | 2 +- .../visual_database_display_name_filter_widget.h | 2 ++ .../visual_database_display_set_filter_widget.cpp | 5 ++--- .../visual_database_display_set_filter_widget.h | 2 ++ .../visual_database_display_sub_type_filter_widget.cpp | 6 ++---- .../visual_database_display_sub_type_filter_widget.h | 2 ++ 12 files changed, 22 insertions(+), 18 deletions(-) diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.cpp index cf815ace0..464d62e4a 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.cpp @@ -11,7 +11,6 @@ #include #include #include -#include #include #include #include @@ -49,7 +48,7 @@ VisualDatabaseDisplayColorFilterWidget::VisualDatabaseDisplayColorFilterWidget(Q &VisualDatabaseDisplayColorFilterWidget::updateFilterMode); connect(filterModel, &FilterTreeModel::layoutChanged, this, - [this]() { QTimer::singleShot(100, this, &VisualDatabaseDisplayColorFilterWidget::syncWithFilterModel); }); + [this]() { syncDebouncer.call([this] { syncWithFilterModel(); }); }); // Call retranslateUi to set the initial text retranslateUi(); diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.h index 38812b02a..184f1f96c 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.h +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.h @@ -12,6 +12,7 @@ #include #include #include +#include #include class FilterTreeModel; @@ -71,6 +72,7 @@ private: QHBoxLayout *layout; QComboBox *modeComboBox; FilterMode currentMode = FilterMode::Includes; // Default mode + QtUtils::Debouncer syncDebouncer{100}; }; #endif // VISUAL_DATABASE_DISPLAY_COLOR_FILTER_WIDGET_H diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_format_legality_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_format_legality_filter_widget.cpp index 3d6a6e56c..e0734fbb8 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_format_legality_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_format_legality_filter_widget.cpp @@ -16,7 +16,6 @@ #include #include #include -#include #include #include #include @@ -72,9 +71,8 @@ VisualDatabaseDisplayFormatLegalityFilterWidget::VisualDatabaseDisplayFormatLega layout->addWidget(toggleButton); connect(toggleButton, &QPushButton::clicked, this, &VisualDatabaseDisplayFormatLegalityFilterWidget::updateFilterMode); - connect(filterModel, &FilterTreeModel::layoutChanged, this, [this]() { - QTimer::singleShot(100, this, &VisualDatabaseDisplayFormatLegalityFilterWidget::syncWithFilterModel); - }); + connect(filterModel, &FilterTreeModel::layoutChanged, this, + [this]() { syncDebouncer.call([this] { syncWithFilterModel(); }); }); createFormatButtons(); // Populate buttons initially updateFilterMode(); // Initialize toggle button text diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_format_legality_filter_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_format_legality_filter_widget.h index e94593e67..6cc55062e 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_format_legality_filter_widget.h +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_format_legality_filter_widget.h @@ -4,6 +4,7 @@ #include #include #include +#include #include class FilterTreeModel; @@ -42,6 +43,7 @@ private: QMap formatButtons; // Store toggle buttons bool exactMatchMode = true; // Toggle between "Exact Match" and "Includes" + QtUtils::Debouncer syncDebouncer{100}; }; #endif // COCKATRICE_VISUAL_DATABASE_DISPLAY_FORMAT_LEGALITY_FILTER_WIDGET_H diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_main_type_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_main_type_filter_widget.cpp index 1f63a6b01..018841b30 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_main_type_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_main_type_filter_widget.cpp @@ -16,7 +16,6 @@ #include #include #include -#include #include #include #include @@ -65,9 +64,8 @@ VisualDatabaseDisplayMainTypeFilterWidget::VisualDatabaseDisplayMainTypeFilterWi toggleButton = new QPushButton(this); layout->addWidget(toggleButton); connect(toggleButton, &QPushButton::clicked, this, &VisualDatabaseDisplayMainTypeFilterWidget::updateFilterMode); - connect(filterModel, &FilterTreeModel::layoutChanged, this, [this]() { - QTimer::singleShot(100, this, &VisualDatabaseDisplayMainTypeFilterWidget::syncWithFilterModel); - }); + connect(filterModel, &FilterTreeModel::layoutChanged, this, + [this]() { syncDebouncer.call([this] { syncWithFilterModel(); }); }); createMainTypeButtons(); // Populate buttons initially updateFilterMode(); // Initialize toggle button text diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_main_type_filter_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_main_type_filter_widget.h index b5a6f332c..1e2fa2b39 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_main_type_filter_widget.h +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_main_type_filter_widget.h @@ -10,6 +10,7 @@ #include #include #include +#include #include class FilterTreeModel; @@ -48,6 +49,7 @@ private: QMap typeButtons; // Store toggle buttons bool exactMatchMode = true; // Toggle between "Exact Match" and "Includes" + QtUtils::Debouncer syncDebouncer{100}; }; #endif // VISUAL_DATABASE_DISPLAY_MAIN_TYPE_FILTER_WIDGET_H diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_name_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_name_filter_widget.cpp index 3e4e8bcc0..e335cd4c0 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_name_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_name_filter_widget.cpp @@ -62,7 +62,7 @@ VisualDatabaseDisplayNameFilterWidget::VisualDatabaseDisplayNameFilterWidget(QWi &VisualDatabaseDisplayNameFilterWidget::actLoadFromClipboard); connect(filterModel, &FilterTreeModel::layoutChanged, this, - [this]() { QTimer::singleShot(100, this, &VisualDatabaseDisplayNameFilterWidget::syncWithFilterModel); }); + [this]() { syncDebouncer.call([this] { syncWithFilterModel(); }); }); retranslateUi(); } diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_name_filter_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_name_filter_widget.h index 1629cb93b..ba2684e05 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_name_filter_widget.h +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_name_filter_widget.h @@ -10,6 +10,7 @@ #include #include #include +#include #include class DeckListModel; @@ -46,6 +47,7 @@ private: QPushButton *loadFromClipboardButton; QMap activeFilters; // Store active name filter buttons + QtUtils::Debouncer syncDebouncer{100}; private slots: void actLoadFromDeck(); diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_set_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_set_filter_widget.cpp index d15600564..fe583361a 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_set_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_set_filter_widget.cpp @@ -22,7 +22,6 @@ #include #include #include -#include #include #include #include @@ -101,7 +100,7 @@ VisualDatabaseDisplaySetFilterWidget::VisualDatabaseDisplaySetFilterWidget(QWidg layout->addWidget(toggleButton); connect(toggleButton, &QPushButton::clicked, this, &VisualDatabaseDisplaySetFilterWidget::updateFilterMode); connect(filterModel, &FilterTreeModel::layoutChanged, this, - [this]() { QTimer::singleShot(100, this, &VisualDatabaseDisplaySetFilterWidget::syncWithFilterModel); }); + [this]() { syncDebouncer.call([this] { syncWithFilterModel(); }); }); createSetButtons(); // Populate buttons initially updateFilterMode(); @@ -304,5 +303,5 @@ void VisualDatabaseDisplaySetFilterWidget::updateFilterMode() // Reconnect the layoutChanged -> sync lambda connect(filterModel, &FilterTreeModel::layoutChanged, this, - [this]() { QTimer::singleShot(100, this, &VisualDatabaseDisplaySetFilterWidget::syncWithFilterModel); }); + [this]() { syncDebouncer.call([this] { syncWithFilterModel(); }); }); } diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_set_filter_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_set_filter_widget.h index 468e97e0d..4874df79b 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_set_filter_widget.h +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_set_filter_widget.h @@ -10,6 +10,7 @@ #include #include #include +#include #include class FilterTreeModel; @@ -64,6 +65,7 @@ private: QMap activeSets; // Track active set filters bool exactMatchMode = true; // Toggle between "Exact Match" and "Includes" + QtUtils::Debouncer syncDebouncer{100}; }; #endif // VISUAL_DATABASE_DISPLAY_SET_FILTER_WIDGET_H diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_sub_type_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_sub_type_filter_widget.cpp index 26fdce64f..68a1edf11 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_sub_type_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_sub_type_filter_widget.cpp @@ -17,7 +17,6 @@ #include #include #include -#include #include #include #include @@ -70,9 +69,8 @@ VisualDatabaseDisplaySubTypeFilterWidget::VisualDatabaseDisplaySubTypeFilterWidg toggleButton = new QPushButton(this); layout->addWidget(toggleButton); connect(toggleButton, &QPushButton::clicked, this, &VisualDatabaseDisplaySubTypeFilterWidget::updateFilterMode); - connect(filterModel, &FilterTreeModel::layoutChanged, this, [this]() { - QTimer::singleShot(100, this, &VisualDatabaseDisplaySubTypeFilterWidget::syncWithFilterModel); - }); + connect(filterModel, &FilterTreeModel::layoutChanged, this, + [this]() { syncDebouncer.call([this] { syncWithFilterModel(); }); }); createSubTypeButtons(); // Populate buttons initially updateFilterMode(); // Initialize the toggle button text diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_sub_type_filter_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_sub_type_filter_widget.h index 7929063fd..8bef870ed 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_sub_type_filter_widget.h +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_sub_type_filter_widget.h @@ -10,6 +10,7 @@ #include #include #include +#include #include class FilterTreeModel; @@ -50,6 +51,7 @@ private: QMap typeButtons; // Store toggle buttons bool exactMatchMode = true; // Toggle between "Exact Match" and "Includes" + QtUtils::Debouncer syncDebouncer{100}; }; #endif // VISUAL_DATABASE_DISPLAY_SUB_TYPE_FILTER_WIDGET_H From 66b9338c90d33dde24273b6e0e904e492fb4e75c Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:50:25 +0200 Subject: [PATCH 021/108] [QtUtils] Use makeScrollArea() for the remaining scroll areas (#7625) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- .../src/interface/palette_editor/palette_grid_widget.cpp | 3 +-- .../widgets/deck_analytics/deck_analytics_widget.cpp | 4 ++-- .../widgets/onboarding/pages/preferences_setup_page.cpp | 4 ++-- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp b/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp index dc61a0099..463c4038d 100644 --- a/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp +++ b/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp @@ -66,8 +66,7 @@ static const QMap APP_ROLE_DESCRIPTIONS = { PaletteGridWidget::PaletteGridWidget(QWidget *parent) : QWidget(parent) { - scroll = new QScrollArea(this); - scroll->setWidgetResizable(true); + scroll = QtUtils::makeScrollArea(this, Qt::ScrollBarAsNeeded, Qt::ScrollBarAsNeeded); scroll->setFrameShape(QFrame::NoFrame); gridHost = new QWidget; diff --git a/cockatrice/src/interface/widgets/deck_analytics/deck_analytics_widget.cpp b/cockatrice/src/interface/widgets/deck_analytics/deck_analytics_widget.cpp index 9f6257930..e0ad7af43 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/deck_analytics_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/deck_analytics_widget.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include DeckAnalyticsWidget::DeckAnalyticsWidget(QWidget *parent, DeckListStatisticsAnalyzer *_statsAnalyzer) @@ -54,8 +55,7 @@ DeckAnalyticsWidget::DeckAnalyticsWidget(QWidget *parent, DeckListStatisticsAnal connect(includeSideboardCheckBox, &QCheckBox::clicked, this, &DeckAnalyticsWidget::includeSideboardChanged); // Scroll area and container - scrollArea = new QScrollArea(this); - scrollArea->setWidgetResizable(true); + scrollArea = QtUtils::makeScrollArea(this, Qt::ScrollBarAsNeeded, Qt::ScrollBarAsNeeded); scrollArea->setFrameShape(QFrame::NoFrame); panelContainer = new QWidget(scrollArea); diff --git a/cockatrice/src/interface/widgets/onboarding/pages/preferences_setup_page.cpp b/cockatrice/src/interface/widgets/onboarding/pages/preferences_setup_page.cpp index da08e3a12..486ada155 100644 --- a/cockatrice/src/interface/widgets/onboarding/pages/preferences_setup_page.cpp +++ b/cockatrice/src/interface/widgets/onboarding/pages/preferences_setup_page.cpp @@ -20,6 +20,7 @@ #include #include #include +#include namespace { @@ -68,9 +69,8 @@ PreferencesSetupPage::PreferencesSetupPage(QWidget *parent) : FirstRunWizardPage contentLayout->addStretch(); - auto *scrollArea = new QScrollArea(this); + auto *scrollArea = QtUtils::makeScrollArea(this, Qt::ScrollBarAsNeeded, Qt::ScrollBarAsNeeded); scrollArea->setWidget(content); - scrollArea->setWidgetResizable(true); scrollArea->setFrameShape(QFrame::NoFrame); auto *layout = new QVBoxLayout(this); From 3b07b10b1905e0dfad68864aa79be9e7da9a6b2c Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:11:09 +0200 Subject: [PATCH 022/108] [Avatar] Respect EXIF orientation and decode at target size (#7634) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read the avatar with setAutoTransform(true) so camera orientation is applied, and ask QImageReader for a scaled decode at MAX_AVATAR_DIMENSION instead of decoding a full-resolution photo and scaling it afterwards. Verification: cockatrice built. Co-authored-by: Lukas Brübach --- .../interface/widgets/dialogs/dlg_edit_avatar.cpp | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_edit_avatar.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_edit_avatar.cpp index 520ece007..7f0141bd5 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_edit_avatar.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_edit_avatar.cpp @@ -71,7 +71,21 @@ void DlgEditAvatar::actBrowse() QImageReader imgReader; imgReader.setDecideFormatFromContent(true); + // Honour the EXIF orientation phone cameras write; otherwise portrait + // photos are shown and stored sideways. + imgReader.setAutoTransform(true); imgReader.setFileName(fileName); + + // Decode straight to the maximum avatar size. Scaling afterwards decodes + // the full-resolution image first, which can be tens of megabytes for a + // modern camera photo. + const QSize sourceSize = imgReader.size(); + if (sourceSize.isValid() && + (sourceSize.width() > MAX_AVATAR_DIMENSION || sourceSize.height() > MAX_AVATAR_DIMENSION)) { + const QSize targetSize = sourceSize.scaled(MAX_AVATAR_DIMENSION, MAX_AVATAR_DIMENSION, Qt::KeepAspectRatio); + imgReader.setScaledSize(targetSize); + } + if (!imgReader.read(&image)) { qCWarning(DlgEditAvatarLog) << "Avatar image loading failed for file:" << fileName; imageLabel->setText(tr("Invalid image chosen.")); From 518c7e61259febd7c4af130405e69a9dbf6a5eb2 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:33:43 +0200 Subject: [PATCH 023/108] [Network] Add a configured QNetworkAccessManager factory (#7628) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Network] Add a configured QNetworkAccessManager factory Every HTTP caller built its own QNetworkAccessManager, so the system proxy and a transfer timeout were easy to omit. Add NetworkManagerFactory::create() as the single place that applies the process-wide proxy configuration and a transfer timeout, and use it at all 14 construction sites. Per-site settings such as disk caches and manual redirect policies stay with their callers. * [Tests] Link libcockatrice_network into the tests that compile its headers release_channel.cpp and card_picture_loader_worker.cpp now include libcockatrice/network/network_manager.h, but the test targets that compile them only linked libcockatrice_settings, so the include directory never reached them and every platform failed with "file not found": loader_local_matching_test and picture_loader_benchmark_test. libcockatrice_network is an INTERFACE library, so linking it costs these targets nothing but hands over the include path. --------- Co-authored-by: Lukas Brübach --- .../interfaces/deck_stats_interface.cpp | 3 +- .../interfaces/tapped_out_interface.cpp | 3 +- .../spoiler_background_updater.cpp | 3 +- .../network/update/client/release_channel.cpp | 3 +- .../update/client/update_downloader.cpp | 3 +- .../card_picture_loader_worker.cpp | 4 +- .../dialogs/dlg_load_deck_from_website.cpp | 3 +- .../widgets/server/handle_public_servers.cpp | 3 +- ..._response_deck_listings_display_widget.cpp | 4 +- .../tabs/api/archidekt/tab_archidekt.cpp | 4 +- .../handle_commander_brackets.cpp | 3 +- .../widgets/tabs/api/edhrec/tab_edhrec.cpp | 4 +- .../tabs/api/edhrec/tab_edhrec_main.cpp | 4 +- .../libcockatrice/network/network_manager.h | 38 +++++++++++++++++++ oracle/src/oraclewizard.cpp | 3 +- tests/CMakeLists.txt | 1 + tests/picture_loader_benchmark/CMakeLists.txt | 3 +- 17 files changed, 69 insertions(+), 20 deletions(-) create mode 100644 libcockatrice_network/libcockatrice/network/network_manager.h diff --git a/cockatrice/src/client/network/interfaces/deck_stats_interface.cpp b/cockatrice/src/client/network/interfaces/deck_stats_interface.cpp index d5cfdbbc9..508f0bdc9 100644 --- a/cockatrice/src/client/network/interfaces/deck_stats_interface.cpp +++ b/cockatrice/src/client/network/interfaces/deck_stats_interface.cpp @@ -20,11 +20,12 @@ #include #include #include +#include #include DeckStatsInterface::DeckStatsInterface(QObject *parent) : QObject(parent) { - manager = new QNetworkAccessManager(this); + manager = NetworkManagerFactory::create(this); connect(manager, &QNetworkAccessManager::finished, this, &DeckStatsInterface::queryFinished); } diff --git a/cockatrice/src/client/network/interfaces/tapped_out_interface.cpp b/cockatrice/src/client/network/interfaces/tapped_out_interface.cpp index 30044c2b6..34dd03765 100644 --- a/cockatrice/src/client/network/interfaces/tapped_out_interface.cpp +++ b/cockatrice/src/client/network/interfaces/tapped_out_interface.cpp @@ -26,11 +26,12 @@ #include #include #include +#include #include TappedOutInterface::TappedOutInterface(QObject *parent) : QObject(parent) { - manager = new QNetworkAccessManager(this); + manager = NetworkManagerFactory::create(this); connect(manager, &QNetworkAccessManager::finished, this, &TappedOutInterface::queryFinished); } diff --git a/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.cpp b/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.cpp index 192029b23..62668c4d9 100644 --- a/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.cpp +++ b/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.cpp @@ -27,6 +27,7 @@ #include #include #include +#include #include #include #include @@ -56,7 +57,7 @@ void SpoilerBackgroundUpdater::startSpoilerDownloadProcess(QString url, bool sav void SpoilerBackgroundUpdater::downloadFromURL(QUrl url, bool saveResults) { - auto *nam = new QNetworkAccessManager(this); + auto *nam = NetworkManagerFactory::create(this); auto request = QNetworkRequest(url); request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); QNetworkReply *reply = nam->get(request); diff --git a/cockatrice/src/client/network/update/client/release_channel.cpp b/cockatrice/src/client/network/update/client/release_channel.cpp index 45c503623..ce80a2a76 100644 --- a/cockatrice/src/client/network/update/client/release_channel.cpp +++ b/cockatrice/src/client/network/update/client/release_channel.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -62,7 +63,7 @@ std::optional getProductVersionMajor() } // namespace -ReleaseChannel::ReleaseChannel() : netMan(new QNetworkAccessManager(this)), response(nullptr), lastRelease(nullptr) +ReleaseChannel::ReleaseChannel() : netMan(NetworkManagerFactory::create(this)), response(nullptr), lastRelease(nullptr) { } diff --git a/cockatrice/src/client/network/update/client/update_downloader.cpp b/cockatrice/src/client/network/update/client/update_downloader.cpp index c86972d60..8868876fa 100644 --- a/cockatrice/src/client/network/update/client/update_downloader.cpp +++ b/cockatrice/src/client/network/update/client/update_downloader.cpp @@ -9,10 +9,11 @@ #include #include #include +#include UpdateDownloader::UpdateDownloader(QObject *parent) : QObject(parent), response(nullptr) { - netMan = new QNetworkAccessManager(this); + netMan = NetworkManagerFactory::create(this); } void UpdateDownloader::beginDownload(QUrl downloadUrl) diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp index 150b4c544..3ab5eb684 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp @@ -28,6 +28,7 @@ #include #include #include +#include #include #include #include @@ -46,11 +47,10 @@ CardPictureLoaderWorker::CardPictureLoaderWorker() : QObject(nullptr), picDownload(SettingsCache::instance().downloads().getPicDownload()), hostRequestLimits(SettingsCache::instance().downloads().getHostRequestLimits()) { - networkManager = new QNetworkAccessManager(this); + networkManager = NetworkManagerFactory::create(this); // We need a timeout to ensure requests don't hang indefinitely in case of // cache corruption, see related Qt bug: https://bugreports.qt.io/browse/QTBUG-111397 // Use Qt's default timeout (30s, as of 2023-02-22) - networkManager->setTransferTimeout(); cache = new QNetworkDiskCache(this); cache->setCacheDirectory(SettingsCache::instance().getNetworkCachePath()); cache->setMaximumCacheSize(1024L * 1024L * diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_website.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_website.cpp index 88b7e2bb3..1c1ada302 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_website.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_website.cpp @@ -27,6 +27,7 @@ #include #include #include +#include #include #include @@ -34,7 +35,7 @@ class QWidget; DlgLoadDeckFromWebsite::DlgLoadDeckFromWebsite(QWidget *parent) : QDialog(parent) { - nam = new QNetworkAccessManager(this); + nam = NetworkManagerFactory::create(this); layout = new QVBoxLayout(this); setLayout(layout); diff --git a/cockatrice/src/interface/widgets/server/handle_public_servers.cpp b/cockatrice/src/interface/widgets/server/handle_public_servers.cpp index 08b7837dd..1c0cbf962 100644 --- a/cockatrice/src/interface/widgets/server/handle_public_servers.cpp +++ b/cockatrice/src/interface/widgets/server/handle_public_servers.cpp @@ -13,13 +13,14 @@ #include #include #include +#include #include #include #define PUBLIC_SERVERS_JSON "https://cockatrice.github.io/public-servers.json" HandlePublicServers::HandlePublicServers(QObject *parent) - : QObject(parent), nam(new QNetworkAccessManager(this)), reply(nullptr) + : QObject(parent), nam(NetworkManagerFactory::create(this)), reply(nullptr) { } diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_listings_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_listings_display_widget.cpp index 81619bdcc..f8bf3c0dc 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_listings_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_listings_display_widget.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include @@ -27,8 +28,7 @@ ArchidektApiResponseDeckListingsDisplayWidget::ArchidektApiResponseDeckListingsD cardSizeSlider->enableCtrlScrollResize(flowWidget); - imageNetworkManager = new QNetworkAccessManager(this); - imageNetworkManager->setTransferTimeout(); // Use Qt's default timeout + imageNetworkManager = NetworkManagerFactory::create(this); imageNetworkManager->setRedirectPolicy(QNetworkRequest::ManualRedirectPolicy); // Add widgets for deck listings diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp index 60bf00c0a..d157f75cb 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp @@ -48,6 +48,7 @@ #include #include #include +#include #include #include #include @@ -58,8 +59,7 @@ TabArchidekt::TabArchidekt(TabSupervisor *_tabSupervisor) : Tab(_tabSupervisor), currentPage(1), isLoadingMore(false), isListMode(true) { // Initialize network - networkManager = new QNetworkAccessManager(this); - networkManager->setTransferTimeout(); + networkManager = NetworkManagerFactory::create(this); networkManager->setRedirectPolicy(QNetworkRequest::ManualRedirectPolicy); connect(networkManager, &QNetworkAccessManager::finished, this, &TabArchidekt::processApiJson); diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.cpp b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.cpp index 22200ffa2..407f53e99 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.cpp @@ -12,12 +12,13 @@ #include #include #include +#include #include static const QUrl COMMANDER_BRACKET_JSON_URL(QStringLiteral("https://cockatrice.github.io/commander-brackets.json")); HandleCommanderBrackets::HandleCommanderBrackets(QObject *parent) - : QObject(parent), nam(new QNetworkAccessManager(this)), reply(nullptr) + : QObject(parent), nam(NetworkManagerFactory::create(this)), reply(nullptr) { } diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec.cpp index 3aa395126..9dd0cca03 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec.cpp @@ -18,14 +18,14 @@ #include #include #include +#include #include class TabSupervisor; TabEdhRec::TabEdhRec(TabSupervisor *_tabSupervisor) : Tab(_tabSupervisor) { - networkManager = new QNetworkAccessManager(this); - networkManager->setTransferTimeout(); // Use Qt's default timeout + networkManager = NetworkManagerFactory::create(this); networkManager->setRedirectPolicy(QNetworkRequest::ManualRedirectPolicy); connect(networkManager, &QNetworkAccessManager::finished, this, &TabEdhRec::processApiJson); } diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.cpp index ca575dc02..741a1e85f 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.cpp @@ -32,6 +32,7 @@ #include #include #include +#include #include #include @@ -44,8 +45,7 @@ static bool canBeCommander(const CardInfoPtr &cardInfo) TabEdhRecMain::TabEdhRecMain(TabSupervisor *_tabSupervisor) : Tab(_tabSupervisor) { - networkManager = new QNetworkAccessManager(this); - networkManager->setTransferTimeout(); // Use Qt's default timeout + networkManager = NetworkManagerFactory::create(this); networkManager->setRedirectPolicy(QNetworkRequest::ManualRedirectPolicy); connect(networkManager, SIGNAL(finished(QNetworkReply *)), this, SLOT(processApiJson(QNetworkReply *))); diff --git a/libcockatrice_network/libcockatrice/network/network_manager.h b/libcockatrice_network/libcockatrice/network/network_manager.h new file mode 100644 index 000000000..bb20e1315 --- /dev/null +++ b/libcockatrice_network/libcockatrice/network/network_manager.h @@ -0,0 +1,38 @@ +#ifndef COCKATRICE_NETWORK_NETWORK_MANAGER_H +#define COCKATRICE_NETWORK_NETWORK_MANAGER_H + +#include +#include +#include + +/** + * @file network_manager.h + * @ingroup Network + * @brief Creates QNetworkAccessManagers with the application-wide baseline. + * + * Every manager in the client should start from NetworkManagerFactory::create() + * so features that apply to all HTTP traffic (the system proxy, a transfer + * timeout) cannot be forgotten in one of the many call sites. Feature-specific + * setup such as a disk cache or a manual redirect policy stays with the caller. + */ +namespace NetworkManagerFactory +{ +/** + * @brief Returns a QNetworkAccessManager configured for Cockatrice. + * + * The manager is parented to @p parent. The system proxy is enabled once, + * process-wide, the first time this function is called. Transfers use Qt's + * default timeout instead of waiting forever. + */ +inline QNetworkAccessManager *create(QObject *parent = nullptr) +{ + static std::once_flag systemProxyFlag; + std::call_once(systemProxyFlag, [] { QNetworkProxyFactory::setUseSystemConfiguration(true); }); + + auto *manager = new QNetworkAccessManager(parent); + manager->setTransferTimeout(); + return manager; +} +} // namespace NetworkManagerFactory + +#endif // COCKATRICE_NETWORK_NETWORK_MANAGER_H diff --git a/oracle/src/oraclewizard.cpp b/oracle/src/oraclewizard.cpp index d2b3a71a4..47afecc06 100644 --- a/oracle/src/oraclewizard.cpp +++ b/oracle/src/oraclewizard.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -53,7 +54,7 @@ OracleWizard::OracleWizard(QWidget *parent) : QWizard(parent) // foreignData for any other language is never imported. importer->setCardLang(SettingsCache::instance().cardsDisplay().getCardLang()); - nam = new QNetworkAccessManager(this); + nam = NetworkManagerFactory::create(this); QList pages; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 5420c07f5..e2c3252ee 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -369,6 +369,7 @@ cockatrice_add_test( ${VERSION_STRING_CPP} loader_local_matching_test.cpp LIBS + libcockatrice_network libcockatrice_settings INCLUDE_DIRS ${CMAKE_SOURCE_DIR}/cockatrice/src diff --git a/tests/picture_loader_benchmark/CMakeLists.txt b/tests/picture_loader_benchmark/CMakeLists.txt index 0684f58f4..9b8d15999 100644 --- a/tests/picture_loader_benchmark/CMakeLists.txt +++ b/tests/picture_loader_benchmark/CMakeLists.txt @@ -15,8 +15,9 @@ cockatrice_add_manual_test( settings_cache_mock.cpp LIBS libcockatrice_card - libcockatrice_settings libcockatrice_interfaces + libcockatrice_network + libcockatrice_settings libcockatrice_utility INCLUDE_DIRS ${CMAKE_SOURCE_DIR}/cockatrice/src From 5ddfcd7ead665907e40e1e62ddba308813023868 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:36:14 +0200 Subject: [PATCH 024/108] [Pictures] Watch the custom picture folder instead of polling (#7635) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local picture loader walked the whole custom picture tree every ten seconds on the GUI thread. Watch the root and every subdirectory with QFileSystemWatcher, debounce bursts of events into a single 500 ms refresh, and keep a five-minute fallback poll for filesystems where watching fails. Add an explicit-path constructor and indexedPaths() so the indexing can be tested without SettingsCache, and emit indexChanged() after a rebuild. Verification: cockatrice built. Co-authored-by: Lukas Brübach --- .../card_picture_loader_local.cpp | 75 ++++++++++++++++--- .../card_picture_loader_local.h | 30 +++++++- 2 files changed, 94 insertions(+), 11 deletions(-) diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp index 1437e23f4..fde6830c2 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -19,35 +20,62 @@ #include #include -static constexpr int REFRESH_INTERVAL_MS = 10 * 1000; +static constexpr int REFRESH_DEBOUNCE_MS = 500; +// Watching can fail on network and FUSE mounts; the slow poll still picks those +// changes up without the old ten-second full rescan. +static constexpr int FALLBACK_REFRESH_INTERVAL_MS = 5 * 60 * 1000; CardPictureLoaderLocal::CardPictureLoaderLocal(QObject *parent) - : QObject(parent), picsPath(SettingsCache::instance().paths().getPicsPath()), - customPicsPath(SettingsCache::instance().paths().getCustomPicsPath()) + : CardPictureLoaderLocal(parent, + SettingsCache::instance().paths().getPicsPath(), + SettingsCache::instance().paths().getCustomPicsPath()) { // Hook up signals to settings connect(&SettingsCache::instance().paths(), &PathsSettings::picsPathChanged, this, &CardPictureLoaderLocal::picsPathChanged); +} + +CardPictureLoaderLocal::CardPictureLoaderLocal(QObject *parent, + const QString &_picsPath, + const QString &_customPicsPath) + : QObject(parent), picsPath(_picsPath), customPicsPath(_customPicsPath), + folderWatcher(new QFileSystemWatcher(this)), indexRefreshTimer(new QTimer(this)), + fallbackRefreshTimer(new QTimer(this)) +{ + indexRefreshTimer->setSingleShot(true); + indexRefreshTimer->setInterval(REFRESH_DEBOUNCE_MS); + connect(indexRefreshTimer, &QTimer::timeout, this, &CardPictureLoaderLocal::refreshIndex); + + connect(folderWatcher, &QFileSystemWatcher::directoryChanged, indexRefreshTimer, qOverload<>(&QTimer::start)); + connect(folderWatcher, &QFileSystemWatcher::fileChanged, indexRefreshTimer, qOverload<>(&QTimer::start)); + + fallbackRefreshTimer->setInterval(FALLBACK_REFRESH_INTERVAL_MS); + connect(fallbackRefreshTimer, &QTimer::timeout, this, &CardPictureLoaderLocal::refreshIndex); + fallbackRefreshTimer->start(); refreshIndex(); - - refreshTimer = new QTimer(this); - connect(refreshTimer, &QTimer::timeout, this, &CardPictureLoaderLocal::refreshIndex); - refreshTimer->start(REFRESH_INTERVAL_MS); } void CardPictureLoaderLocal::refreshIndex() { customFolderIndex.clear(); + QSet directories; + + QDir customDir(customPicsPath); + if (customDir.exists()) { + directories.insert(customDir.absolutePath()); + } QDirIterator it(customPicsPath, QDirIterator::Subdirectories | QDirIterator::FollowSymlinks); // Recursively check all subdirectories of the CUSTOM folder while (it.hasNext()) { - QString thisPath(it.next()); - QFileInfo thisFileInfo(thisPath); + const QString thisPath = it.next(); + const QFileInfo thisFileInfo = it.fileInfo(); - if (thisFileInfo.isFile()) { + if (thisFileInfo.isDir()) { + directories.insert(thisFileInfo.absoluteFilePath()); + } else if (thisFileInfo.isFile()) { // We don't know which name is the correctedName because there might be '.'s in the cardName. // Just add all possibilities to be sure. customFolderIndex.insert(thisFileInfo.baseName(), thisFileInfo.absoluteFilePath()); @@ -55,8 +83,34 @@ void CardPictureLoaderLocal::refreshIndex() } } + updateWatchedDirectories(directories); + qCDebug(CardPictureLoaderLocalLog) << "Finished indexing local image folder CUSTOM; map now has" << customFolderIndex.size() << "entries."; + emit indexChanged(); +} + +void CardPictureLoaderLocal::updateWatchedDirectories(const QSet &directories) +{ + const QSet stale = watchedDirectories - directories; + if (!stale.isEmpty()) { + folderWatcher->removePaths(QStringList(stale.begin(), stale.end())); + } + + const QSet added = directories - watchedDirectories; + if (!added.isEmpty()) { + const QStringList failed = folderWatcher->addPaths(QStringList(added.begin(), added.end())); + if (!failed.isEmpty()) { + qCDebug(CardPictureLoaderLocalLog) << "Could not watch custom picture directories:" << failed; + } + } + + watchedDirectories = directories; +} + +QStringList CardPictureLoaderLocal::indexedPaths(const QString &baseName) const +{ + return customFolderIndex.values(baseName); } QImage CardPictureLoaderLocal::tryLoad(const ExactCard &toLoad) const @@ -145,4 +199,5 @@ void CardPictureLoaderLocal::picsPathChanged() { picsPath = SettingsCache::instance().paths().getPicsPath(); customPicsPath = SettingsCache::instance().paths().getCustomPicsPath(); + refreshIndex(); } diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.h b/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.h index 009897acc..35190366d 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.h +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.h @@ -5,10 +5,12 @@ #include #include #include +#include #include #include class ExactCard; +class QFileSystemWatcher; class QTimer; inline Q_LOGGING_CATEGORY(CardPictureLoaderLocalLog, "card_picture_loader.local"); @@ -40,6 +42,18 @@ public: */ explicit CardPictureLoaderLocal(QObject *parent); + /** + * @brief Constructs the loader against explicit folders without SettingsCache. + * + * Used by tests and by callers that already resolved the configured paths. + */ + CardPictureLoaderLocal(QObject *parent, const QString &picsPath, const QString &customPicsPath); + + /** + * @brief Returns every indexed file whose base name matches @p baseName. + */ + [[nodiscard]] QStringList indexedPaths(const QString &baseName) const; + /** * @brief Attempts to load a card image from local disk or custom folders. * @param toLoad ExactCard object representing the card to load. @@ -54,7 +68,10 @@ private: QString customPicsPath; ///< Path to custom card image folder QMultiHash customFolderIndex; ///< Multimap from cardName to file paths in CUSTOM folder - QTimer *refreshTimer; ///< Timer for periodic folder index refresh + QFileSystemWatcher *folderWatcher; ///< Watches the custom folder tree for changes + QTimer *indexRefreshTimer; ///< Debounces bursts of filesystem events + QTimer *fallbackRefreshTimer; ///< Slow poll for filesystems without change notification + QSet watchedDirectories; ///< Directories currently registered with folderWatcher /** * @brief Rebuilds the index of the custom image folder. @@ -65,6 +82,11 @@ private: */ void refreshIndex(); + /** + * @brief Keeps the watcher registered on exactly @p directories. + */ + void updateWatchedDirectories(const QSet &directories); + /** * @brief Attempts to load a card image from disk given its set and name info. * @param setName Corrected short name of the card's set. @@ -82,6 +104,12 @@ private: const QString &collectorNumber, const QString &providerId) const; +signals: + /** + * @brief Emitted after the custom folder index has been rebuilt. + */ + void indexChanged(); + private slots: /** * @brief Updates internal paths when the user changes picture settings. From 5fbc636f9361d80f94d3f7391745fe5173a2c906 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:01:04 +0200 Subject: [PATCH 025/108] [Models] Use QCollator for natural, cached sorting (#7633) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Models] Use QCollator for natural, cached sorting QString::localeAwareCompare rebuilds its collation data for every comparison, and it compares numbers as text. Keep one QCollator per model, enable numeric mode, and use it for the card name, set and user name sorts. Add a test that sorts "Forest 1", "Forest 2", "Forest 10" through DeckListSortFilterProxyModel and checks the natural order. * [Models] Sort naturally under the C locale too Numeric mode only takes effect through a real locale: under the C locale, which headless CI processes run in, QCollator silently falls back to string order and sorts "Forest 10" before "Forest 2". That made collator_sort_test fail on every Linux leg while passing locally. Add makeNaturalSortCollator(), which enables numeric mode and pins English only when the process locale is the degenerate one, and build the models' collators through it. Users with a real locale keep their own collation. * [Models] Fix compile of natural sorting (QStringView mismatch) * nit: initialize collators in member initializer lists * nit: namespace makeNaturalSortCollator under NaturalSort --------- Co-authored-by: Lukas Brübach --- .../widgets/server/user/user_list_widget.cpp | 12 ++++++-- .../database/card_database_display_model.cpp | 12 ++++---- .../database/card_database_display_model.h | 6 +++- .../database/card_set/card_sets_model.cpp | 6 ++-- .../database/card_set/card_sets_model.h | 4 +++ .../deck_list_sort_filter_proxy_model.cpp | 8 ++--- .../deck_list_sort_filter_proxy_model.h | 7 ++++- .../models/natural_sort_collator.h | 30 +++++++++++++++++++ tests/deck_list_model/CMakeLists.txt | 12 ++++++++ tests/deck_list_model/collator_sort_test.cpp | 30 +++++++++++++++++++ 10 files changed, 111 insertions(+), 16 deletions(-) create mode 100644 libcockatrice_models/libcockatrice/models/natural_sort_collator.h create mode 100644 tests/deck_list_model/collator_sort_test.cpp diff --git a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp index efbdcfb23..a20b39b82 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -55,6 +56,7 @@ #include #include #include +#include #include #include #include @@ -272,6 +274,11 @@ void UserListTWI::setOnline(bool online) */ bool UserListTWI::operator<(const QTreeWidgetItem &other) const { + // QCollator caches the locale data, unlike QString::localeAwareCompare, + // which rebuilds it for every pair. The tree sorts one list at a time, so a + // single shared, immutable collator is enough. + static const QCollator collator = NaturalSort::makeNaturalSortCollator(); + // Sort by online/offline if (data(0, UserListRoles::Online) != other.data(0, UserListRoles::Online)) { return data(0, UserListRoles::Online).toBool(); @@ -287,8 +294,7 @@ bool UserListTWI::operator<(const QTreeWidgetItem &other) const for (const auto &userLevelEntry : userLevelWithNoOtherPrefOrder) { if (lhsUserLevelFlags.testFlag(userLevelEntry) && lhsUserLevelFlags.testFlag(userLevelEntry) == rhsUserLevelFlags.testFlag(userLevelEntry)) { - return QString::localeAwareCompare(data(2, Qt::UserRole).toString(), - other.data(2, Qt::UserRole).toString()) < 0; + return collator.compare(data(2, Qt::UserRole).toString(), other.data(2, Qt::UserRole).toString()) < 0; } else if (lhsUserLevelFlags.testFlag(userLevelEntry) != rhsUserLevelFlags.testFlag(userLevelEntry)) { return lhsUserLevelFlags.testFlag(userLevelEntry) > rhsUserLevelFlags.testFlag(userLevelEntry); } @@ -313,7 +319,7 @@ bool UserListTWI::operator<(const QTreeWidgetItem &other) const } // Sort by name - return QString::localeAwareCompare(data(2, Qt::UserRole).toString(), other.data(2, Qt::UserRole).toString()) < 0; + return collator.compare(data(2, Qt::UserRole).toString(), other.data(2, Qt::UserRole).toString()) < 0; } UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor, diff --git a/libcockatrice_models/libcockatrice/models/database/card_database_display_model.cpp b/libcockatrice_models/libcockatrice/models/database/card_database_display_model.cpp index c333f5506..5573deb18 100644 --- a/libcockatrice_models/libcockatrice/models/database/card_database_display_model.cpp +++ b/libcockatrice_models/libcockatrice/models/database/card_database_display_model.cpp @@ -1,5 +1,6 @@ #include "card_database_display_model.h" +#include "../natural_sort_collator.h" #include "card_database_model.h" #include "libcockatrice/card/card_localization.h" #include "libcockatrice/filters/filter_string.h" @@ -20,7 +21,8 @@ class QObject; CardDatabaseDisplayModel::CardDatabaseDisplayModel(QObject *parent) - : QSortFilterProxyModel(parent), isToken(ShowAll), filterString(nullptr) + : QSortFilterProxyModel(parent), isToken(ShowAll), filterString(nullptr), + collator(NaturalSort::makeNaturalSortCollator()) { filterTree = nullptr; setFilterCaseSensitivity(Qt::CaseInsensitive); @@ -120,10 +122,10 @@ bool CardDatabaseDisplayModel::lessThan(const QModelIndex &left, const QModelInd return lessThanNumerically(leftPT.mid(leftSlash + 1), rightPT.mid(rightSlash + 1)) < 0; } } - return QString::localeAwareCompare(leftString, rightString) < 0; + return collator.compare(leftString, rightString) < 0; } -int CardDatabaseDisplayModel::lessThanNumerically(QStringView left, QStringView right) +int CardDatabaseDisplayModel::lessThanNumerically(QStringView left, QStringView right) const { if (left == right) { return 0; @@ -182,7 +184,7 @@ int CardDatabaseDisplayModel::lessThanNumerically(QStringView left, QStringView } else { // both parsed, same number, but at least one has something else // so compare the part after the number - prefer nothing - return QString::localeAwareCompare(leftAfterNum, rightAfterNum); + return collator.compare(leftAfterNum, rightAfterNum); } } else if (okLeft) { return -1; @@ -190,7 +192,7 @@ int CardDatabaseDisplayModel::lessThanNumerically(QStringView left, QStringView return 1; } // couldn't parse it, just return String comparison - return QString::localeAwareCompare(left, right); + return collator.compare(left, right); } bool CardDatabaseDisplayModel::filterAcceptsRow(int sourceRow, const QModelIndex & /*sourceParent*/) const { diff --git a/libcockatrice_models/libcockatrice/models/database/card_database_display_model.h b/libcockatrice_models/libcockatrice/models/database/card_database_display_model.h index 77152a390..343a7482e 100644 --- a/libcockatrice_models/libcockatrice/models/database/card_database_display_model.h +++ b/libcockatrice_models/libcockatrice/models/database/card_database_display_model.h @@ -10,6 +10,7 @@ #include "libcockatrice/card/card_info.h" +#include #include #include #include @@ -49,6 +50,9 @@ private: /** The translation table that will be used for sanitizeCardName. */ static QMap characterTranslation; + /** Natural, locale-aware comparison shared by every sort in this model. */ + QCollator collator; + public: explicit CardDatabaseDisplayModel(QObject *parent = nullptr); void setSourceModel(QAbstractItemModel *model) override; @@ -89,7 +93,7 @@ signals: protected: [[nodiscard]] bool lessThan(const QModelIndex &left, const QModelIndex &right) const override; - static int lessThanNumerically(QStringView left, QStringView right); + int lessThanNumerically(QStringView left, QStringView right) const; [[nodiscard]] bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override; [[nodiscard]] bool rowMatchesCardName(CardInfoPtr info) const; diff --git a/libcockatrice_models/libcockatrice/models/database/card_set/card_sets_model.cpp b/libcockatrice_models/libcockatrice/models/database/card_set/card_sets_model.cpp index a0b9c1061..b8bdc3466 100644 --- a/libcockatrice_models/libcockatrice/models/database/card_set/card_sets_model.cpp +++ b/libcockatrice_models/libcockatrice/models/database/card_set/card_sets_model.cpp @@ -1,5 +1,6 @@ #include "card_sets_model.h" +#include "../../natural_sort_collator.h" #include "libcockatrice/card/database/card_database.h" #include "libcockatrice/card/set/card_set.h" #include "libcockatrice/interfaces/interface_card_set_priority_controller.h" @@ -296,7 +297,8 @@ QStringList SetsModel::mimeTypes() const return QStringList() << "application/x-cockatricecardset"; } -SetsDisplayModel::SetsDisplayModel(QObject *parent) : QSortFilterProxyModel(parent) +SetsDisplayModel::SetsDisplayModel(QObject *parent) + : QSortFilterProxyModel(parent), collator(NaturalSort::makeNaturalSortCollator()) { setFilterCaseSensitivity(Qt::CaseInsensitive); setSortCaseSensitivity(Qt::CaseInsensitive); @@ -331,5 +333,5 @@ bool SetsDisplayModel::lessThan(const QModelIndex &left, const QModelIndex &righ QString leftString = sourceModel()->data(left, SetsModel::SortRole).toString(); QString rightString = sourceModel()->data(right, SetsModel::SortRole).toString(); - return QString::localeAwareCompare(leftString, rightString) < 0; + return collator.compare(leftString, rightString) < 0; } diff --git a/libcockatrice_models/libcockatrice/models/database/card_set/card_sets_model.h b/libcockatrice_models/libcockatrice/models/database/card_set/card_sets_model.h index 354414dc8..17eab6c53 100644 --- a/libcockatrice_models/libcockatrice/models/database/card_set/card_sets_model.h +++ b/libcockatrice_models/libcockatrice/models/database/card_set/card_sets_model.h @@ -11,6 +11,7 @@ #include "libcockatrice/card/set/card_set_list.h" #include +#include #include #include #include @@ -112,6 +113,9 @@ protected: [[nodiscard]] bool lessThan(const QModelIndex &left, const QModelIndex &right) const override; [[nodiscard]] bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override; void fetchMore(const QModelIndex &index) override; + +private: + QCollator collator; }; #endif diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_sort_filter_proxy_model.cpp b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_sort_filter_proxy_model.cpp index dcb294ec8..cf3410560 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_sort_filter_proxy_model.cpp +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_sort_filter_proxy_model.cpp @@ -23,7 +23,7 @@ bool DeckListSortFilterProxyModel::lessThan(const QModelIndex &left, const QMode if (!leftIsCard || !rightIsCard) { QString lName = src->data(left.siblingAtColumn(DeckListModelColumns::CARD_NAME), Qt::EditRole).toString(); QString rName = src->data(right.siblingAtColumn(DeckListModelColumns::CARD_NAME), Qt::EditRole).toString(); - return lName.localeAwareCompare(rName) < 0; + return collator.compare(lName, rName) < 0; } // Both are cards -> apply sort criteria @@ -38,7 +38,7 @@ bool DeckListSortFilterProxyModel::lessThan(const QModelIndex &left, const QMode if (crit == "name") { QString ln = lNode->getName(); QString rn = rNode->getName(); - int cmp = ln.localeAwareCompare(rn); + int cmp = collator.compare(ln, rn); if (cmp != 0) { return cmp < 0; } @@ -51,14 +51,14 @@ bool DeckListSortFilterProxyModel::lessThan(const QModelIndex &left, const QMode } else if (crit == "colors") { QString lr = lInfo ? lInfo->getColors() : QString(); QString rr = rInfo ? rInfo->getColors() : QString(); - int cmp = lr.localeAwareCompare(rr); + int cmp = collator.compare(lr, rr); if (cmp != 0) { return cmp < 0; } } else if (crit == "maintype") { QString lr = lInfo ? lInfo->getMainCardType() : QString(); QString rr = rInfo ? rInfo->getMainCardType() : QString(); - int cmp = lr.localeAwareCompare(rr); + int cmp = collator.compare(lr, rr); if (cmp != 0) { return cmp < 0; } diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_sort_filter_proxy_model.h b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_sort_filter_proxy_model.h index 522ed1e23..b911b4740 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_sort_filter_proxy_model.h +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_sort_filter_proxy_model.h @@ -7,6 +7,9 @@ #ifndef COCKATRICE_DECK_LIST_SORT_FILTER_PROXY_MODEL_H #define COCKATRICE_DECK_LIST_SORT_FILTER_PROXY_MODEL_H +#include "../natural_sort_collator.h" + +#include #include #include #include @@ -19,7 +22,8 @@ class DeckListSortFilterProxyModel : public QSortFilterProxyModel { Q_OBJECT public: - explicit DeckListSortFilterProxyModel(QObject *parent = nullptr) : QSortFilterProxyModel(parent) + explicit DeckListSortFilterProxyModel(QObject *parent = nullptr) + : QSortFilterProxyModel(parent), collator(NaturalSort::makeNaturalSortCollator()) { } @@ -34,6 +38,7 @@ protected: private: QStringList sortCriteria; + QCollator collator; }; #endif // COCKATRICE_DECK_LIST_SORT_FILTER_PROXY_MODEL_H diff --git a/libcockatrice_models/libcockatrice/models/natural_sort_collator.h b/libcockatrice_models/libcockatrice/models/natural_sort_collator.h new file mode 100644 index 000000000..8c026ca06 --- /dev/null +++ b/libcockatrice_models/libcockatrice/models/natural_sort_collator.h @@ -0,0 +1,30 @@ +#ifndef NATURAL_SORT_COLLATOR_H +#define NATURAL_SORT_COLLATOR_H + +#include +#include + +namespace NaturalSort +{ + +/** + * @brief A collator that compares runs of digits numerically, whatever the process locale is. + * + * Numeric mode only takes effect through a real locale. Under the C locale, which + * headless CI processes run in, QCollator silently falls back to plain string order + * and sorts "Forest 10" before "Forest 2". Pin English for that single degenerate + * locale so sorting is the same everywhere, and keep the user's locale otherwise. + */ +inline QCollator makeNaturalSortCollator() +{ + QCollator collator; + collator.setNumericMode(true); + if (QLocale().language() == QLocale::C) { + collator.setLocale(QLocale(QLocale::English)); + } + return collator; +} + +} // namespace NaturalSort + +#endif // NATURAL_SORT_COLLATOR_H \ No newline at end of file diff --git a/tests/deck_list_model/CMakeLists.txt b/tests/deck_list_model/CMakeLists.txt index cd7d693f3..82a1965d2 100644 --- a/tests/deck_list_model/CMakeLists.txt +++ b/tests/deck_list_model/CMakeLists.txt @@ -21,3 +21,15 @@ cockatrice_add_test( libcockatrice_card libcockatrice_deck_list ) + +cockatrice_add_test( + NAME + collator_sort_test + SOURCES + ${VERSION_STRING_CPP} + collator_sort_test.cpp + LIBS + libcockatrice_models + libcockatrice_card + libcockatrice_deck_list +) diff --git a/tests/deck_list_model/collator_sort_test.cpp b/tests/deck_list_model/collator_sort_test.cpp new file mode 100644 index 000000000..b4ed85525 --- /dev/null +++ b/tests/deck_list_model/collator_sort_test.cpp @@ -0,0 +1,30 @@ +#include +#include +#include +#include +#include +#include + +namespace +{ +TEST(CollatorSort, NumericNamesSortNaturally) +{ + QStandardItemModel source(0, 2); + const QStringList names = {"Forest 10", "Forest 2", "Forest 1"}; + for (int row = 0; row < names.size(); ++row) { + source.setItem(row, DeckListModelColumns::CARD_NAME, new QStandardItem(names.at(row))); + } + + DeckListSortFilterProxyModel proxy; + proxy.setSourceModel(&source); + proxy.setSortCriteria({"name"}); + proxy.sort(DeckListModelColumns::CARD_NAME, Qt::AscendingOrder); + + QStringList actual; + for (int row = 0; row < proxy.rowCount(); ++row) { + actual.append(proxy.index(row, DeckListModelColumns::CARD_NAME).data(Qt::DisplayRole).toString()); + } + + EXPECT_EQ(actual, (QStringList{"Forest 1", "Forest 2", "Forest 10"})); +} +} // namespace From f33cfcb288c65e34848d799897e4b19323703b66 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 25 Oct 2026 10:34:56 +0100 Subject: [PATCH 026/108] [UI] Show 'Password saved' placeholder when verifier is stored (#7649) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp index 43b40bf30..1f567989e 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp @@ -295,9 +295,15 @@ void DlgConnect::updateDisplayInfo(const QString &saveName) const QString stored = _data.at(4); if (stored.startsWith("$")) { storedVerifier = stored; + passwordEdit->clear(); + passwordEdit->setPlaceholderText(tr("Password saved")); } else { passwordEdit->setText(stored); + passwordEdit->setPlaceholderText(QString()); } + } else { + passwordEdit->clear(); + passwordEdit->setPlaceholderText(QString()); } if (!_data.at(6).isEmpty()) { @@ -323,6 +329,7 @@ void DlgConnect::newHostSelected(bool state) portEdit->setDisabled(false); playernameEdit->clear(); passwordEdit->clear(); + passwordEdit->setPlaceholderText(QString()); storedVerifier.clear(); saveEdit->clear(); saveEdit->setPlaceholderText(tr("Unique Server Name")); From a4c4c676744b8676598a65d8468904b42e4e776e Mon Sep 17 00:00:00 2001 From: Neel Jogani Date: Sun, 25 Oct 2026 17:00:17 +0530 Subject: [PATCH 027/108] Fix tab retranslation (#7580) * Retranslate all open tabs on language change * Address review: deeper Visual DB retranslation, index-based Archidekt size logic In Qt 6, setItemText() on the current item emits currentTextChanged but not currentIndexChanged, so connecting the Archidekt combos to currentTextChanged fired a search on every language change. Both combos now use currentIndexChanged, and buildSearchUrl() compares the size-logic combo by index so translated labels cannot drop sizeLogic. * Derive display mode button text in one place --- .../tabs/api/archidekt/tab_archidekt.cpp | 37 +++++++++----- .../tabs/api/archidekt/tab_archidekt.h | 4 +- .../interface/widgets/tabs/tab_supervisor.cpp | 48 ++----------------- .../tabs/tab_visual_database_display.cpp | 3 +- .../visual_database_display_widget.cpp | 14 ++++-- .../visual_database_display_widget.h | 1 + 6 files changed, 45 insertions(+), 62 deletions(-) diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp index d157f75cb..7f7a2d3fd 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp @@ -220,9 +220,9 @@ void TabArchidekt::setupFilterWidgets() // Advanced filters (in secondary toolbar) // EDH Bracket - auto *bracketLabel = new QLabel(tr("Bracket:"), secondaryToolbar); + bracketLabel = new QLabel(secondaryToolbar); edhBracketCombo = new QComboBox(secondaryToolbar); - edhBracketCombo->addItem(tr("Any")); + edhBracketCombo->addItem(QString()); edhBracketCombo->addItems({"1", "2", "3", "4", "5"}); edhBracketCombo->setSizeAdjustPolicy(QComboBox::AdjustToContents); @@ -276,7 +276,6 @@ void TabArchidekt::setupFilterWidgets() sizeLayout->setContentsMargins(4, 4, 4, 4); minDeckSizeSpin = new QSpinBox(sizeContainer); - minDeckSizeSpin->setSpecialValueText(tr("Any")); minDeckSizeSpin->setRange(0, 200); minDeckSizeSpin->setValue(0); @@ -284,7 +283,8 @@ void TabArchidekt::setupFilterWidgets() minDeckSizeLogicCombo->addItems({"Exact", "≥", "≤"}); minDeckSizeLogicCombo->setCurrentIndex(1); - sizeLayout->addWidget(new QLabel(tr("Cards:"), sizeContainer)); + cardsLabel = new QLabel(sizeContainer); + sizeLayout->addWidget(cardsLabel); sizeLayout->addWidget(minDeckSizeSpin); sizeLayout->addWidget(minDeckSizeLogicCombo); @@ -341,9 +341,9 @@ void TabArchidekt::connectSignals() }); // Format filters trigger search - connect(edhBracketCombo, &QComboBox::currentTextChanged, this, &TabArchidekt::doSearch); + connect(edhBracketCombo, &QComboBox::currentIndexChanged, this, &TabArchidekt::doSearch); connect(minDeckSizeSpin, qOverload(&QSpinBox::valueChanged), this, &TabArchidekt::doSearch); - connect(minDeckSizeLogicCombo, &QComboBox::currentTextChanged, this, &TabArchidekt::doSearch); + connect(minDeckSizeLogicCombo, &QComboBox::currentIndexChanged, this, &TabArchidekt::doSearch); // Allow Enter key in text fields to trigger search connect(nameField, &QLineEdit::returnPressed, this, &TabArchidekt::doSearchImmediate); @@ -393,8 +393,15 @@ void TabArchidekt::retranslateUi() commandersField->setPlaceholderText(tr("Commander...")); deckTagNameField->setPlaceholderText(tr("Tag...")); + bracketLabel->setText(tr("Bracket:")); + // Index 0 is the "Any" placeholder added in setupFilterWidgets() + edhBracketCombo->setItemText(0, tr("Any")); + formatButton->setButtonText(tr("Formats")); deckSizeButton->setButtonText(tr("Deck Size")); + cardsLabel->setText(tr("Cards:")); + minDeckSizeSpin->setSpecialValueText(tr("Any")); + minDeckSizeLogicCombo->setItemText(0, tr("Exact")); searchButton->setText(tr("Search")); @@ -484,13 +491,17 @@ QString TabArchidekt::buildSearchUrl() query.addQueryItem("size", QString::number(minDeckSizeSpin->value())); QString logic = "GTE"; - QString selected = minDeckSizeLogicCombo->currentText(); - if (selected == "≥") { - logic = "GTE"; - } else if (selected == "≤") { - logic = "LTE"; - } else { - logic = ""; + // Index order matches setupFilterWidgets(): 0 = Exact, 1 = ≥, 2 = ≤ + switch (minDeckSizeLogicCombo->currentIndex()) { + case 1: + logic = "GTE"; + break; + case 2: + logic = "LTE"; + break; + default: + logic = ""; + break; } if (!logic.isEmpty()) { diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.h b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.h index 015b8590b..c74380b63 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.h +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.h @@ -287,9 +287,11 @@ private: SettingsButtonWidget *formatButton; ///< Collapsible button for format filters QVector formatChecks; ///< Individual checkboxes for each format + QLabel *bracketLabel; ///< Label for EDH bracket selection QComboBox *edhBracketCombo; ///< Dropdown for EDH bracket selection SettingsButtonWidget *deckSizeButton; ///< Collapsible button for deck size filter + QLabel *cardsLabel; ///< Label for deck size selection QSpinBox *minDeckSizeSpin; ///< Spinner to select minimum deck size QComboBox *minDeckSizeLogicCombo; ///< Combo box for size comparison logic @@ -300,4 +302,4 @@ private: CardInfoPtr cardToQuery; ///< Optional pre-selected card for initial filtering }; -#endif // COCKATRICE_TAB_ARCHIDEKT_H \ No newline at end of file +#endif // COCKATRICE_TAB_ARCHIDEKT_H diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index 8ec76aaf0..c614fe7f6 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -51,8 +51,6 @@ #include #include #include -#include -#include #include #include #include @@ -292,49 +290,11 @@ void TabSupervisor::retranslateUi() aTabDeveloper->setText(tr("Developer")); // tabs - QList tabs; - tabs.append(tabHome); - tabs.append(tabServer); - tabs.append(tabReplays); - tabs.append(tabDeckStorage); - tabs.append(tabAdmin); - tabs.append(tabAccount); - tabs.append(tabLog); - tabs.append(tabReport); - tabs.append(tabModeration); - tabs.append(tabCardArtRules); - tabs.append(tabDeveloper); - QMapIterator roomIterator(roomTabs); - while (roomIterator.hasNext()) { - tabs.append(roomIterator.next().value()); - } - QMapIterator gameIterator(gameTabs); - while (gameIterator.hasNext()) { - tabs.append(gameIterator.next().value()); - } - QMapIterator publicDecksIterator(publicDecksTabs); - while (publicDecksIterator.hasNext()) { - tabs.append(publicDecksIterator.next().value()); - } - QListIterator replayIterator(replayTabs); - while (replayIterator.hasNext()) { - tabs.append(replayIterator.next()); - } - QListIterator deckEditorIterator(deckEditorTabs); - while (deckEditorIterator.hasNext()) { - tabs.append(deckEditorIterator.next()); - } - QMapIterator messageIterator(messageTabs); - while (messageIterator.hasNext()) { - tabs.append(messageIterator.next().value()); - } - - for (auto &tab : tabs) { - if (tab) { - int idx = indexOf(tab); + for (int i = 0; i < count(); ++i) { + if (auto *tab = qobject_cast(widget(i))) { QString tabText = tab->getTabText(); - setTabText(idx, sanitizeTabName(tabText)); - setTabToolTip(idx, sanitizeHtml(tabText)); + setTabText(i, sanitizeTabName(tabText)); + setTabToolTip(i, sanitizeHtml(tabText)); tab->retranslateUi(); } } diff --git a/cockatrice/src/interface/widgets/tabs/tab_visual_database_display.cpp b/cockatrice/src/interface/widgets/tabs/tab_visual_database_display.cpp index f4b007072..26d29463b 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_visual_database_display.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_visual_database_display.cpp @@ -23,9 +23,10 @@ TabVisualDatabaseDisplay::TabVisualDatabaseDisplay(TabSupervisor *_tabSupervisor void TabVisualDatabaseDisplay::retranslateUi() { + visualDatabaseDisplayWidget->retranslateUi(); } void TabVisualDatabaseDisplay::openEdhrecTab(const CardInfoPtr &info, bool isCommander) const { getTabSupervisor()->addEdhrecTab(info, isCommander); -} \ No newline at end of file +} diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp index ecf59cfc0..5a1097b0d 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp @@ -116,7 +116,7 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, setFocusProxy(searchEdit); setFocusPolicy(Qt::ClickFocus); - displayModeButton = new QPushButton(tr("Visual"), this); + displayModeButton = new QPushButton(this); displayModeButton->setCheckable(true); // Toggle button connect(displayModeButton, &QPushButton::toggled, this, &VisualDatabaseDisplayWidget::onDisplayModeChanged); @@ -262,6 +262,10 @@ void VisualDatabaseDisplayWidget::retranslateUi() { databaseLoadIndicator->setText(tr("Loading database ...")); clearFilterWidget->setToolTip(tr("Clear all filters")); + searchEdit->setPlaceholderText(tr("Search by card name (or search expressions)")); + updateDisplayModeButton(); + colorFilterWidget->retranslateUi(); + filterContainer->retranslateUi(); } void VisualDatabaseDisplayWidget::highlightAllSearchEdit() @@ -269,6 +273,11 @@ void VisualDatabaseDisplayWidget::highlightAllSearchEdit() searchEdit->setSelection(0, searchEdit->text().length()); } +void VisualDatabaseDisplayWidget::updateDisplayModeButton() +{ + displayModeButton->setText(displayModeButton->isChecked() ? tr("Table") : tr("Visual")); +} + void VisualDatabaseDisplayWidget::resizeEvent(QResizeEvent *event) { QWidget::resizeEvent(event); @@ -277,16 +286,15 @@ void VisualDatabaseDisplayWidget::resizeEvent(QResizeEvent *event) void VisualDatabaseDisplayWidget::onDisplayModeChanged(bool checked) { + updateDisplayModeButton(); if (checked) { // Table mode - displayModeButton->setText(tr("Table")); flowWidget->setVisible(false); cardSizeWidget->setVisible(false); databaseView->setItemDelegate(new QStyledItemDelegate(databaseView)); databaseView->setVisible(true); } else { // Visual mode - displayModeButton->setText(tr("Visual")); flowWidget->setVisible(true); cardSizeWidget->setVisible(true); databaseView->setVisible(false); diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h index 9771e55c1..15adca214 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h @@ -145,6 +145,7 @@ private: void initializeFilters(); void highlightAllSearchEdit(); + void updateDisplayModeButton(); bool nearEndOfPage() const; protected: From b4e24f958a8650ab77de0a35cee42e6bba0e17f4 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:49:24 +0100 Subject: [PATCH 028/108] [Network] Do not format discarded debug messages, parse without memmove (#7606) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Network] Do not format discarded debug messages, parse without memmove Protocol debug logging gets the full protobuf message rendered through TextFormat and reflection. On the server this happened before every session/room/game/moderator/admin/developer command, even though the default Server_ProtocolHandler::logDebugMessage() is a no-op and even when servatrice has no log file configured. On the clients the same string was built as an argument to qCDebug(), which evaluates its arguments regardless of whether the category is enabled. Server_ProtocolHandler now exposes a virtual logsDebugMessages() hook. The base implementation returns false, so handlers without a log destination spend nothing. AbstractServerSocketInterface overrides it with ServerLogger::isLoggingEnabled(), which checks the log file and the server/writelog setting that logMessage() itself consults. All debug message call sites check the hook before formatting. The client send/receive paths check QLoggingCategory::isDebugEnabled() first, the same pattern AbstractClient already uses for its slow-command warning. ISL message decoding does the same. Byte stream decoding called inputBuffer.remove(0, n) for the length header and then again for every message. Each call shifts all remaining bytes, so a read carrying k messages cost O(k * bytes remaining). The three receive loops (remote client, TCP server interface, ISL interface) now parse at a read offset and compact the consumed prefix once, when they stop because a full message is not buffered yet. No buffering or size-limit behavior changes. Also in this batch: - Server_ProtocolHandler::processGameCommandContainer() copied the whole games map under gameListMutex just to look up one game id. A new getGameInfo() performs the single lookup and returns the room/player ids. - The TCP receive loop's `ok` flag is initialized, so a ParseFromArray exception can no longer leave it indeterminate before `if (ok)`. - The exception log prints only the message being parsed instead of hex-dumping the entire pending buffer. Full build and ctest (628 tests) pass, including the server tests. * [Network] Address review comments - Move getGameInfo() out of the header into server_abstractuserinterface.cpp. - Drop the consumed prefix once after the parse loop instead of on every early return: the two "not enough data" exits now break out of the loop. Applied to the remote client, TCP server and ISL receive loops. --------- Co-authored-by: Lukas Brübach --- .../network/client/local/local_client.cpp | 8 ++- .../network/client/remote/remote_client.cpp | 48 +++++++++++------ .../remote/server_abstractuserinterface.cpp | 12 +++++ .../remote/server_abstractuserinterface.h | 7 +++ .../server/remote/server_protocolhandler.cpp | 35 +++++++----- .../server/remote/server_protocolhandler.h | 11 ++++ servatrice/src/isl_interface.cpp | 39 +++++++++----- servatrice/src/server_logger.cpp | 5 ++ servatrice/src/server_logger.h | 9 ++++ servatrice/src/serversocketinterface.cpp | 54 ++++++++++++------- servatrice/src/serversocketinterface.h | 1 + 11 files changed, 164 insertions(+), 65 deletions(-) diff --git a/libcockatrice_network/libcockatrice/network/client/local/local_client.cpp b/libcockatrice_network/libcockatrice/network/client/local/local_client.cpp index c1c1cc976..f7d8645e8 100644 --- a/libcockatrice_network/libcockatrice/network/client/local/local_client.cpp +++ b/libcockatrice_network/libcockatrice/network/client/local/local_client.cpp @@ -39,14 +39,18 @@ LocalClient::~LocalClient() void LocalClient::sendCommandContainer(const CommandContainer &cont) { - qCDebug(LocalClientLog).noquote() << userName << "OUT" << getSafeDebugString(cont); + if (LocalClientLog().isDebugEnabled()) { + qCDebug(LocalClientLog).noquote() << userName << "OUT" << getSafeDebugString(cont); + } lsi->itemFromClient(cont); } void LocalClient::itemFromServer(const ServerMessage &item) { - qCDebug(LocalClientLog).noquote() << userName << "IN" << getSafeDebugString(item); + if (LocalClientLog().isDebugEnabled()) { + qCDebug(LocalClientLog).noquote() << userName << "IN" << getSafeDebugString(item); + } processProtocolItem(item); } diff --git a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp index cbf7dbda2..e02f5d3d8 100644 --- a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp +++ b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp @@ -496,9 +496,15 @@ void RemoteClient::readData() inputBuffer.append(data); - do { + // Parse messages straight out of inputBuffer at readOffset. Removing each + // consumed message from the front of the buffer would memmove all of the + // remaining bytes once per message; instead the consumed prefix is dropped + // once parsing stops. + qsizetype readOffset = 0; + + while (true) { if (!messageInProgress) { - if (inputBuffer.size() >= 4) { + if (inputBuffer.size() - readOffset >= 4) { // dirty hack to be compatible with v14 server that sends 60 bytes of garbage at the beginning if (!handshakeStarted) { handshakeStarted = true; @@ -508,29 +514,30 @@ void RemoteClient::readData() } } else { // end of hack - messageLength = (((quint32)(unsigned char)inputBuffer[0]) << 24) + - (((quint32)(unsigned char)inputBuffer[1]) << 16) + - (((quint32)(unsigned char)inputBuffer[2]) << 8) + - ((quint32)(unsigned char)inputBuffer[3]); - inputBuffer.remove(0, 4); + const auto *header = reinterpret_cast(inputBuffer.constData() + readOffset); + messageLength = (static_cast(header[0]) << 24) + (static_cast(header[1]) << 16) + + (static_cast(header[2]) << 8) + static_cast(header[3]); + readOffset += 4; messageInProgress = true; } } else { - return; + break; } } - if (inputBuffer.size() < messageLength) { - return; + if (inputBuffer.size() - readOffset < messageLength) { + break; } ServerMessage newServerMessage; - bool ok = newServerMessage.ParseFromArray(inputBuffer.data(), messageLength); + bool ok = newServerMessage.ParseFromArray(inputBuffer.constData() + readOffset, messageLength); - inputBuffer.remove(0, messageLength); + readOffset += messageLength; messageInProgress = false; if (ok) { - qCDebug(RemoteClientLog).noquote() << "IN" << getSafeDebugString(newServerMessage); + if (RemoteClientLog().isDebugEnabled()) { + qCDebug(RemoteClientLog).noquote() << "IN" << getSafeDebugString(newServerMessage); + } processProtocolItem(newServerMessage); } else { @@ -540,7 +547,12 @@ void RemoteClient::readData() if (getStatus() == StatusDisconnecting) { // use thread-safe getter doDisconnectFromServer(); } - } while (!inputBuffer.isEmpty()); + } + + // Drop the consumed prefix once, instead of shifting the buffer per message. + if (readOffset > 0) { + inputBuffer.remove(0, readOffset); + } } void RemoteClient::websocketMessageReceived(const QByteArray &message) @@ -548,7 +560,9 @@ void RemoteClient::websocketMessageReceived(const QByteArray &message) lastDataReceived = timeRunning; ServerMessage newServerMessage; if (newServerMessage.ParseFromArray(message.data(), message.length())) { - qCDebug(RemoteClientLog).noquote() << "IN" << getSafeDebugString(newServerMessage); + if (RemoteClientLog().isDebugEnabled()) { + qCDebug(RemoteClientLog).noquote() << "IN" << getSafeDebugString(newServerMessage); + } processProtocolItem(newServerMessage); } else { @@ -564,7 +578,9 @@ void RemoteClient::sendCommandContainer(const CommandContainer &cont) auto size = static_cast(cont.ByteSize()); #endif - qCDebug(RemoteClientLog).noquote() << "OUT" << getSafeDebugString(cont); + if (RemoteClientLog().isDebugEnabled()) { + qCDebug(RemoteClientLog).noquote() << "OUT" << getSafeDebugString(cont); + } QByteArray buf; bool ok; diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.cpp index 25aa415fc..c3524bb93 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.cpp @@ -84,6 +84,18 @@ void Server_AbstractUserInterface::onLogin(ResponseContainer &) { } +bool Server_AbstractUserInterface::getGameInfo(int gameId, int &roomId, int &playerId) const +{ + QMutexLocker locker(&gameListMutex); + const auto it = games.constFind(gameId); + if (it == games.constEnd()) { + return false; + } + roomId = it.value().first; + playerId = it.value().second; + return true; +} + void Server_AbstractUserInterface::playerRemovedFromGame(Server_Game *game) { qDebug() << "Server_AbstractUserInterface::playerRemovedFromGame(): gameId =" << game->getGameId(); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.h b/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.h index a19475e34..a2a4ed8c7 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.h @@ -58,6 +58,13 @@ public: return games; } + /** + * @brief Looks up the room and player id of one game without copying the game list. + * + * @return True if the game is known; otherwise false and the outputs are untouched. + */ + bool getGameInfo(int gameId, int &roomId, int &playerId) const; + virtual void sendProtocolItem(const Response &item) = 0; virtual void sendProtocolItem(const SessionEvent &item) = 0; virtual void sendProtocolItem(const GameEventContainer &item) = 0; diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp index eb2b2fc1c..dd074a517 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp @@ -208,7 +208,7 @@ Response::ResponseCode Server_ProtocolHandler::processSessionCommandContainer(co Response::ResponseCode resp = Response::RespInvalidCommand; const SessionCommand &sc = cont.session_command(i); const int num = getPbExtension(sc); - if (num != SessionCommand::PING) { // don't log ping commands + if (num != SessionCommand::PING && logsDebugMessages()) { // don't log ping commands logDebugMessage(getSafeDebugString(sc)); } const auto commandType = static_cast(num); @@ -271,7 +271,9 @@ Response::ResponseCode Server_ProtocolHandler::processRoomCommandContainer(const Response::ResponseCode resp = Response::RespInvalidCommand; const RoomCommand &sc = cont.room_command(i); const int num = getPbExtension(sc); - logDebugMessage(getSafeDebugString(sc)); + if (logsDebugMessages()) { + logDebugMessage(getSafeDebugString(sc)); + } switch ((RoomCommand::RoomCommandType)num) { case RoomCommand::LEAVE_ROOM: resp = cmdLeaveRoom(sc.GetExtension(Command_LeaveRoom::ext), room, rc); @@ -320,14 +322,14 @@ Response::ResponseCode Server_ProtocolHandler::processGameCommandContainer(const return Response::RespLoginNeeded; } - QMap> gameMap = getGames(); - if (!gameMap.contains(cont.game_id())) { + int roomId; + int playerId; + if (!getGameInfo(cont.game_id(), roomId, playerId)) { return Response::RespNotInRoom; } - const QPair roomIdAndPlayerId = gameMap.value(cont.game_id()); QReadLocker roomsLocker(&server->roomsLock); - Server_Room *room = server->getRooms().value(roomIdAndPlayerId.first); + Server_Room *room = server->getRooms().value(roomId); if (!room) { return Response::RespNotInRoom; } @@ -337,14 +339,14 @@ Response::ResponseCode Server_ProtocolHandler::processGameCommandContainer(const if (!game) { if (room->getExternalGames().contains(cont.game_id())) { server->sendIsl_GameCommand(cont, room->getExternalGames().value(cont.game_id()).server_id(), - userInfo->session_id(), roomIdAndPlayerId.first, roomIdAndPlayerId.second); + userInfo->session_id(), roomId, playerId); return Response::RespNothing; } return Response::RespNotInRoom; } QMutexLocker gameLocker(&game->gameMutex); - auto *participant = game->getParticipants().value(roomIdAndPlayerId.second); + auto *participant = game->getParticipants().value(playerId); if (!participant) { return Response::RespNotInRoom; } @@ -357,8 +359,9 @@ Response::ResponseCode Server_ProtocolHandler::processGameCommandContainer(const Response::ResponseCode finalResponseCode = Response::RespOk; for (int i = cont.game_command_size() - 1; i >= 0; --i) { const GameCommand &sc = cont.game_command(i); - logDebugMessage(QString("game %1 player %2: ").arg(cont.game_id()).arg(roomIdAndPlayerId.second) + - getSafeDebugString(sc)); + if (logsDebugMessages()) { + logDebugMessage(QString("game %1 player %2: ").arg(cont.game_id()).arg(playerId) + getSafeDebugString(sc)); + } if (commandCountingInterval > 0) { int totalCount = 0; @@ -407,7 +410,9 @@ Response::ResponseCode Server_ProtocolHandler::processModeratorCommandContainer( Response::ResponseCode resp = Response::RespInvalidCommand; const ModeratorCommand &sc = cont.moderator_command(i); const int num = getPbExtension(sc); - logDebugMessage(getSafeDebugString(sc)); + if (logsDebugMessages()) { + logDebugMessage(getSafeDebugString(sc)); + } resp = processExtendedModeratorCommand(num, sc, rc); if (resp != Response::RespOk) { @@ -434,7 +439,9 @@ Response::ResponseCode Server_ProtocolHandler::processAdminCommandContainer(cons Response::ResponseCode resp = Response::RespInvalidCommand; const AdminCommand &sc = cont.admin_command(i); const int num = getPbExtension(sc); - logDebugMessage(getSafeDebugString(sc)); + if (logsDebugMessages()) { + logDebugMessage(getSafeDebugString(sc)); + } resp = processExtendedAdminCommand(num, sc, rc); if (resp != Response::RespOk) { @@ -461,7 +468,9 @@ Response::ResponseCode Server_ProtocolHandler::processDeveloperCommandContainer( Response::ResponseCode resp = Response::RespInvalidCommand; const DeveloperCommand &sc = cont.developer_command(i); const int num = getPbExtension(sc); - logDebugMessage(getSafeDebugString(sc)); + if (logsDebugMessages()) { + logDebugMessage(getSafeDebugString(sc)); + } resp = processExtendedDeveloperCommand(num, sc, rc); if (resp != Response::RespOk) { diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h index c0a1ffe9a..266c4b26c 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h @@ -55,6 +55,17 @@ protected: virtual void logDebugMessage(const QString & /* message */) { } + /** + * @brief Whether debug messages produced by this handler are actually written anywhere. + * + * Formatting a protocol message for the log is expensive, so callers only + * build the debug string when this returns true. The base handler has no + * log destination and therefore never needs one. + */ + [[nodiscard]] virtual bool logsDebugMessages() const + { + return false; + } private: QList messageSizeOverTime, messageCountOverTime, commandCountOverTime; diff --git a/servatrice/src/isl_interface.cpp b/servatrice/src/isl_interface.cpp index 8733c9a40..7121bfa48 100644 --- a/servatrice/src/isl_interface.cpp +++ b/servatrice/src/isl_interface.cpp @@ -264,13 +264,17 @@ void IslInterface::readClient() server->incRxBytes(data.size()); inputBuffer.append(data); - do { + // Parse messages straight out of inputBuffer at readOffset. Removing each + // consumed message from the front would memmove the remaining bytes once + // per message; the consumed prefix is dropped once parsing stops. + qsizetype readOffset = 0; + + while (true) { if (!messageInProgress) { - if (inputBuffer.size() >= 4) { - messageLength = (((quint32)(unsigned char)inputBuffer[0]) << 24) + - (((quint32)(unsigned char)inputBuffer[1]) << 16) + - (((quint32)(unsigned char)inputBuffer[2]) << 8) + - ((quint32)(unsigned char)inputBuffer[3]); + if (inputBuffer.size() - readOffset >= 4) { + const auto *header = reinterpret_cast(inputBuffer.constData() + readOffset); + messageLength = (static_cast(header[0]) << 24) + (static_cast(header[1]) << 16) + + (static_cast(header[2]) << 8) + static_cast(header[3]); // Reject implausible lengths instead of buffering until they arrive. // This also handles lengths that overflow into negative int values. if (messageLength < 0 || messageLength > MAX_ISL_MESSAGE_SIZE) { @@ -278,19 +282,19 @@ void IslInterface::readClient() socket->disconnectFromHost(); return; } - inputBuffer.remove(0, 4); + readOffset += 4; messageInProgress = true; } else { - return; + break; } } - if (inputBuffer.size() < messageLength) { - return; + if (inputBuffer.size() - readOffset < messageLength) { + break; } IslMessage newMessage; - bool ok = newMessage.ParseFromArray(inputBuffer.data(), messageLength); - inputBuffer.remove(0, messageLength); + bool ok = newMessage.ParseFromArray(inputBuffer.constData() + readOffset, messageLength); + readOffset += messageLength; messageInProgress = false; if (ok) { @@ -298,7 +302,12 @@ void IslInterface::readClient() } else { qCWarning(IslInterfaceLog) << "parsing error!"; } - } while (!inputBuffer.isEmpty()); + } + + // Drop the consumed prefix once, instead of shifting the buffer per message. + if (readOffset > 0) { + inputBuffer.remove(0, readOffset); + } } void IslInterface::catchSocketError(QAbstractSocket::SocketError socketError) @@ -495,7 +504,9 @@ void IslInterface::processRoomCommand(const CommandContainer &cont, qint64 sessi void IslInterface::processMessage(const IslMessage &item) { - qCDebug(IslInterfaceLog) << getSafeDebugString(item); + if (IslInterfaceLog().isDebugEnabled()) { + qCDebug(IslInterfaceLog) << getSafeDebugString(item); + } switch (item.message_type()) { case IslMessage::ROOM_COMMAND_CONTAINER: { diff --git a/servatrice/src/server_logger.cpp b/servatrice/src/server_logger.cpp index 1972444f1..1c83299b8 100644 --- a/servatrice/src/server_logger.cpp +++ b/servatrice/src/server_logger.cpp @@ -54,6 +54,11 @@ void ServerLogger::startLog(const QString &logFileName) connect(this, SIGNAL(sigFlushBuffer()), this, SLOT(flushBuffer()), Qt::QueuedConnection); } +bool ServerLogger::isLoggingEnabled() const +{ + return logFile != nullptr && settingsCache->value("server/writelog", 1).toBool(); +} + void ServerLogger::logMessage(const QString &message, void *caller) { if (!logFile) { diff --git a/servatrice/src/server_logger.h b/servatrice/src/server_logger.h index f2875a6a3..e0be4b712 100644 --- a/servatrice/src/server_logger.h +++ b/servatrice/src/server_logger.h @@ -16,6 +16,15 @@ class ServerLogger : public QObject public: ServerLogger(bool _logToConsole, QObject *parent = 0); ~ServerLogger(); + + /** + * @brief Whether messages passed to logMessage() would be written anywhere. + * + * Callers use this to avoid formatting expensive debug payloads that + * logMessage() would discard. + */ + [[nodiscard]] bool isLoggingEnabled() const; + public slots: void startLog(const QString &logFileName); void logMessage(const QString &message, void *caller = 0); diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index 890a93fbc..fc911950e 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -265,6 +265,11 @@ void AbstractServerSocketInterface::logDebugMessage(const QString &message) logger->logMessage(message, this); } +bool AbstractServerSocketInterface::logsDebugMessages() const +{ + return logger != nullptr && logger->isLoggingEnabled(); +} + void AbstractServerSocketInterface::processCommandContainer(const CommandContainer &cont) { QElapsedTimer timer; @@ -4431,13 +4436,17 @@ void TcpServerSocketInterface::readClient() servatrice->incRxBytes(data.size()); inputBuffer.append(data); - do { + // Parse messages straight out of inputBuffer at readOffset. Removing each + // consumed message from the front would memmove the remaining bytes once + // per message; the consumed prefix is dropped once parsing stops. + qsizetype readOffset = 0; + + while (true) { if (!messageInProgress) { - if (inputBuffer.size() >= 4) { - messageLength = (((quint32)(unsigned char)inputBuffer[0]) << 24) + - (((quint32)(unsigned char)inputBuffer[1]) << 16) + - (((quint32)(unsigned char)inputBuffer[2]) << 8) + - ((quint32)(unsigned char)inputBuffer[3]); + if (inputBuffer.size() - readOffset >= 4) { + const auto *header = reinterpret_cast(inputBuffer.constData() + readOffset); + messageLength = (static_cast(header[0]) << 24) + (static_cast(header[1]) << 16) + + (static_cast(header[2]) << 8) + static_cast(header[3]); // Reject implausible lengths instead of buffering until they arrive. // This also handles lengths that overflow into negative int values. if (messageLength < 0 || messageLength > MAX_TCP_MESSAGE_SIZE) { @@ -4446,32 +4455,33 @@ void TcpServerSocketInterface::readClient() prepareDestroy(); return; } - inputBuffer.remove(0, 4); + readOffset += 4; messageInProgress = true; } else { - return; + break; } } - if (inputBuffer.size() < messageLength) { - return; + if (inputBuffer.size() - readOffset < messageLength) { + break; } CommandContainer newCommandContainer; - bool ok; + bool ok = false; try { - ok = newCommandContainer.ParseFromArray(inputBuffer.data(), messageLength); + ok = newCommandContainer.ParseFromArray(inputBuffer.constData() + readOffset, messageLength); } catch (std::exception &e) { - qCWarning(TcpServerSocketInterfaceLog) << "Caught std::exception in" << __FILE__ << __LINE__ << + qCWarning(TcpServerSocketInterfaceLog) + << "Caught std::exception in" << __FILE__ << __LINE__ << #ifdef _MSC_VER // Visual Studio __FUNCTION__ #else __PRETTY_FUNCTION__ #endif - << Qt::endl - << "Exception:" << e.what() << Qt::endl - << "Message coming from:" << getAddress() << Qt::endl - << "Message length:" << messageLength << Qt::endl - << "Message content:" << inputBuffer.toHex(); + << Qt::endl + << "Exception:" << e.what() << Qt::endl + << "Message coming from:" << getAddress() << Qt::endl + << "Message length:" << messageLength << Qt::endl + << "Message content:" << inputBuffer.mid(readOffset, messageLength).toHex(); } catch (...) { qCWarning(TcpServerSocketInterfaceLog) << "Unhandled exception in" << __FILE__ << __LINE__ << #ifdef _MSC_VER // Visual Studio @@ -4482,7 +4492,7 @@ void TcpServerSocketInterface::readClient() << Qt::endl << "Message coming from:" << getAddress(); } - inputBuffer.remove(0, messageLength); + readOffset += messageLength; messageInProgress = false; if (ok) { @@ -4499,8 +4509,12 @@ void TcpServerSocketInterface::readClient() } else { qCWarning(TcpServerSocketInterfaceLog) << "parsing error!"; } + } - } while (!inputBuffer.isEmpty()); + // Drop the consumed prefix once, instead of shifting the buffer per message. + if (readOffset > 0) { + inputBuffer.remove(0, readOffset); + } } bool TcpServerSocketInterface::initTcpSession() diff --git a/servatrice/src/serversocketinterface.h b/servatrice/src/serversocketinterface.h index dd9d3f2bb..71854513f 100644 --- a/servatrice/src/serversocketinterface.h +++ b/servatrice/src/serversocketinterface.h @@ -123,6 +123,7 @@ signals: protected: void logDebugMessage(const QString &message) override; + [[nodiscard]] bool logsDebugMessages() const override; bool tooManyRegistrationAttempts(const QString &ipAddress); void processCommandContainer(const CommandContainer &cont) override; bool acceptsCredentialFormat(bool passwordNeedsHash, const QString &password) const; From 93309e01a2eece1f8151b78f31aea93fb5b699a1 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 25 Oct 2026 13:19:20 +0100 Subject: [PATCH 029/108] [Server/Client/Protocol] Let moderators clear banner cards (#7546) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- .../widgets/tabs/tab_card_art_rules.cpp | 56 +++++++++- .../widgets/tabs/tab_card_art_rules.h | 3 + .../interface/widgets/tabs/tab_moderation.cpp | 93 +++++++++++++--- .../interface/widgets/tabs/tab_moderation.h | 5 +- .../libcockatrice/protocol/pb/CMakeLists.txt | 2 + .../protocol/pb/moderator_commands.proto | 15 +++ .../response_clear_blacklisted_banners.proto | 9 ++ .../pb/response_remove_user_banner.proto | 11 ++ .../src/servatrice_database_interface.cpp | 102 ++++++++++++++++++ .../src/servatrice_database_interface.h | 12 +++ servatrice/src/serversocketinterface.cpp | 67 ++++++++++++ servatrice/src/serversocketinterface.h | 5 + 12 files changed, 366 insertions(+), 14 deletions(-) create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/response_clear_blacklisted_banners.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/response_remove_user_banner.proto diff --git a/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.cpp b/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.cpp index 1fc2854ce..f18aa82b9 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -29,7 +30,9 @@ #include #include #include +#include #include +#include #include class QObject; @@ -172,6 +175,7 @@ void TabCardArtRules::setupUi() addBtn = new QPushButton; removeBtn = new QPushButton; refreshBtn = new QPushButton; + clearBannedArtBtn = new QPushButton; modeBox->addItems({"ALLOW", "DENY"}); @@ -188,10 +192,14 @@ void TabCardArtRules::setupUi() form->addRow(tr("Mode:"), modeBox); form->addRow(tr("Reason:"), reasonEdit); + // The bulk clear acts on every user, so keep it visibly apart from the + // rules this tab edits. auto *buttons = new QHBoxLayout; buttons->addWidget(addBtn); buttons->addWidget(removeBtn); buttons->addWidget(refreshBtn); + buttons->addStretch(); + buttons->addWidget(clearBannedArtBtn); auto *layout = new QVBoxLayout; layout->addLayout(form); @@ -207,6 +215,8 @@ void TabCardArtRules::setupUi() connect(refreshBtn, &QPushButton::clicked, this, &TabCardArtRules::refresh); + connect(clearBannedArtBtn, &QPushButton::clicked, this, &TabCardArtRules::clearBlacklistedBanners); + retranslateUi(); } @@ -263,6 +273,7 @@ void TabCardArtRules::retranslateUi() addBtn->setText(tr("Add rule")); removeBtn->setText(tr("Remove rule")); refreshBtn->setText(tr("Refresh")); + clearBannedArtBtn->setText(tr("Clear blacklisted banners")); } void TabCardArtRules::refresh() @@ -299,4 +310,47 @@ void TabCardArtRules::removeSelected() client->sendCommand(client->prepareModeratorCommand(cmd)); refresh(); -} \ No newline at end of file +} + +void TabCardArtRules::clearBlacklistedBanners() +{ + if (!QtUtils::confirm(this, tr("Clear blacklisted banners"), + tr("Remove the banner card from every user whose current banner matches a DENY rule?\n\n" + "Users who are online keep showing their banner until they log in again."), + QMessageBox::No)) { + return; + } + + Command_ClearBlacklistedBanners cmd; + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabCardArtRules::clearBlacklistedBannersResponse); + client->sendCommand(pend); +} + +void TabCardArtRules::clearBlacklistedBannersResponse(const Response &r) +{ + switch (r.response_code()) { + case Response::RespOk: + break; + case Response::RespFunctionNotAllowed: + QMessageBox::warning(this, tr("Not supported"), + tr("This server does not support clearing blacklisted banners. It may be running an " + "older version.")); + return; + case Response::RespInternalError: + QMessageBox::critical(this, tr("Error"), + tr("The server could not clear blacklisted banners because of a database error.")); + return; + default: + QMessageBox::critical(this, tr("Error"), + tr("The server rejected the request: %1") + .arg(QString::fromStdString(Response_ResponseCode_Name(r.response_code())))); + return; + } + + const auto &resp = r.GetExtension(Response_ClearBlacklistedBanners::ext); + QMessageBox::information(this, tr("Banners cleared"), + tr("%n banner card(s) cleared.\n\n" + "Users who are online keep showing their banner until they log in again.", + "number of banners cleared", resp.cleared_count())); +} diff --git a/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.h b/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.h index f34300179..1e0155ab7 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.h +++ b/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.h @@ -78,6 +78,8 @@ private slots: void addRule(); void removeSelected(); void refresh(); + void clearBlacklistedBanners(); + void clearBlacklistedBannersResponse(const Response &r); private: AbstractClient *client; @@ -97,6 +99,7 @@ private: QPushButton *addBtn; QPushButton *removeBtn; QPushButton *refreshBtn; + QPushButton *clearBannedArtBtn; QTableView *table; CardArtRulesModel *tableModel; diff --git a/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp b/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp index 09a640540..be1adeef8 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp @@ -30,12 +30,14 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include #include @@ -85,12 +87,16 @@ TabModeration::TabModeration(TabSupervisor *_tabSupervisor, AbstractClient *_cli removeAvatarButton = new QPushButton; connect(removeAvatarButton, &QPushButton::clicked, this, &TabModeration::removeAvatar); + removeBannerButton = new QPushButton; + connect(removeBannerButton, &QPushButton::clicked, this, &TabModeration::removeBanner); + auto *topBar = new QHBoxLayout; topBar->addWidget(searchEdit); topBar->addWidget(investigateButton); topBar->addStretch(); topBar->addWidget(resetPasswordButton); topBar->addWidget(removeAvatarButton); + topBar->addWidget(removeBannerButton); userInfoGroup = new QGroupBox; userInfoNameLabel = new QLabel; @@ -186,6 +192,7 @@ void TabModeration::retranslateUi() investigateButton->setText(tr("Investigate")); resetPasswordButton->setText(tr("Reset Password")); removeAvatarButton->setText(tr("Remove Avatar")); + removeBannerButton->setText(tr("Remove Banner")); refreshStaffButton->setText(tr("Refresh")); userInfoGroup->setTitle(tr("User Info")); @@ -227,6 +234,7 @@ void TabModeration::clearUserData() sessionsTable->setRowCount(0); resetPasswordButton->setEnabled(false); removeAvatarButton->setEnabled(false); + removeBannerButton->setEnabled(false); } void TabModeration::investigate(const QString &userName) @@ -247,6 +255,7 @@ void TabModeration::investigateUser() currentUser = userName; resetPasswordButton->setEnabled(true); removeAvatarButton->setEnabled(true); + removeBannerButton->setEnabled(true); altsTable->setRowCount(0); sessionsTable->setRowCount(0); requestUserInfo(userName); @@ -414,13 +423,11 @@ void TabModeration::resetPassword() return; } - QMessageBox::StandardButton choice = - QMessageBox::warning(this, tr("Reset Password"), - tr("Reset the password of %1? A temporary password will be generated and shown to you. " - "The user must change it on their first login.") - .arg(currentUser), - QMessageBox::Ok | QMessageBox::Cancel, QMessageBox::Cancel); - if (choice != QMessageBox::Ok) { + if (!QtUtils::confirm(this, tr("Reset Password"), + tr("Reset the password of %1? A temporary password will be generated and shown to you. " + "The user must change it on their first login.") + .arg(currentUser), + QMessageBox::No)) { return; } @@ -451,11 +458,9 @@ void TabModeration::removeAvatar() return; } - QMessageBox::StandardButton choice = - QMessageBox::warning(this, tr("Remove Avatar"), - tr("Remove the avatar of %1? The user will have to upload a new one.").arg(currentUser), - QMessageBox::Ok | QMessageBox::Cancel, QMessageBox::Cancel); - if (choice != QMessageBox::Ok) { + if (!QtUtils::confirm(this, tr("Remove Avatar"), + tr("Remove the avatar of %1? The user will have to upload a new one.").arg(currentUser), + QMessageBox::No)) { return; } @@ -477,3 +482,67 @@ void TabModeration::removeAvatarResponse(const Response &response) QMessageBox::information(this, tr("Avatar removed"), tr("The avatar of %1 has been removed.").arg(QString::fromStdString(resp.user_name()))); } + +void TabModeration::removeBanner() +{ + if (currentUser.isEmpty()) { + return; + } + + if (!QtUtils::confirm(this, tr("Remove Banner"), + tr("Remove the banner card of %1? The user will have to choose a new one.\n\n" + "Other players stop seeing the banner the next time %1 logs in.") + .arg(currentUser), + QMessageBox::No)) { + return; + } + + Command_RemoveUserBanner cmd; + cmd.set_user_name(currentUser.toStdString()); + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabModeration::removeBannerResponse); + client->sendCommand(pend); +} + +void TabModeration::removeBannerResponse(const Response &response) +{ + const QString target = currentUser.isEmpty() ? tr("this user") : currentUser; + + switch (response.response_code()) { + case Response::RespOk: + break; + case Response::RespNameNotFound: + QMessageBox::warning(this, tr("User not found"), + tr("The server does not know a user named %1.").arg(target)); + return; + case Response::RespFunctionNotAllowed: + QMessageBox::warning(this, tr("Not supported"), + tr("This server does not support removing banner cards. It may be running an older " + "version.")); + return; + case Response::RespInternalError: + QMessageBox::critical( + this, tr("Error"), + tr("The server could not clear the banner card of %1 because of a database error.").arg(target)); + return; + default: + QMessageBox::critical( + this, tr("Error"), + tr("The server rejected the request for %1: %2") + .arg(target, QString::fromStdString(Response_ResponseCode_Name(response.response_code())))); + return; + } + + const Response_RemoveUserBanner &resp = response.GetExtension(Response_RemoveUserBanner::ext); + const QString removedUser = resp.user_name().empty() ? target : QString::fromStdString(resp.user_name()); + if (!resp.had_banner()) { + QMessageBox::information(this, tr("No banner set"), + tr("%1 has no banner card set, so there was nothing to remove.").arg(removedUser)); + return; + } + + QMessageBox::information(this, tr("Banner removed"), + tr("The banner card of %1 has been removed.\n\n" + "Other players stop seeing it the next time %1 logs in.") + .arg(removedUser)); +} diff --git a/cockatrice/src/interface/widgets/tabs/tab_moderation.h b/cockatrice/src/interface/widgets/tabs/tab_moderation.h index e352e90bb..ce23b5a7a 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_moderation.h +++ b/cockatrice/src/interface/widgets/tabs/tab_moderation.h @@ -20,7 +20,7 @@ class TabSupervisor; /** * Staff investigation tool. Lets moderators look up a user's account data, * alternate accounts, login sessions, and staff login activity, and offers - * the password-reset and remove-avatar actions. + * the password-reset, remove-avatar, and remove-banner actions. */ class TabModeration : public Tab { @@ -44,6 +44,8 @@ private slots: void resetPasswordResponse(const Response &response); void removeAvatar(); void removeAvatarResponse(const Response &response); + void removeBanner(); + void removeBannerResponse(const Response &response); private: void requestUserInfo(const QString &userName); @@ -60,6 +62,7 @@ private: QPushButton *investigateButton; QPushButton *resetPasswordButton; QPushButton *removeAvatarButton; + QPushButton *removeBannerButton; QGroupBox *userInfoGroup; QLabel *userInfoNameLabel; diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt index bd0b5bda2..059e83096 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt @@ -174,6 +174,8 @@ set(PROTO_FILES response_report_user_info.proto response_moderator_last_logins.proto response_remove_user_avatar.proto + response_remove_user_banner.proto + response_clear_blacklisted_banners.proto response_reset_user_password.proto response_user_alts.proto response_user_sessions.proto diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto index 5298d3dad..292917fa9 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto @@ -20,6 +20,8 @@ message ModeratorCommand { GET_MODERATOR_LAST_LOGINS = 1015; RESET_USER_PASSWORD = 1016; REMOVE_USER_AVATAR = 1017; + REMOVE_USER_BANNER = 1018; + CLEAR_BLACKLISTED_BANNERS = 1019; REPORT_LIST = 1200; REPORT_ASSIGN = 1201; REPORT_RESOLVE = 1202; @@ -179,3 +181,16 @@ message Command_RemoveUserAvatar { } optional string user_name = 1; } + +message Command_RemoveUserBanner { + extend ModeratorCommand { + optional Command_RemoveUserBanner ext = 1018; + } + optional string user_name = 1; +} + +message Command_ClearBlacklistedBanners { + extend ModeratorCommand { + optional Command_ClearBlacklistedBanners ext = 1019; + } +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_clear_blacklisted_banners.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_clear_blacklisted_banners.proto new file mode 100644 index 000000000..86cbc92f3 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_clear_blacklisted_banners.proto @@ -0,0 +1,9 @@ +syntax = "proto2"; +import "response.proto"; + +message Response_ClearBlacklistedBanners { + extend Response { + optional Response_ClearBlacklistedBanners ext = 1222; + } + optional uint32 cleared_count = 1 [default = 0]; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_remove_user_banner.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_remove_user_banner.proto new file mode 100644 index 000000000..bfad7333c --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_remove_user_banner.proto @@ -0,0 +1,11 @@ +syntax = "proto2"; +import "response.proto"; + +message Response_RemoveUserBanner { + extend Response { + optional Response_RemoveUserBanner ext = 1221; + } + optional string user_name = 1; + // false when the user exists but had no banner card set, so nothing was cleared + optional bool had_banner = 2 [default = true]; +} diff --git a/servatrice/src/servatrice_database_interface.cpp b/servatrice/src/servatrice_database_interface.cpp index 04f6047fa..b5c45065c 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -2077,6 +2077,108 @@ bool Servatrice_DatabaseInterface::removeUserAvatar(const QString &userName) return query->numRowsAffected() > 0; } +Servatrice_DatabaseInterface::RemoveUserBannerResult +Servatrice_DatabaseInterface::removeUserBanner(const QString &userName) +{ + if (!checkSql()) { + return RemoveUserBannerResult::DatabaseError; + } + + // The UPDATE alone cannot tell a missing user from a user without a banner, + // so read the row first and let the moderator know which case they hit. + QSqlQuery *lookupQuery = prepareQuery("SELECT card_art_params FROM {prefix}_users WHERE name = :user_name"); + lookupQuery->bindValue(":user_name", userName); + if (!execSqlQuery(lookupQuery)) { + return RemoveUserBannerResult::DatabaseError; + } + if (!lookupQuery->next()) { + return RemoveUserBannerResult::NoSuchUser; + } + if (lookupQuery->value(0).toString().isEmpty()) { + return RemoveUserBannerResult::NothingToRemove; + } + + QSqlQuery *query = prepareQuery("UPDATE {prefix}_users SET card_art_params = NULL WHERE name = :user_name"); + query->bindValue(":user_name", userName); + if (!execSqlQuery(query)) { + return RemoveUserBannerResult::DatabaseError; + } + return RemoveUserBannerResult::Removed; +} + +// isCardArtAllowed() compares against the rule table with the database's +// case-insensitive collation, so the sweep has to fold case the same way or it +// leaves behind banners the server already treats as blacklisted. +static QPair bannerKey(const QString &cardName, const QString &cardProviderId) +{ + return {cardName.toCaseFolded(), cardProviderId.toCaseFolded()}; +} + +int Servatrice_DatabaseInterface::clearBlacklistedBanners() +{ + if (!checkSql()) { + return -1; + } + + QSqlQuery *ruleQuery = prepareQuery("SELECT card_name, card_provider_id FROM {prefix}_card_art_name_rules " + "WHERE mode = 'DENY'"); + if (!execSqlQuery(ruleQuery)) { + return -1; + } + + QSet> denied; + while (ruleQuery->next()) { + denied.insert(bannerKey(ruleQuery->value(0).toString(), ruleQuery->value(1).toString())); + } + if (denied.isEmpty()) { + return 0; + } + + QSqlQuery *userQuery = + prepareQuery("SELECT id, card_art_params FROM {prefix}_users WHERE card_art_params IS NOT NULL"); + if (!execSqlQuery(userQuery)) { + return -1; + } + + QList matchingIds; + while (userQuery->next()) { + const QJsonDocument doc = QJsonDocument::fromJson(userQuery->value(1).toString().toUtf8()); + if (!doc.isObject()) { + continue; + } + const QJsonObject obj = doc.object(); + if (denied.contains(bannerKey(obj.value("card_name").toString(), obj.value("card_provider_id").toString()))) { + matchingIds.append(userQuery->value(0)); + } + } + + if (matchingIds.isEmpty()) { + return 0; + } + + // Chunk the UPDATE so very large user tables do not exceed driver placeholder limits. + int cleared = 0; + constexpr int BATCH_SIZE = 500; + for (int i = 0; i < matchingIds.size(); i += BATCH_SIZE) { + const QList batch = matchingIds.mid(i, BATCH_SIZE); + QStringList placeholders; + placeholders.reserve(batch.size()); + for (int j = 0; j < batch.size(); ++j) { + placeholders.append(QString(":id%1").arg(j)); + } + QSqlQuery *clearQuery = prepareQuery( + QString("UPDATE {prefix}_users SET card_art_params = NULL WHERE id IN (%1)").arg(placeholders.join(","))); + for (int j = 0; j < batch.size(); ++j) { + clearQuery->bindValue(placeholders.at(j), batch.at(j)); + } + if (!execSqlQuery(clearQuery)) { + return -1; + } + cleared += clearQuery->numRowsAffected(); + } + return cleared; +} + QList Servatrice_DatabaseInterface::getMessageLogHistory(const QString &user, const QString &ipaddress, const QString &gamename, diff --git a/servatrice/src/servatrice_database_interface.h b/servatrice/src/servatrice_database_interface.h index 822accada..6b38384e4 100644 --- a/servatrice/src/servatrice_database_interface.h +++ b/servatrice/src/servatrice_database_interface.h @@ -231,6 +231,18 @@ public: }; UptimeSnapshot getLatestUptimeSnapshot(int serverId); bool removeUserAvatar(const QString &userName); + + // Outcome of removeUserBanner(), so the caller can tell a missing user apart + // from a user who simply has no banner card set. + enum class RemoveUserBannerResult + { + Removed, + NothingToRemove, + NoSuchUser, + DatabaseError, + }; + RemoveUserBannerResult removeUserBanner(const QString &userName); + int clearBlacklistedBanners(); bool isCardArtAllowed(const QString &cardName, const QString &cardProviderId) override; bool addForgotPassword(const QString &user); bool removeForgotPassword(const QString &user) override; diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index fc911950e..2cb4df8ba 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -126,6 +126,7 @@ #include #include #include +#include #include #include #include @@ -140,6 +141,7 @@ #include #include #include +#include #include #include #include @@ -484,6 +486,10 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedModeratorCo return cmdGetModeratorLastLogins(cmd.GetExtension(Command_GetModeratorLastLogins::ext), rc); case ModeratorCommand::REMOVE_USER_AVATAR: return cmdRemoveUserAvatar(cmd.GetExtension(Command_RemoveUserAvatar::ext), rc); + case ModeratorCommand::REMOVE_USER_BANNER: + return cmdRemoveUserBanner(cmd.GetExtension(Command_RemoveUserBanner::ext), rc); + case ModeratorCommand::CLEAR_BLACKLISTED_BANNERS: + return cmdClearBlacklistedBanners(cmd.GetExtension(Command_ClearBlacklistedBanners::ext), rc); default: return Response::RespFunctionNotAllowed; } @@ -2691,6 +2697,67 @@ Response::ResponseCode AbstractServerSocketInterface::cmdRemoveUserAvatar(const return Response::RespOk; } +Response::ResponseCode AbstractServerSocketInterface::cmdRemoveUserBanner(const Command_RemoveUserBanner &cmd, + ResponseContainer &rc) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + const QString userName = nameFromStdString(cmd.user_name()).simplified(); + if (userName.isEmpty()) { + return Response::RespContextError; + } + + bool hadBanner = true; + switch (sqlInterface->removeUserBanner(userName)) { + case Servatrice_DatabaseInterface::RemoveUserBannerResult::Removed: + break; + case Servatrice_DatabaseInterface::RemoveUserBannerResult::NothingToRemove: + hadBanner = false; + break; + case Servatrice_DatabaseInterface::RemoveUserBannerResult::NoSuchUser: + return Response::RespNameNotFound; + case Servatrice_DatabaseInterface::RemoveUserBannerResult::DatabaseError: + default: + return Response::RespInternalError; + } + + if (hadBanner) { + sqlInterface->addAuditRecord(userName, this->getAddress(), QString::fromStdString(userInfo->clientid()), + "REMOVE_USER_BANNER", "Moderator removed user banner card", true); + } + + Response_RemoveUserBanner *re = new Response_RemoveUserBanner; + re->set_user_name(userName.toStdString()); + re->set_had_banner(hadBanner); + rc.setResponseExtension(re); + return Response::RespOk; +} + +Response::ResponseCode +AbstractServerSocketInterface::cmdClearBlacklistedBanners(const Command_ClearBlacklistedBanners & /* cmd */, + ResponseContainer &rc) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + const int cleared = sqlInterface->clearBlacklistedBanners(); + if (cleared < 0) { + return Response::RespInternalError; + } + + sqlInterface->addAuditRecord(QString::fromStdString(userInfo->name()), this->getAddress(), + QString::fromStdString(userInfo->clientid()), "CLEAR_BLACKLISTED_BANNERS", + QString("Moderator cleared %1 blacklisted banner(s)").arg(cleared), true); + + Response_ClearBlacklistedBanners *re = new Response_ClearBlacklistedBanners; + re->set_cleared_count(static_cast(cleared)); + rc.setResponseExtension(re); + return Response::RespOk; +} + void AbstractServerSocketInterface::sendPendingReportNotifications(ResponseContainer &rc) { if (!sqlInterface->checkSql()) { diff --git a/servatrice/src/serversocketinterface.h b/servatrice/src/serversocketinterface.h index 71854513f..cd37286c6 100644 --- a/servatrice/src/serversocketinterface.h +++ b/servatrice/src/serversocketinterface.h @@ -73,6 +73,7 @@ class Command_AccountPassword; class Command_Activate; class Command_AddCardArtRule; class Command_AdjustMod; +class Command_ClearBlacklistedBanners; class Command_ForceActivateUser; class Command_ForgotPasswordChallenge; class Command_ForgotPasswordRequest; @@ -90,6 +91,7 @@ class Command_ListCardArtRules; class Command_Register; class Command_RemoveCardArtRule; class Command_RemoveUserAvatar; +class Command_RemoveUserBanner; class Command_Report; class Command_ReportAddComment; class Command_ReportAssign; @@ -236,6 +238,9 @@ private: Response::ResponseCode cmdGetModeratorLastLogins(const Command_GetModeratorLastLogins &cmd, ResponseContainer &rc); Response::ResponseCode cmdResetUserPassword(const Command_ResetUserPassword &cmd, ResponseContainer &rc); Response::ResponseCode cmdRemoveUserAvatar(const Command_RemoveUserAvatar &cmd, ResponseContainer &rc); + Response::ResponseCode cmdRemoveUserBanner(const Command_RemoveUserBanner &cmd, ResponseContainer &rc); + Response::ResponseCode cmdClearBlacklistedBanners(const Command_ClearBlacklistedBanners &cmd, + ResponseContainer &rc); Response::ResponseCode cmdGetServerStats(const Command_GetServerStats &cmd, ResponseContainer &rc); From f3fb3331d141da686da07a3dd560b2391293acb1 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:35:10 +0100 Subject: [PATCH 030/108] [Cards] Guard against null QScreen when positioning enlarged pixmap (#7650) Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> --- .../interface/widgets/cards/card_info_picture_widget.cpp | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp b/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp index 3c1e1b77f..0d94e7209 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp @@ -491,7 +491,14 @@ void CardInfoPictureWidget::showEnlargedPixmap() enlargedPixmapWidget->setCardPixmap(exactCard, enlargedSize); const QPoint cursorPos = QCursor::pos(); - const QRect screenGeometry = QGuiApplication::screenAt(cursorPos)->geometry(); + QScreen *screen = QGuiApplication::screenAt(cursorPos); + if (!screen) { + screen = QGuiApplication::primaryScreen(); + } + if (!screen) { + return; + } + const QRect screenGeometry = screen->geometry(); const QSize widgetSize = enlargedPixmapWidget->size(); int newX = cursorPos.x() + ENLARGED_PIXMAP_OFFSET; From 4ab875be48fb198572075f03ce192157e1d3864d Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:35:46 +0100 Subject: [PATCH 031/108] [VisualDeckStorage] Keep databaseLoadIndicator alive when swapping in folder widget (#7651) Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> --- .../visual_deck_storage_widget.cpp | 12 +++++++++++- .../visual_deck_storage/visual_deck_storage_widget.h | 3 ++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp index efcf19e58..4e1d6293f 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp @@ -184,7 +184,9 @@ void VisualDeckStorageWidget::resizeEvent(QResizeEvent *event) void VisualDeckStorageWidget::retranslateUi() { - databaseLoadIndicator->setText(tr("Loading database ...")); + if (databaseLoadIndicator) { + databaseLoadIndicator->setText(tr("Loading database ...")); + } refreshButton->setToolTip(tr("Refresh loaded files")); shareButton->setToolTip(tr("Select decks to share")); @@ -279,6 +281,14 @@ void VisualDeckStorageWidget::createRootFolderWidget() folderWidget = new VisualDeckStorageFolderDisplayWidget(this, this, QString(), false, quickSettingsWidget->getShowFolders()); + // QScrollArea::setWidget() deletes the widget it currently holds. If that is still the + // database load indicator, take it back first so the label stays alive for retranslateUi(). + if (databaseLoadIndicator && scrollArea->widget() == databaseLoadIndicator) { + scrollArea->takeWidget(); + databaseLoadIndicator->setParent(this); + databaseLoadIndicator->setVisible(false); + } + scrollArea->setWidget(folderWidget); // this automatically destroys the old folderWidget scrollArea->widget()->setMaximumWidth(scrollArea->viewport()->width()); scrollArea->widget()->adjustSize(); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.h index b512dd87c..19d16f107 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.h @@ -6,6 +6,7 @@ #ifndef VISUAL_DECK_STORAGE_WIDGET_H #define VISUAL_DECK_STORAGE_WIDGET_H +#include #include #include #include @@ -87,7 +88,7 @@ private: QVBoxLayout *layout; QWidget *searchAndSortContainer; QHBoxLayout *searchAndSortLayout; - QLabel *databaseLoadIndicator; + QPointer databaseLoadIndicator; VisualDeckStorageSortWidget *sortWidget; VisualDeckStorageSearchWidget *searchWidget; DeckPreviewColorIdentityFilterWidget *deckPreviewColorIdentityFilterWidget; From e6b4c515850adc2bb4def5a1cbc3ec5ef0937f59 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:14:21 +0100 Subject: [PATCH 032/108] [Version] Bump to 3.2.0 for beta release. (#7653) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 41fe393e7..284c1b9b0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -90,7 +90,7 @@ endif() # A project name is needed for CPack # Version can be overriden by git tags, see cmake/getversion.cmake -project("Cockatrice" VERSION 3.1.2) +project("Cockatrice" VERSION 3.2.0) # Set release name if not provided via env/cmake var if(NOT DEFINED GIT_TAG_RELEASENAME) From 4d8ab54313dfcd58c0f3c35a274524ad1b336664 Mon Sep 17 00:00:00 2001 From: ebbit1q Date: Sun, 25 Oct 2026 20:19:23 +0100 Subject: [PATCH 033/108] Revert "[Version] Bump to 3.2.0 for beta release. (#7653)" (#7660) This reverts commit e6b4c515850adc2bb4def5a1cbc3ec5ef0937f59. --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 284c1b9b0..41fe393e7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -90,7 +90,7 @@ endif() # A project name is needed for CPack # Version can be overriden by git tags, see cmake/getversion.cmake -project("Cockatrice" VERSION 3.2.0) +project("Cockatrice" VERSION 3.1.2) # Set release name if not provided via env/cmake var if(NOT DEFINED GIT_TAG_RELEASENAME) From 858b270fa9637a6fc6d08a29ae0fa89ab9c261e2 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:40:57 +0100 Subject: [PATCH 034/108] [Installer] Retry locked DLL purge before aborting upgrade (#7657) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Installer] Retry locked DLL purge before aborting upgrade * [Installer] Keep the purge verdict when the portable purge runs $IsUpgrade was read from IsExistingCockatriceInstall after the portable purge, which deletes the very markers that function looks for. A purge whose only survivors were non-Qt DLLs (abseil_dll.dll, libssl-3-x64.dll) or content under Plugins therefore reported "not an install", $IsUpgrade became 0 and the abort was skipped - the mixed DLL set the retry exists to prevent. Measure the directory before either purge block, where it still describes the directory as it arrived, and let RetryDllPurge's own verdict decide the abort. A portable install into a directory without portable.dat is neither purge block, so $StaleRuntimeAfterPurge was never assigned there and the abort could not fire either. Track whether a purge ran and measure that directory with HasStaleRuntimeFiles instead, which is what every upgrade did before the retry. --------- Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> Co-authored-by: BruebachL <44814898+BruebachL@users.noreply.github.com> Co-authored-by: Lukas Brübach --- cmake/NSIS.template.in | 104 ++++++++++++++++++++++++++++++++--------- 1 file changed, 83 insertions(+), 21 deletions(-) diff --git a/cmake/NSIS.template.in b/cmake/NSIS.template.in index ae156313d..12185adda 100644 --- a/cmake/NSIS.template.in +++ b/cmake/NSIS.template.in @@ -27,6 +27,11 @@ Var StaleFindName Var DataBackupFindHandle Var DataBackupFindName Var IsUpgrade +; Set together by whichever purge block ran: the verdict RetryDllPurge measured +; and whether a purge ran at all - a portable install into a directory without +; portable.dat is neither purge block, and the abort below measures it itself. +Var StaleRuntimeAfterPurge +Var DllPurgeRan Var AbortReason ; Diagnostics for a failed purge: the holder probe in LogProcessesUsingInstallDir writes ; $HolderOutput (one log line) and sets $HolderFound when it named anything. @@ -153,6 +158,10 @@ StrCpy $LooseDeckNoticeShown "" ; No old install location resolved yet; Backup/Restore fall back to $INSTDIR ; when this is empty (the /R path where $INSTDIR already is the old dir). StrCpy $OldInstallDir "" +; No purge has run and nothing has been measured yet. The section sets both +; after a purge, or measures the directory itself when neither purge applies. +StrCpy $StaleRuntimeAfterPurge 0 +StrCpy $DllPurgeRan 0 ; Start each run from a clean file so the log that gets sent back is the last ; run, not every past run glued together. FileOpen $LogHandle "$InstallLogPath" w @@ -1176,6 +1185,33 @@ Function CheckStaleRuntimeMask ${EndIf} FunctionEnd +; Removes the Plugins directory and all top-level DLLs from $INSTDIR, then +; retries up to two more times (2 s apart) when files remain locked, so that +; transient holders such as antivirus scanners have a chance to release their +; handles before the installer gives up. Three attempts total. +; Sets $R0 to 0 when the purge is clean, or to 1 when stale DLLs remain +; after all attempts. +Function RetryDllPurge + StrCpy $R5 0 ; counts retries after the initial attempt + rdp_loop: + RMDir /r "$INSTDIR\Plugins" + Delete "$INSTDIR\*.dll" + Call HasStaleRuntimeFiles + ; $R0 = 0: all clear, nothing left to do. + ${If} $R0 = 0 + Return + ${EndIf} + IntOp $R5 $R5 + 1 + ; Allow two retries after the initial attempt ($R5 is the retry counter, + ; so the loop runs at most 3 times: attempt + retry 1 + retry 2). + ${If} $R5 > 2 + !insertmacro CockLog "RetryDllPurge: stale files remain after 3 attempts, giving up" + Return ; $R0 is already 1 + ${EndIf} + !insertmacro CockLog "RetryDllPurge: retry $R5 of 2 after stale purge, sleeping 2 s" + Sleep 2000 + Goto rdp_loop +FunctionEnd ; RetryDllPurge ; Turns a Win32 error code into the wording that goes into the log. ; usage: set $0 to the code, result in $R0. ; @@ -1631,6 +1667,23 @@ ${If} $ReinstallMode = 0 Call EnsureAppsNotRunning ${EndIf} +; Measured before either purge block, because it has to describe the directory as +; it arrived here and not what a purge left of it: IsExistingCockatriceInstall +; looks for cockatrice.exe, oracle.exe, servatrice.exe, uninstall.exe, Qt6*.dll +; and qt.conf, and the portable purge below deletes exactly those. Taken after +; that purge it answered "not an install" as soon as the only survivor was a file +; it does not look for - a locked abseil_dll.dll or libssl-3-x64.dll, say - and +; the abort further down was then skipped for the very directory the purge had +; just failed in. +; Remembered because the stale-runtime check below must only ever fire for a +; directory this installer recognised as a Cockatrice install. Scanning for +; leftovers in an unrelated directory (a first-time install into a folder that +; happens to hold some Qt5 DLLs, say) would otherwise turn those files into an +; abort reason even though the payload is about to overwrite nothing of theirs. +Call IsExistingCockatriceInstall +!insertmacro CockLog "section: IsExistingCockatriceInstall=$R0" +StrCpy $IsUpgrade $R0 + ${If} $PortableMode = 1 ${AndIf} ${FileExists} "$INSTDIR\portable.dat" ; upgrade portable mode @@ -1654,7 +1707,12 @@ ${AndIf} ${FileExists} "$INSTDIR\portable.dat" ; what this has to guarantee is that nothing from the previous build is left ; to be loaded next to the new executables. Naming the families by hand is ; what let abseil_dll.dll through; see HasStaleRuntimeFiles. - Delete "$INSTDIR\*.dll" + Call RetryDllPurge + ; The purge's own verdict: HasStaleRuntimeFiles measures *.dll + Plugins\*.*, + ; the set that catches any leftover runtime DLL, Qt or otherwise. Held in a + ; variable because $R0 is reused by the next function call in this section. + StrCpy $StaleRuntimeAfterPurge $R0 + StrCpy $DllPurgeRan 1 Delete "$INSTDIR\qt.conf" Delete "$INSTDIR\qdebug.txt" Delete "$INSTDIR\servatrice.sql" @@ -1685,25 +1743,13 @@ ${EndIf} ; checked without a Windows toolchain. Compiled by the same build that compiles this ; installer, the layout belongs to the Windows SDK, and .ci/compile.sh runs the probe ; on the Windows runners instead of trusting that it compiles. -Call IsExistingCockatriceInstall -!insertmacro CockLog "section: IsExistingCockatriceInstall=$R0" -; Remembered because the stale-runtime check below must only ever fire for a -; directory this installer recognised as a Cockatrice install. Scanning for -; leftovers in an unrelated directory (a first-time install into a folder that -; happens to hold some Qt5 DLLs, say) would otherwise turn those files into an -; abort reason even though the payload is about to overwrite nothing of theirs. -StrCpy $IsUpgrade $R0 ${If} $PortableMode = 0 -${AndIf} $R0 = 1 +${AndIf} $IsUpgrade = 1 !insertmacro CockLog "section: non-portable upgrade purge" !insertmacro CockLogListing "before-purge" "*.dll" - RMDir /r "$INSTDIR\Plugins" - ; RMDir /r reports a failure only through the error flag, and only as a - ; boolean; the surviving file names come from the listing below. - ${If} ${FileExists} "$INSTDIR\Plugins\*.*" - !insertmacro CockLog "purge: '$INSTDIR\Plugins' could not be removed completely" - ${EndIf} - Delete "$INSTDIR\*.dll" + Call RetryDllPurge + StrCpy $StaleRuntimeAfterPurge $R0 + StrCpy $DllPurgeRan 1 !insertmacro CockLogListing "after-purge" "*.dll" !insertmacro CockLogListing "after-plugins" "Plugins\*.*" ${EndIf} @@ -1718,13 +1764,29 @@ ${EndIf} ; run on Windows or it contains an error", so stop here instead of compounding ; it. Nothing of the new payload has been written at this point, so the next run, ; with the applications closed, gets a clean install. +; $StaleRuntimeAfterPurge is RetryDllPurge's verdict, measured with +; HasStaleRuntimeFiles (*.dll + Plugins\*.*) - the set that catches any leftover +; runtime DLL, Qt or otherwise. It exists only when a purge ran, and the two +; purge blocks above cover every upgrade except one: a portable install into a +; directory without portable.dat is neither of them, so that directory is +; measured here instead. A test of $IsUpgrade alone could not stand in for it - +; that only says the directory was ours before the purge, not what the purge +; left behind. +; A directory this installer did not recognise as an install never aborts: the +; unrelated DLLs already in it (a first-time install into a folder that happens +; to hold some Qt5 DLLs, say) are not leftovers, and the payload overwrites +; nothing of theirs. ${If} $IsUpgrade = 1 - Call HasStaleRuntimeFiles - !insertmacro CockLog "section: HasStaleRuntimeFiles=$R0" + ${If} $DllPurgeRan = 0 + !insertmacro CockLog "section: no purge applies, measuring the directory instead" + Call HasStaleRuntimeFiles + StrCpy $StaleRuntimeAfterPurge $R0 + ${EndIf} + !insertmacro CockLog "section: stale-runtime after purge=$StaleRuntimeAfterPurge" ${Else} - StrCpy $R0 0 + StrCpy $StaleRuntimeAfterPurge 0 ${EndIf} -${If} $R0 = 1 +${If} $StaleRuntimeAfterPurge = 1 !insertmacro CockLog "section: aborting, files of a previous version survived the purge" DetailPrint "Files of a previous version could not be removed; aborting the installation." ; Who is holding the payload is recorded before the abort, so the log that From 675e1f74471937c703e7a0c5098f34a06513244a Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:39:26 +0100 Subject: [PATCH 035/108] [Translation] Name languages without reading the translation files (#7662) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Translation] Name languages without reading the translation files The language selector derived every label from the language's own .qm file: it loaded the file and translated the literal "English" in the "i18n" context. lupdate could never extract that literal from the callers, which pass the DEFAULT_LANG_NAME macro, so a dummy QT_TRANSLATE_NOOP in cache_settings.cpp existed to give it something to extract. #7050 deleted that dummy, the next lupdate push dropped the string from Transifex, and the following pull rewrote the .ts files without it. lrelease only emits translated messages, so 22 of 25 .qm files returned an empty name. The names are data, not translations: a selector has to be readable before any translation is loaded, and a label must not depend on the translation of the language it names. TranslationLoader::languageDisplayName now holds them next to availableLanguages, falling back to Qt's native language name and then to the code, so a language added later still gets a label. Also select the configured language by item data instead of by matching the translated "English" string, which pointed at the wrong row whenever the lookup came back empty, and restore that English entry's own name: en_US is "English (United States)" so it can no longer be confused with the untranslated default. Oracle shared the code path, so its wizard language page is fixed too. * [Translation] Name the default language selection once Review follow-up: log the unresolvable language through the page's own category, and move the en_US entry both selectors fall back to for an unset setting into TranslationLoader::defaultLanguageCode. --------- Co-authored-by: Lukas Brübach --- .../src/client/settings/cache_settings.h | 1 - .../widgets/onboarding/pages/welcome_page.cpp | 3 +- .../settings_page/general_settings_page.cpp | 38 +++++-------- .../settings_page/general_settings_page.h | 1 - .../utility/translation_loader.cpp | 56 +++++++++++++++++++ .../utility/translation_loader.h | 20 +++++++ oracle/src/oraclewizard.cpp | 3 - oracle/src/pages.cpp | 35 +++++------- oracle/src/pages.h | 1 - tests/CMakeLists.txt | 1 + tests/translation_loader_test.cpp | 49 ++++++++++++++++ 11 files changed, 157 insertions(+), 51 deletions(-) diff --git a/cockatrice/src/client/settings/cache_settings.h b/cockatrice/src/client/settings/cache_settings.h index 5f699247d..9aeb88a18 100644 --- a/cockatrice/src/client/settings/cache_settings.h +++ b/cockatrice/src/client/settings/cache_settings.h @@ -19,7 +19,6 @@ inline Q_LOGGING_CATEGORY(SettingsCacheLog, "settings_cache"); class ReleaseChannel; -#define DEFAULT_LANG_NAME "English" #define CLIENT_INFO_NOT_SET "notset" #define DEFAULT_FONT_SIZE 12 diff --git a/cockatrice/src/interface/widgets/onboarding/pages/welcome_page.cpp b/cockatrice/src/interface/widgets/onboarding/pages/welcome_page.cpp index 71d2fa409..b2d5ab1e0 100644 --- a/cockatrice/src/interface/widgets/onboarding/pages/welcome_page.cpp +++ b/cockatrice/src/interface/widgets/onboarding/pages/welcome_page.cpp @@ -5,6 +5,7 @@ #include "../../settings_page/general_settings_page.h" #include "../first_run_wizard_page.h" #include "libcockatrice/settings/personal_settings.h" +#include "libcockatrice/utility/translation_loader.h" #include #include @@ -30,7 +31,7 @@ WelcomePage::WelcomePage(QWidget *parent) : FirstRunWizardPage(parent) languageLabel = new QLabel(this); langCombo = new QComboBox(this); for (const QString &code : GeneralSettingsPage::findQmFiles()) { - langCombo->addItem(GeneralSettingsPage::languageName(code), code); + langCombo->addItem(TranslationLoader::languageDisplayName(code), code); } QString current = SettingsCache::instance().personal().getLang(); diff --git a/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp index 2b885237b..77abc699c 100644 --- a/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp @@ -8,7 +8,6 @@ #include "update/client/release_channel.h" #include -#include #include #include #include @@ -22,7 +21,6 @@ #include #include #include -#include #include #include #include @@ -51,18 +49,24 @@ enum startupCardUpdateCheckBehaviorIndex GeneralSettingsPage::GeneralSettingsPage() { // language settings - QStringList languageCodes = findQmFiles(); - for (const QString &code : languageCodes) { - QString langName = languageName(code); - languageBox.addItem(langName, code); + for (const QString &code : findQmFiles()) { + languageBox.addItem(TranslationLoader::languageDisplayName(code), code); } - QString setLanguage = QCoreApplication::translate("i18n", DEFAULT_LANG_NAME); - int index = languageBox.findText(setLanguage, Qt::MatchExactly); - if (index == -1) { - qWarning() << "could not find language" << setLanguage; - } else { + // Select the language the client runs with: its own entry, the base language for a variant without a + // file of its own (de_CH -> de), and the default language for an unset setting, which names no file. + const QString configuredLanguage = SettingsCache::instance().personal().getLang(); + int index = languageBox.findData(configuredLanguage); + if (index < 0) { + index = languageBox.findData(configuredLanguage.section('_', 0, 0)); + } + if (index < 0) { + index = languageBox.findData(TranslationLoader::defaultLanguageCode()); + } + if (index >= 0) { languageBox.setCurrentIndex(index); + } else { + qCWarning(GeneralSettingsPageLog) << "could not find language" << configuredLanguage; } advertiseTranslationPageLabel.setTextInteractionFlags(Qt::LinksAccessibleByMouse); @@ -384,18 +388,6 @@ QStringList GeneralSettingsPage::findQmFiles() TranslationLoader::applicationTranslationPaths(translationPath)); } -QString GeneralSettingsPage::languageName(const QString &lang) -{ - QTranslator qTranslator; - - // Only the language name is needed here, so the file is loaded without logging a line per - // language: findQmFiles() asked for languages whose files exist in the first place. - TranslationLoader::loadFrom(qTranslator, translationPrefix + "_" + lang, - TranslationLoader::applicationTranslationPaths(translationPath)); - - return qTranslator.translate("i18n", DEFAULT_LANG_NAME); -} - void GeneralSettingsPage::deckPathButtonClicked() { QString path = QFileDialog::getExistingDirectory(this, tr("Choose path"), deckPathEdit->text()); diff --git a/cockatrice/src/interface/widgets/settings_page/general_settings_page.h b/cockatrice/src/interface/widgets/settings_page/general_settings_page.h index 20564c968..9451db434 100644 --- a/cockatrice/src/interface/widgets/settings_page/general_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/general_settings_page.h @@ -26,7 +26,6 @@ public: void retranslateUi() override; static QStringList findQmFiles(); - static QString languageName(const QString &lang); signals: /// Request to re-import the card database with the newly selected card language diff --git a/libcockatrice_utility/libcockatrice/utility/translation_loader.cpp b/libcockatrice_utility/libcockatrice/utility/translation_loader.cpp index 41c6107b3..e5761fcaf 100644 --- a/libcockatrice_utility/libcockatrice/utility/translation_loader.cpp +++ b/libcockatrice_utility/libcockatrice/utility/translation_loader.cpp @@ -5,8 +5,10 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -100,6 +102,60 @@ QList TranslationLoader::availableLanguages(const QString &_prefix, con return sortedLanguages; } +QString TranslationLoader::languageDisplayName(const QString &_lang) +{ + if (_lang.isEmpty()) { + return QString(); + } + + // The native name first, so a speaker finds their own language, then the English one, so everyone + // else does. A language shipped with a .qm file belongs in here: the names used to come from the + // translation files, where a missing translation left the selector blank. + static const QHash displayNames = { + {QStringLiteral("ca"), QStringLiteral("Català (Catalan)")}, + {QStringLiteral("cs"), QStringLiteral("Česky (Czech)")}, + {QStringLiteral("de"), QStringLiteral("Deutsch (German)")}, + {QStringLiteral("el"), QStringLiteral("Ελληνικά (Greek)")}, + {QStringLiteral("en@pirate"), QStringLiteral("English, arr! (Pirate English)")}, + {QStringLiteral("en_US"), QStringLiteral("English (United States)")}, + {QStringLiteral("es"), QStringLiteral("Español (Spanish)")}, + {QStringLiteral("et"), QStringLiteral("Eesti Keel (Estonian)")}, + {QStringLiteral("fi"), QStringLiteral("Suomi (Finnish)")}, + {QStringLiteral("fr"), QStringLiteral("Français (French)")}, + {QStringLiteral("hu"), QStringLiteral("Magyar (Hungarian)")}, + {QStringLiteral("it"), QStringLiteral("Italiano (Italian)")}, + {QStringLiteral("ja"), QStringLiteral("日本語 (Japanese)")}, + {QStringLiteral("ko"), QStringLiteral("한국어 (Korean)")}, + {QStringLiteral("nb"), QStringLiteral("Norsk Bokmål (Norwegian Bokmål)")}, + {QStringLiteral("nl"), QStringLiteral("Nederlands (Dutch)")}, + {QStringLiteral("pl"), QStringLiteral("Polski (Polish)")}, + {QStringLiteral("pt"), QStringLiteral("Português (Portuguese)")}, + {QStringLiteral("pt_BR"), QStringLiteral("Português do Brasil (Brazilian Portuguese)")}, + {QStringLiteral("ru"), QStringLiteral("Русский (Russian)")}, + {QStringLiteral("sr"), QStringLiteral("Srpski (Serbian)")}, + {QStringLiteral("sv"), QStringLiteral("Svenska (Swedish)")}, + {QStringLiteral("tr"), QStringLiteral("Türkçe (Turkish)")}, + {QStringLiteral("yue"), QStringLiteral("粵語 (Cantonese)")}, + {QStringLiteral("zh-Hans"), QStringLiteral("简体中文 (Chinese Simplified)")}, + {QStringLiteral("zh-Hant"), QStringLiteral("繁體中文 (Chinese Traditional)")}, + }; + + const QString displayName = displayNames.value(_lang); + if (!displayName.isEmpty()) { + return displayName; + } + + // A language added after this table still gets a label instead of the blank one this function exists + // to prevent. Qt knows no name for an unknown code and answers with an empty string. + const QString nativeName = QLocale(_lang).nativeLanguageName(); + return nativeName.isEmpty() ? _lang : nativeName; +} + +QString TranslationLoader::defaultLanguageCode() +{ + return QStringLiteral("en_US"); +} + TranslationLoadResult TranslationLoader::loadFrom(QTranslator &_translator, const QString &_nameHint, const QList &_directories) { diff --git a/libcockatrice_utility/libcockatrice/utility/translation_loader.h b/libcockatrice_utility/libcockatrice/utility/translation_loader.h index 645489696..53f419cf2 100644 --- a/libcockatrice_utility/libcockatrice/utility/translation_loader.h +++ b/libcockatrice_utility/libcockatrice/utility/translation_loader.h @@ -74,6 +74,26 @@ TranslationLoadResult loadQt(QTranslator &_translator, const QString &_lang); */ QList availableLanguages(const QString &_prefix, const QList &_directories); +/** + * @brief A human-readable name for a language, e.g. "Deutsch (German)". + * + * The names are data, not translations: a language selector has to be readable before any translation + * is loaded, and its labels must not depend on the translation of the language they name. + * + * @param _lang The language code, e.g. "de" or "pt_BR". + * @return The curated name; for a code it does not know, the native language name; the code itself when + * Qt has no name for it either; an empty string when _lang is empty. + */ +QString languageDisplayName(const QString &_lang); + +/** + * @brief The language a client runs with when none is configured, for preselecting a selector entry. + * + * English is the untranslated default and ships no translation file of its own, so a selector has nothing + * of its own to point at and uses the entry that carries an English translation instead. + */ +QString defaultLanguageCode(); + /** * @brief Loads the <_prefix>_<_lang>.qm translation file from _directories into _translator. * diff --git a/oracle/src/oraclewizard.cpp b/oracle/src/oraclewizard.cpp index 47afecc06..c33d821a5 100644 --- a/oracle/src/oraclewizard.cpp +++ b/oracle/src/oraclewizard.cpp @@ -32,9 +32,6 @@ class QWidget; OracleWizard::OracleWizard(QWidget *parent) : QWizard(parent) { - // define a dummy context that will be used where needed - QString dummy = QT_TRANSLATE_NOOP("i18n", "English"); - #ifdef Q_OS_WIN setWizardStyle(QWizard::ModernStyle); #endif diff --git a/oracle/src/pages.cpp b/oracle/src/pages.cpp index ed8e04d1a..4c664a38c 100644 --- a/oracle/src/pages.cpp +++ b/oracle/src/pages.cpp @@ -10,7 +10,6 @@ #include #include #include -#include #include #include #include @@ -39,7 +38,6 @@ #include #include #include -#include #include #include #include @@ -203,16 +201,23 @@ IntroPage::IntroPage(QWidget *parent) : OracleWizardPage(parent) QStringList languageCodes = findQmFiles(); for (const QString &code : languageCodes) { - QString langName = languageName(code); - languageBox->addItem(langName, code); + languageBox->addItem(TranslationLoader::languageDisplayName(code), code); } - QString setLanguage = QCoreApplication::translate("i18n", DEFAULT_LANG_NAME); - int index = languageBox->findText(setLanguage, Qt::MatchExactly); - if (index == -1) { - qWarning() << "could not find language" << setLanguage; - } else { + // Select the language the wizard runs with: its own entry, the base language for a variant without a + // file of its own (de_CH -> de), and the default language for an unset setting, which names no file. + const QString configuredLanguage = SettingsCache::instance().personal().getLang(); + int index = languageBox->findData(configuredLanguage); + if (index < 0) { + index = languageBox->findData(configuredLanguage.section('_', 0, 0)); + } + if (index < 0) { + index = languageBox->findData(TranslationLoader::defaultLanguageCode()); + } + if (index >= 0) { languageBox->setCurrentIndex(index); + } else { + qWarning() << "could not find language" << configuredLanguage; } connect(languageBox, qOverload(&QComboBox::currentIndexChanged), this, &IntroPage::languageBoxChanged); @@ -239,18 +244,6 @@ QStringList IntroPage::findQmFiles() TranslationLoader::applicationTranslationPaths(translationPath)); } -QString IntroPage::languageName(const QString &lang) -{ - QTranslator qTranslator; - - // Only the language name is needed here, so the file is loaded without logging a line per - // language: findQmFiles() asked for languages whose files exist in the first place. - TranslationLoader::loadFrom(qTranslator, translationPrefix + "_" + lang, - TranslationLoader::applicationTranslationPaths(translationPath)); - - return qTranslator.translate("i18n", DEFAULT_LANG_NAME); -} - void IntroPage::languageBoxChanged(int index) { SettingsCache::instance().personal().setLang(languageBox->itemData(index).toString()); diff --git a/oracle/src/pages.h b/oracle/src/pages.h index 221f032d3..0530010ba 100644 --- a/oracle/src/pages.h +++ b/oracle/src/pages.h @@ -31,7 +31,6 @@ public: private: QStringList findQmFiles(); - QString languageName(const QString &lang); private: QLabel *label, *languageLabel, *versionLabel; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e2c3252ee..62b735a50 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -406,6 +406,7 @@ if(TARGET Qt6::lrelease) libcockatrice_utility DEFINITIONS TRANSLATION_LOADER_TEST_DIR="${CMAKE_CURRENT_BINARY_DIR}" + COCKATRICE_TRANSLATIONS_DIR="${CMAKE_SOURCE_DIR}/cockatrice/translations" ) add_dependencies(translation_loader_test translation_loader_test_qm) diff --git a/tests/translation_loader_test.cpp b/tests/translation_loader_test.cpp index 745cd04bc..7a59e1b9b 100644 --- a/tests/translation_loader_test.cpp +++ b/tests/translation_loader_test.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #include #include @@ -19,6 +20,11 @@ #define TRANSLATION_LOADER_TEST_DIR "" #endif +// The client's translation files, whose language codes are the languages the language selector offers. +#ifndef COCKATRICE_TRANSLATIONS_DIR +#define COCKATRICE_TRANSLATIONS_DIR "" +#endif + namespace { const QString testContext = QStringLiteral("TranslationLoaderTest"); @@ -233,6 +239,49 @@ TEST(TranslationLoaderTest, AvailableLanguagesListsTheTranslationsThatCanBeLoade EXPECT_TRUE(TranslationLoader::availableLanguages(QStringLiteral("translation_loader_test"), {}).isEmpty()); } +TEST(TranslationLoaderTest, LanguageDisplayNamesDoNotNeedATranslationFile) +{ + // The regression this guards: the selector took every label from the language's own .qm file, so it + // went blank as soon as translators had not translated the "English" placeholder in the i18n context. + EXPECT_EQ(QStringLiteral("Deutsch (German)"), TranslationLoader::languageDisplayName(QStringLiteral("de"))); + EXPECT_EQ(QStringLiteral("简体中文 (Chinese Simplified)"), + TranslationLoader::languageDisplayName(QStringLiteral("zh-Hans"))); + EXPECT_EQ(QStringLiteral("English, arr! (Pirate English)"), + TranslationLoader::languageDisplayName(QStringLiteral("en@pirate"))); +} + +TEST(TranslationLoaderTest, EveryLanguageTheClientShipsHasItsOwnDisplayName) +{ + // The shipped translation files are what the selector offers, so each of them has to end up with a + // label: an empty one is what made the dropdown look broken, and two equal ones are a coin flip. + const QDir translations(QStringLiteral(COCKATRICE_TRANSLATIONS_DIR)); + const QStringList files = translations.entryList({QStringLiteral("cockatrice_*.ts")}, QDir::Files); + ASSERT_FALSE(files.isEmpty()); + + QStringList languages; + QSet displayNames; + for (const QString &file : files) { + const QString language = QFileInfo(file).completeBaseName().section(QLatin1Char('_'), 1); + const QString displayName = TranslationLoader::languageDisplayName(language); + EXPECT_FALSE(displayName.isEmpty()) << language.toStdString(); + languages << language; + displayNames.insert(displayName); + } + + EXPECT_EQ(files.size(), displayNames.size()); + + // The selector preselects this one for an unset setting, so it has to be offered at all. + EXPECT_TRUE(languages.contains(TranslationLoader::defaultLanguageCode())); +} + +TEST(TranslationLoaderTest, UnknownLanguagesFallBackToTheNativeNameThenTheCode) +{ + // A language added after the table, and a code Qt does not know either. + EXPECT_FALSE(TranslationLoader::languageDisplayName(QStringLiteral("sv_FI")).isEmpty()); + EXPECT_EQ(QStringLiteral("zz_QQ"), TranslationLoader::languageDisplayName(QStringLiteral("zz_QQ"))); + EXPECT_TRUE(TranslationLoader::languageDisplayName(QString()).isEmpty()); +} + TEST(TranslationLoaderTest, LoadApplicationReportsAMissingTranslationDirectory) { QTemporaryDir emptyDir; From 4793c1fc044e07768beb6e50d569d52559d8c3ff Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 09:45:52 +0100 Subject: [PATCH 036/108] [Installer] Offer to close blocking processes and retry purge on upgrade (#7664) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Installer] Offer to close blocking processes and retry purge on upgrade * [Installer] Retry the purge after closing only our own applications The first version of this fix closed every Restart Manager holder through RmShutdown, forcing what refused to close. RmShutdown also stops services, and the only API that brings them back is RmRestart, which would restart Cockatrice itself into the files that are about to be replaced; the forced pass also contradicts the running-instance guard, which never force-closes Cockatrice because it may be holding an unsaved deck. The guard ran first anyway, so for the case the fix exists for - a Cockatrice restarted during the update - the probe never got to run: the guard waited 60 s and aborted. Keep the prompt and the retry, but ask only Cockatrice, Oracle and Servatrice to close, with WM_CLOSE and nothing else, and leave every other holder named by the probe to the user. Repeat the prompt while the user keeps saying Yes, refresh the names after a failed retry, and keep the /R abort message pointing at the downloaded installer. Also clear the read-only attribute before every purge attempt. Delete and RMDir leave such a file in place, so the retry could never make progress, and the diagnostics branch that named the cause had no action behind it. The holder probe goes back to being diagnostic-only, so its Restart Manager code and the --close argument are dropped. * [Installer] Make an accepted close of the running client actually land WM_CLOSE is a request, and Cockatrice answers it: while a card database update is running, a game is open or a deck is unsaved it puts up its own "are you sure?" confirmation. That dialog opened behind the installer's prompt, so the user saw nothing happen; the next Yes was posted to a main window the app's own modal dialog had disabled, so it was ignored as well, and the identical yes/no prompt came back with no hint of why. - Activate the matching window before posting WM_CLOSE, so the app's own confirmation is in front where it can be answered. - Wait up to 30 s for cockatrice.exe to exit instead of purging after a fixed 2 s, so the purge measures the app gone rather than its dialog. - On the second and later rounds ask with Retry/Cancel and say what is needed, instead of repeating the same offer that just did nothing. - Stop promising Oracle and Servatrice: they are console applications, so CloseMainWindow() has nothing to send WM_CLOSE to. They are not force-stopped either - Oracle writes the card database in place. * [Installer] Close the client from the holder probe, not PowerShell Accepting the offer to close did nothing on a real machine: the instance stayed up, never came forward, and the same prompt came back. The prompt names its holders from the Restart Manager probe, but the close itself went through powershell.exe: Get-Process -Name, a strict "path starts with $INSTDIR" filter, then Process.CloseMainWindow() and a WScript.Shell AppActivate. Every step of that is silent. A process whose path cannot be read is filtered out, a process .NET finds no main window for makes CloseMainWindow() return false without posting anything, and AppActivate is refused for a background caller - so an attempted close, a missed close and a close request the client ignored all looked the same in the log: nothing. Ask the probe instead. installer-holder-probe --close-holders closes exactly the processes Restart Manager just named as holding a file of the version being replaced, so no executable path has to be guessed and a third party application that merely shares the image name is never in the set. Per instance it restores and brings the top-level window forward (AttachThreadInput, so the client's own "really quit?" confirmation is on screen rather than behind the installer), posts WM_CLOSE to the main window, and waits up to 30 s for the process to exit. Its report names every holder with what happened to it, including the ones left to the user, and goes into the installer log as one line. That also removes the separate PowerShell polling loop that used to wait for the close: the probe holds a handle on the process it asked, so the purge that follows measures a settled directory. Oracle and Servatrice are still never closed, and nothing is force-stopped. --------- Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> Co-authored-by: Lukas Brübach --- cmake/NSIS.template.in | 244 ++++++++++++++++-- tools/installer-holder-probe/CMakeLists.txt | 2 +- .../installer-holder-probe/close_holders.cpp | 212 +++++++++++++++ tools/installer-holder-probe/close_holders.h | 46 ++++ tools/installer-holder-probe/main.cpp | 45 +++- 5 files changed, 524 insertions(+), 25 deletions(-) create mode 100644 tools/installer-holder-probe/close_holders.cpp create mode 100644 tools/installer-holder-probe/close_holders.h diff --git a/cmake/NSIS.template.in b/cmake/NSIS.template.in index 12185adda..6ce765a35 100644 --- a/cmake/NSIS.template.in +++ b/cmake/NSIS.template.in @@ -38,6 +38,9 @@ Var AbortReason Var HolderExit Var HolderOutput Var HolderFound +; OfferToCloseAndRetryPurge counts the accepted close-and-retry rounds, so the +; prompt can stop repeating an offer that provably did not help (see there). +Var HolderCloseRounds Var WaitPid Var LauncherExited Var InstallLogPath @@ -766,8 +769,16 @@ Function CloseMatchingApps ; or one owned by another account running as SYSTEM) as a match would make ; the installer close a third party application, and it runs elevated, so ; CloseMainWindow() would succeed on it. + ; + ; The window is activated first, because WM_CLOSE is answered, not obeyed: + ; Cockatrice replies with a confirmation of its own while a card database + ; update is running, a game is open or a deck is unsaved. Without the + ; activation that dialog opens behind the installer's own prompt, where it + ; looks like the close did nothing and the second WM_CLOSE is delivered to a + ; main window its own modal dialog has disabled, which is ignored. Failing to + ; activate is harmless and swallowed: the close request still goes out. System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")' - nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { try { $$p = $$_.Path; if ($$p) { $$p.StartsWith($$env:COCKATRICE_GUARD_DIR, [StringComparison]::OrdinalIgnoreCase) } else { $$false } } catch { $$false } } | ForEach-Object { $$null = $$_.CloseMainWindow() }"' + nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { try { $$p = $$_.Path; if ($$p) { $$p.StartsWith($$env:COCKATRICE_GUARD_DIR, [StringComparison]::OrdinalIgnoreCase) } else { $$false } } catch { $$false } } | ForEach-Object { try { $$null = (New-Object -ComObject WScript.Shell).AppActivate($$_.Id) } catch { }; $$null = $$_.CloseMainWindow() }"' Pop $R3 FunctionEnd @@ -1185,15 +1196,74 @@ Function CheckStaleRuntimeMask ${EndIf} FunctionEnd +; Clears the read-only attribute of everything the purge is about to delete. +; +; Delete and RMDir only set the error flag: a read-only file is left on disk +; untouched, and the retry loop then cannot make progress however long it waits, +; because the file is not held - it is protected. The abort diagnostics have a +; branch for exactly that ("read-only, which stops Delete"), so a survivor that +; is both free and read-only was a dead end until now. This runs on every +; attempt, so an attribute set again between attempts is cleared as well. +; SetFileAttributes has no wildcard support, hence the walks. +Function ClearReadOnlyPurgeTargets + FindFirst $StaleFindHandle $StaleFindName "$INSTDIR\*.dll" + ${If} $StaleFindHandle != "" + crp_dll_loop: + SetFileAttributes "$INSTDIR\$StaleFindName" NORMAL + ClearErrors + FindNext $StaleFindHandle $StaleFindName + IfErrors 0 crp_dll_loop + FindClose $StaleFindHandle + ${EndIf} + StrCpy $R6 "$INSTDIR\Plugins" + Call ClearReadOnlyTree +FunctionEnd + +; usage: set $R6 to a directory. Clears the read-only attribute of every entry +; below it, so RMDir /r is not stopped by a single file. Saves and restores the +; registers it works in, because it recurses into itself. +Function ClearReadOnlyTree + Push $R6 + Push $R7 + Push $R8 + FindFirst $R7 $R8 "$R6\*.*" + ${If} $R7 != "" + crt_loop: + ${If} $R8 != "." + ${AndIf} $R8 != ".." + SetFileAttributes "$R6\$R8" NORMAL + ; Only an entry with contents has anything below it that the purge + ; would fail on; an empty directory's own attribute is already + ; cleared above and there is nothing left to descend into. + ${If} ${FileExists} "$R6\$R8\*.*" + Push $R6 + StrCpy $R6 "$R6\$R8" + Call ClearReadOnlyTree + Pop $R6 + ${EndIf} + ${EndIf} + ClearErrors + FindNext $R7 $R8 + IfErrors 0 crt_loop + FindClose $R7 + ${EndIf} + Pop $R8 + Pop $R7 + Pop $R6 +FunctionEnd + ; Removes the Plugins directory and all top-level DLLs from $INSTDIR, then ; retries up to two more times (2 s apart) when files remain locked, so that ; transient holders such as antivirus scanners have a chance to release their -; handles before the installer gives up. Three attempts total. +; handles before the installer gives up. Three attempts total. The read-only +; attribute is cleared before each attempt: a protected file is not a held one +; and would never clear on its own. ; Sets $R0 to 0 when the purge is clean, or to 1 when stale DLLs remain ; after all attempts. Function RetryDllPurge StrCpy $R5 0 ; counts retries after the initial attempt rdp_loop: + Call ClearReadOnlyPurgeTargets RMDir /r "$INSTDIR\Plugins" Delete "$INSTDIR\*.dll" Call HasStaleRuntimeFiles @@ -1253,12 +1323,14 @@ FunctionEnd ; ; The probe opens each survivor for DELETE access and then closes it again ; without deleting anything. The code is read by the plugin right after that call -; (?e), so a failure reports what Windows refused; clearing LastError first is -; what makes a zero code mean "this open worked", i.e. the purge-time failure was -; transient (an antivirus scan that has finished, an instance still shutting -; down) rather than a standing condition. The plugin parses the call's arguments -; between the clear and the call, so a non-zero code is reported as what Windows -; said rather than as proof of a cause - the wording below never claims one. +; (?e), so a failure reports what Windows refused; clearing LastError first makes +; a zero code mean "the open worked". That is all it means: Windows lets a DLL a +; running process has mapped be opened for DELETE, and DeleteFile still refuses +; it until the image is unloaded, so a zero code is not evidence that the +; failure was transient - the holders line is what names a running process. The +; plugin parses the call's arguments between the clear and the call, so a +; non-zero code is reported as what Windows said rather than as proof of a +; cause - the wording below never claims one. Function LogStaleRuntimeDiagnostics FindFirst $StaleFindHandle $StaleFindName "$INSTDIR\*.dll" ${If} $StaleFindHandle != "" @@ -1275,11 +1347,12 @@ Function LogStaleRuntimeDiagnostics ${Else} Pop $R2 ${If} $R2 = 0 - ; A valid handle: close it. The open proves the file is not held - ; open, but not that Delete would succeed: a read-only file opens - ; for DELETE and still fails DeleteFile until the attribute is - ; cleared, which the purge does not do. Report that standing - ; cause instead of calling the failure transient. + ; A valid handle: close it. The open says nothing about Delete: + ; a DLL a running process has mapped opens for DELETE and still + ; refuses DeleteFile, and a read-only file does the same until its + ; attribute is cleared (the purge clears it before every attempt + ; now, so a survivor that is read-only had it set again). Report + ; that standing cause instead of calling the failure transient. System::Call 'kernel32::CloseHandle(p R1)' System::Call 'kernel32::GetFileAttributesW(w "$INSTDIR\$StaleFindName") i.R3' ${If} $R3 = -1 @@ -1287,9 +1360,9 @@ Function LogStaleRuntimeDiagnostics ${Else} IntOp $R3 $R3 & 1 ${If} $R3 = 1 - StrCpy $R0 "a delete-access open now succeeds, but it is still read-only, which Delete does not clear" + StrCpy $R0 "a delete-access open now succeeds, but it is read-only, which stops Delete" ${Else} - StrCpy $R0 "a delete-access open now succeeds" + StrCpy $R0 "a delete-access open now succeeds, but a DLL a running process has mapped still refuses Delete - see holders" ${EndIf} ${EndIf} ${Else} @@ -1360,6 +1433,125 @@ Function LogProcessesUsingInstallDir !insertmacro CockLog "holders: $HolderOutput" FunctionEnd +; Called after a purge left files behind and LogStaleRuntimeDiagnostics named who +; is holding them. Instead of aborting straight away - which on /R strands the +; user, since the old version is already uninstalled - ask, close what this +; installer owns, purge again, and retry while the user keeps asking. Sets +; $StaleRuntimeAfterPurge to the last purge's verdict, so the caller still aborts +; when the files survive. +; +; Cockatrice is asked to close (WM_CLOSE, the same request as clicking the +; window's close button) and keeps the last word: it may be waiting for an answer +; to its own "really quit?" confirmation (a running card database update, an open +; game, an unsaved deck), and that stays its decision, exactly as the +; running-instance guard treats it. The instance is brought forward first, so that +; confirmation is on screen rather than behind this installer's prompt. Oracle and +; Servatrice have no window to send a close to at all, and nothing here is +; force-stopped: Oracle writes the card database in place and killing it mid-write +; would corrupt it. Everything the probe named that this installer cannot close - +; those two included - is left to the user, whom the prompt names it to. +; +; Restart Manager's RmShutdown was the alternative and was rejected: it shuts down +; services as well as applications, and the only way to bring them back is +; RmRestart, which would restart this very application into files that are about +; to be replaced. +; +; No offer when nobody is at the keyboard (a /S install without /R, e.g. a package +; manager): a dialog there would block forever. /R is the in-app updater, whose +; client has already quit, so the user is there - the same reasoning AbortInstall +; uses for its message box. No offer either when the probe named nobody: there is +; nothing to close, and the abort's per-file diagnostics already record what +; Windows said. +; +; The first round offers the close; every later one stops offering it and says +; what to do instead. Repeating the same question is what made an accepted close +; look like a Yes that was ignored: when Cockatrice was blocked on its own +; confirmation, the second Yes was delivered to a window that dialog had disabled, +; so nothing closed, the holders were unchanged, and the identical prompt came +; back with no hint of what had happened. +Function OfferToCloseAndRetryPurge + ${If} $ReinstallMode = 0 + ${AndIf} ${Silent} + !insertmacro CockLog "close-holders: unattended install, not offering to close holders" + Return + ${EndIf} + ${If} $HolderFound != 1 + !insertmacro CockLog "close-holders: no holder was named, nothing to offer" + Return + ${EndIf} + StrCpy $HolderCloseRounds 0 + ocrp_prompt: + ${If} $HolderCloseRounds = 0 + MessageBox MB_YESNO|MB_ICONEXCLAMATION|MB_DEFBUTTON2 \ + "Files of the previous version of Cockatrice are still in use by:$\r$\n$\r$\n$HolderOutput$\r$\n$\r$\nThe installer can close Cockatrice for you. It may first ask you to confirm quitting - it does while a card database update is running, a game is open or a deck is unsaved - so answer that window. Close Oracle, Servatrice and any other program listed above by hand.$\r$\n$\r$\nChoose Yes to close Cockatrice and try again, or No to cancel the installation." \ + IDYES ocrp_close + !insertmacro CockLog "close-holders: declined by user" + Return + ${EndIf} + MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ + "Files of the previous version of Cockatrice are still in use by:$\r$\n$\r$\n$HolderOutput$\r$\n$\r$\nCockatrice may be waiting for an answer in its own window - it asks before quitting while a card database update is running, a game is open or a deck is unsaved. Switch to it and confirm, then close Oracle, Servatrice and any other program listed above.$\r$\n$\r$\nChoose Retry once they are closed, or Cancel to cancel the installation." \ + IDRETRY ocrp_close + !insertmacro CockLog "close-holders: declined by user after $HolderCloseRounds round(s)" + Return + ocrp_close: + IntOp $HolderCloseRounds $HolderCloseRounds + 1 + !insertmacro CockLog "close-holders: accepted (round $HolderCloseRounds), holders were [$HolderOutput]" + ; The close is a request, not a fact: AskCockatriceAppsToClose waits for the + ; instances it asked to exit, so a confirmation of Cockatrice's that is still up is + ; reported as still running instead of being measured as a successful close. + Call AskCockatriceAppsToClose + Call RetryDllPurge + StrCpy $StaleRuntimeAfterPurge $R0 + !insertmacro CockLogListing "after-close-purge" "*.dll" + !insertmacro CockLogListing "after-close-plugins" "Plugins\*.*" + !insertmacro CockLog "close-holders: stale-runtime after retry=$StaleRuntimeAfterPurge" + ${If} $StaleRuntimeAfterPurge = 0 + DetailPrint "Files of the previous version were removed; continuing the installation." + Return + ${EndIf} + ; Still stuck: refresh the names, so the next prompt and the log describe what + ; survived the close and not what was there before it. + Call LogStaleRuntimeDiagnostics + ${If} $HolderFound != 1 + Return + ${EndIf} + Goto ocrp_prompt +FunctionEnd + +; Asks the running Cockatrice to close, and waits for it to exit. +; +; The set is the Restart Manager answer from LogProcessesUsingInstallDir: exactly the +; processes holding a file of the version being replaced. tools\installer-holder-probe +; closes that set by pid, which is why nothing here has to match an executable path or +; depend on powershell.exe. Only cockatrice.exe is asked: Oracle writes the card database +; in place and Servatrice has no window, so both stay the user's job, as the prompt says. +; +; Each instance is brought to the foreground before WM_CLOSE is posted to its main +; window. WM_CLOSE is answered, not obeyed - Cockatrice asks before it quits while a card +; database update is running, a game is open or a deck is unsaved - and that question is +; only useful if it is in front of the installer instead of behind it. +; +; The probe waits up to 30 s for the processes it asked to exit, so the purge that follows +; measures a settled directory rather than sleeping for a fixed time. Its line goes into +; the log as it stands: it names every holder, including the ones left to the user, so a +; close that did nothing can be told apart from one that was never sent. +Function AskCockatriceAppsToClose + InitPluginsDir + ; LogProcessesUsingInstallDir extracted it already; the check keeps a single copy of + ; the executable in the installer and still works if that order ever changes. + IfFileExists "$PLUGINSDIR\installer-holder-probe.exe" ck_close_probe_ready + File "/oname=$PLUGINSDIR\installer-holder-probe.exe" "..\..\..\installer-holder-probe.exe" + ck_close_probe_ready: + System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$INSTDIR")' + ; The probe waits 30 s itself. The timeout here only bounds a probe that never + ; returns; the Restart Manager call inside it is the same one the holder listing makes. + nsExec::ExecToStack /TIMEOUT=40000 '"$PLUGINSDIR\installer-holder-probe.exe" --close-holders' + Pop $R3 + Pop $R4 + !insertmacro CockLog "close-holders: probe exit=$R3 [$R4]" + DetailPrint "Closing Cockatrice: $R4" +FunctionEnd + ; Uninstaller copies of the same routines (the uninstaller gets its own ; function set compiled in, it cannot call the installer functions). Function un.IsAppRunning @@ -1786,13 +1978,21 @@ ${If} $IsUpgrade = 1 ${Else} StrCpy $StaleRuntimeAfterPurge 0 ${EndIf} +${If} $StaleRuntimeAfterPurge = 1 + !insertmacro CockLog "section: files of a previous version survived the purge" + ; Who is holding the payload is recorded first, so the log that reaches us + ; answers the question the message can only guess at, and so the prompt can + ; name them and offer to close the ones this installer owns. + Call LogStaleRuntimeDiagnostics + ; On /R the previous version is already uninstalled at this point, so an abort + ; leaves the user with no working Cockatrice. Offer to close and purge again, + ; repeating while the user keeps saying Yes; this updates + ; $StaleRuntimeAfterPurge with the last retry's verdict. + Call OfferToCloseAndRetryPurge +${EndIf} ${If} $StaleRuntimeAfterPurge = 1 !insertmacro CockLog "section: aborting, files of a previous version survived the purge" DetailPrint "Files of a previous version could not be removed; aborting the installation." - ; Who is holding the payload is recorded before the abort, so the log that - ; reaches us answers the question the message can only guess at, and so the - ; message can name the processes to close whenever any were found. - Call LogStaleRuntimeDiagnostics ; The in-app updater runs this installer silent and detached and never looks ; at its exit code, so the user has to be told what happened. A /S install ; without /R is unattended (a package manager, for instance) and a message @@ -1803,6 +2003,12 @@ ${If} $StaleRuntimeAfterPurge = 1 ${If} $HolderFound = 1 StrCpy $AbortReason "$AbortReason$\r$\n$\r$\nStill using the install directory:$\r$\n$HolderOutput" ${EndIf} + ; "Run the installer again" is not enough on an in-app update: the old version + ; was uninstalled before the purge, so point at the installer that was downloaded. + ${If} $ReinstallMode = 1 + ${AndIf} $PortableMode = 0 + StrCpy $AbortReason "$AbortReason$\r$\n$\r$\nThe previous version has already been removed. To finish the update, run this installer again:$\r$\n$EXEPATH" + ${EndIf} Call AbortInstall ${EndIf} diff --git a/tools/installer-holder-probe/CMakeLists.txt b/tools/installer-holder-probe/CMakeLists.txt index 3098669cc..ab3bb3eff 100644 --- a/tools/installer-holder-probe/CMakeLists.txt +++ b/tools/installer-holder-probe/CMakeLists.txt @@ -4,7 +4,7 @@ # the installer and is extracted to $PLUGINSDIR there, never installed, so it links # nothing from this project: no Qt, and a static C runtime because the installed runtime # is exactly what the purge may have failed to replace. -add_executable(installer-holder-probe holder_line.cpp main.cpp restart_manager_holders.cpp) +add_executable(installer-holder-probe close_holders.cpp holder_line.cpp main.cpp restart_manager_holders.cpp) target_link_libraries(installer-holder-probe PRIVATE rstrtmgr) diff --git a/tools/installer-holder-probe/close_holders.cpp b/tools/installer-holder-probe/close_holders.cpp new file mode 100644 index 000000000..508d9b3c1 --- /dev/null +++ b/tools/installer-holder-probe/close_holders.cpp @@ -0,0 +1,212 @@ +#include "close_holders.h" + +// clang-format off +// windows.h has to come before anything that pulls in a Windows type, as everywhere in +// this tool. +#include +// clang-format on + +#include +#include +#include +#include + +namespace +{ +constexpr wchar_t COCKATRICE_IMAGE[] = L"cockatrice.exe"; + +/// How long one poll of the asked processes takes. One WaitForSingleObject per process +/// and no enumeration, so polling costs nothing next to the seconds this is called on. +constexpr unsigned long WAIT_POLL_MILLISECONDS = 200; + +/// How long the report line may get before it is cut. NSIS holds one string in 1024 +/// characters, and this one is written there verbatim; every holder is named, so normally +/// the line stays far below it. +constexpr std::size_t REPORT_CHAR_MAX = 900; + +bool isCockatriceImage(const std::wstring &imageName) +{ + return CompareStringOrdinal(imageName.c_str(), -1, COCKATRICE_IMAGE, -1, TRUE) == CSTR_EQUAL; +} + +/// The holder as the log has to name it to be actionable. Deliberately not +/// formatHolder(): that one is worded for the dialog, where ", running" says something +/// the line here continues after. +std::wstring processLabel(const Holder &holder) +{ + std::wstring label = holder.imageName.empty() ? holder.appName : holder.imageName; + if (label.empty()) { + label = L"an unnamed process"; + } + label += L" (pid "; + label += std::to_wstring(holder.processId); + label += L")"; + return label; +} + +struct WindowSearch +{ + DWORD processId = 0; + HWND topmost = nullptr; ///< first visible window in Z-order: a dialog when one is up + HWND main = nullptr; ///< first visible window without an owner: the one to close + HWND any = nullptr; ///< first window at all, used when nothing is visible +}; + +BOOL CALLBACK findProcessWindow(HWND window, LPARAM parameter) +{ + auto &search = *reinterpret_cast(parameter); + DWORD processId = 0; + GetWindowThreadProcessId(window, &processId); + if (processId != search.processId) { + return TRUE; + } + if (search.any == nullptr) { + search.any = window; + } + // A tray icon leaves the main window hidden, so a window that is not visible is still + // kept as a fallback. EnumWindows walks the Z-order top down, so the first visible + // window is the one the user would see in front. + if (IsWindowVisible(window) != FALSE) { + if (search.topmost == nullptr) { + search.topmost = window; + } + if (search.main == nullptr && GetWindow(window, GW_OWNER) == nullptr) { + search.main = window; + } + } + return TRUE; +} + +/// Restores a minimized window and takes the foreground, so a confirmation Cockatrice +/// answers the close with is in front of the installer instead of behind it. +/// +/// A process in the background may not take the foreground on its own. Attaching to the +/// thread that owns the foreground window lifts that restriction for the duration of the +/// call; detaching afterwards leaves the two input queues as they were. +bool bringForward(HWND window) +{ + if (IsIconic(window) != FALSE) { + ShowWindow(window, SW_RESTORE); + } else { + ShowWindow(window, SW_SHOW); + } + + const HWND foreground = GetForegroundWindow(); + const DWORD foregroundThread = foreground != nullptr ? GetWindowThreadProcessId(foreground, nullptr) : 0; + const DWORD currentThread = GetCurrentThreadId(); + const bool attached = foregroundThread != 0 && foregroundThread != currentThread && + AttachThreadInput(currentThread, foregroundThread, TRUE) != FALSE; + BringWindowToTop(window); + SetForegroundWindow(window); + if (attached) { + AttachThreadInput(currentThread, foregroundThread, FALSE); + } + return GetForegroundWindow() == window; +} + +/// Waits for @p processes to signal, up to @p waitMilliseconds. Returns whether they all +/// exited and how long the wait took. +bool waitForExit(const std::vector &processes, unsigned long waitMilliseconds, unsigned long long &elapsed) +{ + const ULONGLONG start = GetTickCount64(); + const ULONGLONG deadline = start + waitMilliseconds; + for (;;) { + bool allExited = true; + for (const HANDLE process : processes) { + if (WaitForSingleObject(process, 0) != WAIT_OBJECT_0) { + allExited = false; + break; + } + } + elapsed = GetTickCount64() - start; + if (allExited || GetTickCount64() >= deadline) { + return allExited; + } + Sleep(WAIT_POLL_MILLISECONDS); + } +} +} // namespace + +CloseHoldersResult closeCockatriceHolders(const std::vector &holders, unsigned long waitMilliseconds) +{ + CloseHoldersResult result; + std::vector lines; + std::vector asked; + std::vector askedLines; + + for (const Holder &holder : holders) { + if (holder.isService || !isCockatriceImage(holder.imageName)) { + // Oracle and Servatrice are named by the prompt and close by hand: Oracle + // writes the card database in place. A service has no window to ask either. + lines.push_back(processLabel(holder) + L" left to you"); + continue; + } + + WindowSearch search; + search.processId = holder.processId; + EnumWindows(findProcessWindow, reinterpret_cast(&search)); + if (search.any == nullptr) { + lines.push_back(processLabel(holder) + L" has no window to close, left to you"); + continue; + } + + const HWND target = + search.main != nullptr ? search.main : (search.topmost != nullptr ? search.topmost : search.any); + const HWND toRaise = search.topmost != nullptr ? search.topmost : target; + std::wstring line = processLabel(holder); + line += bringForward(toRaise) ? L" brought forward" : L" could not be brought forward"; + + // Kept open across the wait so a pid reused after the process exits cannot be + // mistaken for the instance that was asked to close. + const HANDLE process = OpenProcess(SYNCHRONIZE, FALSE, holder.processId); + const DWORD openError = process == nullptr ? GetLastError() : ERROR_SUCCESS; + if (PostMessageW(target, WM_CLOSE, 0, 0) == FALSE) { + // Read the error before CloseHandle, which is free to reset it. + const DWORD postError = GetLastError(); + if (process != nullptr) { + CloseHandle(process); + } + lines.push_back(line + L", and the close request could not be sent (win32 " + std::to_wstring(postError) + + L")"); + continue; + } + line += L" and asked to close"; + ++result.askedToClose; + if (process == nullptr) { + line += L", but its exit could not be watched (win32 " + std::to_wstring(openError) + L")"; + } else { + askedLines.push_back(lines.size()); + asked.push_back(process); + } + lines.push_back(std::move(line)); + } + + if (!asked.empty() && waitMilliseconds > 0) { + unsigned long long elapsed = 0; + const bool allExited = waitForExit(asked, waitMilliseconds, elapsed); + for (std::size_t index = 0; index < asked.size(); ++index) { + const bool exited = allExited || WaitForSingleObject(asked[index], 0) == WAIT_OBJECT_0; + lines[askedLines[index]] += exited ? L", exited after " + std::to_wstring(elapsed) + L" ms" + : L", still running after " + std::to_wstring(elapsed) + L" ms"; + } + } + for (const HANDLE process : asked) { + CloseHandle(process); + } + + if (lines.empty()) { + result.report = L"nothing is holding the files"; + return result; + } + for (const std::wstring &line : lines) { + if (!result.report.empty()) { + result.report += L"; "; + } + result.report += line; + } + if (result.report.size() > REPORT_CHAR_MAX) { + result.report.erase(REPORT_CHAR_MAX); + result.report += L"..."; + } + return result; +} diff --git a/tools/installer-holder-probe/close_holders.h b/tools/installer-holder-probe/close_holders.h new file mode 100644 index 000000000..427da5ea6 --- /dev/null +++ b/tools/installer-holder-probe/close_holders.h @@ -0,0 +1,46 @@ +#ifndef CLOSE_HOLDERS_H +#define CLOSE_HOLDERS_H + +#include "holder_line.h" + +#include +#include + +/** + * @brief What asking the holders to close did, in the form the installer logs. + * + * @c report is one line naming every holder, including the ones this deliberately + * leaves alone, because the installer repeats it in its log and in the second prompt. + */ +struct CloseHoldersResult +{ + unsigned int askedToClose = 0; ///< holders a WM_CLOSE was posted to + std::wstring report; ///< one line, narrowed to ASCII when written +}; + +/** + * @brief Brings every running Cockatrice holding an installer file forward and asks it + * to close. + * + * The set is the Restart Manager answer, so it is exactly the processes holding a file of + * the previous version: nothing here has to guess at an executable path, and an + * application that merely shares the image name is never touched. Only cockatrice.exe is + * asked to close. Oracle and Servatrice are left to the user - Oracle writes the card + * database in place - and so is every service. + * + * Two things happen per instance. Its top-level window is restored and brought to the + * foreground, because Cockatrice may answer the close with a confirmation of its own that + * would otherwise open behind the installer's prompt, and then WM_CLOSE is posted to the + * main window, the same request as clicking its close button. Stopping the process is + * never attempted. + * + * Once the requests are out, this waits up to @c waitMilliseconds for the processes to + * exit, so the caller can measure the purge immediately afterwards instead of sleeping + * for a fixed time or re-asking a question this call already answered. + * + * @param holders the Restart Manager answer, one entry per application or service + * @param waitMilliseconds how long to wait for the asked instances to exit, 0 to not wait + */ +CloseHoldersResult closeCockatriceHolders(const std::vector &holders, unsigned long waitMilliseconds); + +#endif diff --git a/tools/installer-holder-probe/main.cpp b/tools/installer-holder-probe/main.cpp index 3b5bb5d22..269d22821 100644 --- a/tools/installer-holder-probe/main.cpp +++ b/tools/installer-holder-probe/main.cpp @@ -1,3 +1,4 @@ +#include "close_holders.h" #include "holder_line.h" #include "restart_manager_holders.h" @@ -12,8 +13,16 @@ namespace /// trailing backslash then never has to survive NSIS quoting or a command line. constexpr wchar_t INSTALL_DIR_VARIABLE[] = L"COCKATRICE_GUARD_DIR"; +/// How long the close mode waits for the instance it asked to exit before it reports the +/// process as still running. The installer measures the purge right after this call, so +/// the wait belongs here: it is the only place that holds a handle on the process whose +/// exit it is waiting for. +constexpr unsigned long CLOSE_WAIT_MILLISECONDS = 30000; + /// Exit codes the installer reads. "Something was found" is deliberately not 0, so an /// answer of "nothing holds the files" stays distinguishable from a probe that failed. +/// In the close mode 1 means a close was actually sent and 0 that every holder was left +/// to the user, which is what the report line next to the code spells out. enum ExitCode { EXIT_NO_HOLDERS = 0, @@ -22,6 +31,20 @@ enum ExitCode EXIT_PROBE_FAILED = 3, }; +/// The probe has two modes, selected by the first argument: +/// +/// (none) name what holds the install directory, one line, for the abort +/// dialog and the log +/// --close-holders bring the running Cockatrice forward, ask it to close, and wait for +/// it to exit, for the offer to close after a failed purge +/// +/// Both answer from the same Restart Manager query, so the close can only ever reach a +/// process that is holding a file of the version being replaced. +bool closeModeRequested(int argumentCount, char **arguments) +{ + return argumentCount > 1 && std::string(arguments[1]) == "--close-holders"; +} + /// Writes one line to the pipe nsExec reads. Narrowed to ASCII on purpose: the bytes /// come back to the installer as an ANSI string, so a non-ASCII process name would be /// mojibake in the log; a '?' at least stays honest. Control characters are dropped @@ -54,7 +77,7 @@ std::wstring installDirFromEnvironment() } } // namespace -int main() +int main(int argumentCount, char **arguments) { const std::wstring installDir = installDirFromEnvironment(); if (installDir.empty()) { @@ -68,11 +91,23 @@ int main() writeLine(L"the Restart Manager lookup failed with win32 error " + std::to_wstring(error)); return EXIT_PROBE_FAILED; } + + const bool closeMode = closeModeRequested(argumentCount, arguments); if (holders.empty()) { - // No output: the installer words the "nothing is holding them" case, and it - // owns the message either way. + // No output in the naming mode: the installer words the "nothing is holding them" + // case, and it owns the message either way. The close mode says it itself, because + // its line is logged verbatim next to what it was asked to do. + if (closeMode) { + writeLine(L"nothing is holding the files"); + } return EXIT_NO_HOLDERS; } - writeLine(formatHolders(holders)); - return EXIT_HOLDERS_FOUND; + if (!closeMode) { + writeLine(formatHolders(holders)); + return EXIT_HOLDERS_FOUND; + } + + const CloseHoldersResult result = closeCockatriceHolders(holders, CLOSE_WAIT_MILLISECONDS); + writeLine(result.report); + return result.askedToClose > 0 ? EXIT_HOLDERS_FOUND : EXIT_NO_HOLDERS; } From d42b9607c54348f6ade066ef881470b282a67618 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:25:10 +0100 Subject: [PATCH 037/108] [Installer] Check for running instances with the Restart Manager probe (#7671) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Installer] Check for running instances with the Restart Manager probe The running-instance guard was built on powershell.exe started from the 32-bit NSIS stub, which is the 32-bit Windows PowerShell: its Get-Process cannot read the path of a 64-bit process, so a running 64-bit Cockatrice was reported as not running, the update went ahead, and the purge then failed on the DLLs that instance still had mapped. Use tools/installer-holder-probe instead - the same Restart Manager lookup the abort path already had - before anything is replaced and in the uninstaller as well. It is bitness-agnostic, so the Sysnative and WMI fallbacks are gone. The guard looks only at the payload, asks Cockatrice to close once by itself (WM_CLOSE, the same request as the window's close button) and then offers close again / retry after closing by hand / cancel for whatever the close cannot reach - Oracle writes the card database in place, Servatrice has no window - without force-stopping anything. A failed purge offers the same close-and-retry, tries one last purge when nobody is named, and every dialog shows in every mode: a silent reinstall stays silent until something goes wrong. * [Installer] Name processes running from the install directory The running-instance guard answers from the Restart Manager, which reports who has a file open. A running instance does not have to have one open: its own image and the modules it loaded are mapped into it rather than opened by it, and a mapping is not reliably reported. That is the state the probe was added for - every DLL still on disk, an instance running from the directory, and "nothing is holding them any more" as the answer. A guard built on file handles alone is blind to exactly that instance, so it passes, the purge fails, the holder lookup after it names nobody either, and the install ends in a warning that no running program was found: no close is attempted and no retry is offered. A manual install with another instance open never asks it to close at all. Name every process whose image lives in the directory being replaced as well, with the same bitness-safe QueryFullProcessImageNameW the probe already uses to label its holders. The image lookup is listed first - it is the instance the user is asked to close - and the Restart Manager entries follow with the pids already named dropped, so the close still reaches everything that holds a file. A Restart Manager call that fails only makes the probe unable to answer when the image lookup named nobody either. Ask instead of dead-ending when the probe cannot answer at all: Retry checks again, Ignore carries on, Abort cancels, in the installer and the uninstaller alike, and the abort dialog says the check failed instead of claiming no running program was found. The Windows CI probe check covers the new lookup with a process started from the directory that holds no file open. * [Installer] Offer to wait again when the launcher has not exited A client that is still closing after 60 s is usually not hung: it is showing its own "quit anyway?" confirmation for an unsaved deck, an open game or a card database update, and the user is the one who has to answer it. The installer answered that with a plain warning and stopped, so the only way forward was to close Cockatrice by hand and run the update again - which on an in-app update means finding the downloaded installer. Ask instead: Retry brings the client to the front, asks it to close again (WM_CLOSE, nothing forced) and keeps waiting on its handle, Cancel stops as before. There is no "install anyway" answer because a live process has its image and Qt DLLs mapped, so writing the payload over them is the broken install this wait exists to prevent - and the guard that runs right after names the same process by its image path and stops there, so ignoring would only move the same question a moment later. * [Installer] Ask about a launcher that has not exited after ten seconds A minute of silence is a long time to sit in front of an installer that has no window yet, and the case it is waiting for is not a slow flush: a client that started an update exits in well under a second, so a minute means it is almost always showing its own "quit anyway?" confirmation with nobody looking at it. Wait ten seconds for the first question instead, and the full grace for the ones after it. The normal path is untouched - WaitForSingleObject returns as soon as the handle signals, so a client that exits in 200 ms still costs 200 ms - and answering the first question once does not turn into a stream of prompts while the user is in Cockatrice's window. --------- Co-authored-by: Lukas Brübach --- .ci/compile.sh | 22 +- cmake/NSIS.template.in | 1207 +++++++---------- tools/installer-holder-probe/CMakeLists.txt | 13 +- tools/installer-holder-probe/main.cpp | 40 +- .../restart_manager_holders.cpp | 106 +- .../restart_manager_holders.h | 41 +- 6 files changed, 721 insertions(+), 708 deletions(-) diff --git a/.ci/compile.sh b/.ci/compile.sh index d60683b6e..e85e2b5ba 100755 --- a/.ci/compile.sh +++ b/.ci/compile.sh @@ -515,7 +515,27 @@ try { New-Item -ItemType Directory -Force $empty | Out-Null $nothing = Invoke-Probe $empty if ($nothing.Code -ne 0) { throw "expected exit 0 for a directory with nothing open, got $($nothing.Code) : $($nothing.Output)" } - Write-Host "holder lookup named pid $PID for a locked file and named nothing for an empty directory" + # A process running from the directory is a holder even when it has no file open: an + # instance holds its payload as mapped images, not as handles, and that is the state + # the guard used to miss - it passed, the purge failed, and the install ended in a + # warning with no offer to close and try again. The image is a copy of a native + # Windows program, so it starts without its usual directory next to it. + $copy = Join-Path $dir "fake-cockatrice.exe" + Copy-Item -LiteralPath (Join-Path $env:SystemRoot "System32\cmd.exe") -Destination $copy + $running = Start-Process -FilePath $copy -ArgumentList "/c", "ping -n 20 127.0.0.1 >nul" -PassThru + try { + Start-Sleep -Milliseconds 1500 + $byImage = Invoke-Probe $dir + } finally { + try { $running.Kill() } catch { } + # Wait for the image to be released, or the cleanup below leaves the copy behind. + try { $running.WaitForExit(5000) | Out-Null } catch { } + } + if ($byImage.Code -ne 1) { throw "expected exit 1 for a process running from the directory, got $($byImage.Code) : $($byImage.Output)" } + if ($byImage.Output -notmatch "fake-cockatrice\.exe \(pid\s+$($running.Id)") { + throw "the lookup did not name the process running from the directory: $($byImage.Output)" + } + Write-Host "holder lookup named pid $PID for a locked file, named nothing for an empty directory, and named pid $($running.Id) for a process running from the directory" } finally { Remove-Item -Recurse -Force $dir -ErrorAction SilentlyContinue } diff --git a/cmake/NSIS.template.in b/cmake/NSIS.template.in index 6ce765a35..d54111a84 100644 --- a/cmake/NSIS.template.in +++ b/cmake/NSIS.template.in @@ -12,7 +12,6 @@ Var NormalDestDir Var PortableDestDir Var PortableMode Var ReinstallMode -Var AllowForceClose Var GuardDir Var OldUninstallString Var OldInstallDir @@ -38,11 +37,19 @@ Var AbortReason Var HolderExit Var HolderOutput Var HolderFound -; OfferToCloseAndRetryPurge counts the accepted close-and-retry rounds, so the -; prompt can stop repeating an offer that provably did not help (see there). -Var HolderCloseRounds +; What the last RunHolderProbe call answered: its exit code and its line. Kept +; apart from $HolderExit/$HolderOutput because the guard only wants to know +; whether the payload is free, while the abort dialog reports what survived. +Var GuardExit +Var GuardOutput +; Whether the guard already asked Cockatrice to close once (see GuardAppsNotRunning) +Var GuardCloseTried Var WaitPid Var LauncherExited +; How long one WaitForSingleObject on the launcher's handle waits before asking the user +; (see WaitForLauncherToExit). A variable because the first slice is short and the ones +; after the first question are not. +Var WaitSlice Var InstallLogPath Var LogHandle Var LogMessage @@ -107,7 +114,10 @@ Page Custom PortableModePageCreate PortableModePageLeave Call CockLogWrite !macroend -Function CockLogWrite +; The body is shared because the uninstaller needs its own copy: un.* functions are +; compiled into a separate binary, and inside one a Call may only name an un. function, +; so CockLog's Call to CockLogWrite cannot be used there. +!macro CockLogWriteBody Pop $LogMessage FileOpen $LogHandle "$InstallLogPath" a ${If} $LogHandle == "" @@ -119,6 +129,20 @@ Function CockLogWrite FileSeek $LogHandle 0 END FileWrite $LogHandle "$LogMessage$\r$\n" FileClose $LogHandle +!macroend + +Function CockLogWrite + !insertmacro CockLogWriteBody +FunctionEnd + +; CockLog for un.* code: same lines, prefixed call target. +!macro CockLogUn text + Push "${text}" + Call un.CockLogWrite +!macroend + +Function un.CockLogWrite + !insertmacro CockLogWriteBody FunctionEnd ; Logs every file matching $INSTDIR\. Used to record the DLL inventory @@ -231,10 +255,10 @@ ${EndIf} ; /PID= carries the process id of the application that started this installer. ; The in-app updater launches the installer and only then quits itself, so ; without it the installer has to guess whether the launching process is still -; alive by polling powershell.exe for an image name - a guess that can come back -; "not running" or "could not determine" while the process is very much alive and -; still has its Qt DLLs mapped. An older client does not pass /PID= and an older -; installer ignores it, so both sides of the pair have to keep working. +; alive from the files it still has open - a guess that can come back "nothing +; is holding them" while the client is very much alive and still has its Qt DLLs +; mapped. An older client does not pass /PID= and an older installer ignores it, +; so both sides of the pair have to keep working. ; Note that /D= must stay the last parameter on the command line, which is why ; the client puts it last and this one anywhere. StrCpy $WaitPid "" @@ -272,18 +296,16 @@ ${Else} ${EndIf} ; Wait for the process that launched this installer to exit before anything is -; replaced or deleted. It goes first because it is the one check here that needs -; no powershell.exe: on a machine where the process lookup cannot run at all, the -; launcher is still proven gone here instead of the whole upgrade being cancelled -; by a check that was only ever a proxy for this one. +; replaced or deleted. It goes first because a real handle on that process is +; authority: the holder probe that follows asks the same question indirectly, by +; looking at which files are still open. ; ; Then make sure no *other* application instance is still running (and holding -; file locks) before the previous version is uninstalled. On a silent update -; (/R /S) the running processes are asked to close gracefully and waited for; -; Cockatrice itself is never force-killed (data loss, e.g. an unsaved deck) so the -; update is aborted instead, while the console applications Oracle and Servatrice -; are force-stopped by PID once the grace period is over. On interactive installs -; the user is prompted to close them instead. +; file locks) before the previous version is uninstalled. The probe names them +; and the user is asked to close them; nothing is ever force-stopped, because +; Cockatrice may hold an unsaved deck and Oracle writes the card database in +; place. On /R the old version is about to be removed, so an answer the probe +; cannot give cancels the upgrade before it starts rather than after it. ; Only needed this early for /R, where AutoUninstallIfNeeded runs below; for ; interactive installs the section re-checks after the directory page, when ; $INSTDIR is the directory the user actually picked. @@ -307,6 +329,14 @@ ${If} ${NSIS_IS_64_BIT} == 1 SetRegView 64 ${EndIf} +; The uninstaller logs to its own file: the installer truncates its log on every run, so +; sharing one would mean either side erasing the other's account of a failed upgrade. +StrCpy $InstallLogPath "$TEMP\Cockatrice-uninstaller.log" +FileOpen $LogHandle "$InstallLogPath" w +FileClose $LogHandle +!insertmacro CockLogUn "==================================================================" +!insertmacro CockLogUn "uninstaller start: @CPACK_PACKAGE_NAME@ @CPACK_PACKAGE_VERSION@ dir=$INSTDIR" + ; Ensure no application instance is still running before removing files. Call un.EnsureAppsNotRunning @@ -685,230 +715,196 @@ FunctionEnd ; "The procedure entry point X could not be located in the dynamic link ; library ...Qt6Network.dll" on the next start. ; -; On a silent update (/R /S) matching processes are asked to close gracefully -; (WM_CLOSE) and waited for up to 60 s. Cockatrice is never force-stopped: it -; may be holding an unsaved deck or showing a "really leave this game?" -; confirmation, so if it refuses to close the install is aborted - nothing has -; been replaced yet, so the update is simply deferred. Oracle and Servatrice -; are console applications without a main window, so CloseMainWindow() does -; nothing for them and waiting cannot help; they hold no unsaved user data and -; are force-stopped by PID once the grace period is over. On interactive -; installs and uninstalls the user is prompted to close them instead. +; Both questions this installer has about running processes - "may I replace +; the payload?" and "what is holding this file I could not delete?" - are +; answered by tools/installer-holder-probe, a small executable built from this +; repository and embedded in the installer and the uninstaller alike. It names +; every process and service that has one of the payload files open or mapped +; (the Restart Manager, RmRegisterResources/RmGetList) and, separately, every +; process whose image lives in the directory being replaced. ; -; Processes are matched by image name AND by their executable path living -; under the guarded directory ($GuardDir, normally $INSTDIR), so unrelated -; processes that merely share an image name (e.g. the Oracle DB instance -; "oracle.exe") are never touched. - -; usage: set $R2 to the base image name (without extension, e.g. "cockatrice", -; as accepted by Get-Process -Name), call this, result in $R0 -; (1 = running from $GuardDir, 0 = not running, 2 = could not determine) -Function IsAppRunning - ; Every caller has to set $GuardDir; an empty one would make the path check - ; below match every process that merely shares the image name, which is the - ; opposite of what this guard is for. - ${If} $GuardDir == "" - StrCpy $R0 2 - Return - ${EndIf} - ; Pass the directory to guard to PowerShell via an environment variable so - ; it never has to be embedded into the -Command string: apostrophes in a - ; path would terminate the command early, and the path in a -like pattern - ; would treat [ and ] as wildcard characters. - System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")' - ; Exit the process explicitly so nsExec's pop reflects whether a matching - ; process was found: powershell.exe exits 0 whether or not the pipeline - ; produced objects. 0 = not running, 1 = running, anything else = could not - ; be determined (see IsProcessLookupAvailable). - ; A process is only a match when its path is readable and lives under - ; $GuardDir. A process we cannot inspect is not one of ours: the updater is - ; always started by the application it updates, so it runs in the same user - ; session, and an interactive install is elevated and can read the paths of - ; the user's own processes. Assuming a match instead would be a hard stop - ; rather than a fail-closed guard - an Oracle database instance running as - ; SYSTEM has a process named "oracle" whose path raises Access denied, and - ; that would make every silent update abort and every interactive install - ; loop on the Retry prompt with no way out but Cancel. - nsExec::ExecToLog 'powershell -NoProfile -Command "if (Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { try { $$p = $$_.Path; if ($$p) { $$p.StartsWith($$env:COCKATRICE_GUARD_DIR, [StringComparison]::OrdinalIgnoreCase) } else { $$false } } catch { $$false } } | Select-Object -First 1) { exit 1 } else { exit 0 }"' - Pop $R0 - ${If} $R0 == "0" - StrCpy $R0 0 - ${ElseIf} $R0 == "1" - StrCpy $R0 1 - ${Else} - ; powershell.exe could not be run at all (nsExec pushes the string - ; "error"), or died with an unexpected code. That is "could not - ; determine", which has to stay distinguishable from "definitely - ; running": a state that can never clear would block the installer - ; forever. IsProcessLookupAvailable warns about that case once. - StrCpy $R0 2 - ${EndIf} -FunctionEnd - -; Sets $R0 to 1 if PowerShell can be started at all, 0 if it cannot. The -; running instance check is built on top of it, so a machine where -; powershell.exe is missing, blocked by policy or refused by antivirus is -; reported once instead of being reported as "Cockatrice is running" on every -; single check. -Function IsProcessLookupAvailable - nsExec::ExecToLog 'powershell -NoProfile -Command "exit 0"' - Pop $R0 - ${If} $R0 == "0" - StrCpy $R0 1 - ${Else} - StrCpy $R0 0 - ${EndIf} -FunctionEnd - -Function CloseMatchingApps - ; gracefully ask every matching instance to close (sends WM_CLOSE) - ; - ; The filter here is deliberately the strict one: a process is only asked to - ; close when its path is readable and lives under $GuardDir. Treating a - ; process whose path cannot be read (an unrelated oracle.exe / servatrice.exe, - ; or one owned by another account running as SYSTEM) as a match would make - ; the installer close a third party application, and it runs elevated, so - ; CloseMainWindow() would succeed on it. - ; - ; The window is activated first, because WM_CLOSE is answered, not obeyed: - ; Cockatrice replies with a confirmation of its own while a card database - ; update is running, a game is open or a deck is unsaved. Without the - ; activation that dialog opens behind the installer's own prompt, where it - ; looks like the close did nothing and the second WM_CLOSE is delivered to a - ; main window its own modal dialog has disabled, which is ignored. Failing to - ; activate is harmless and swallowed: the close request still goes out. - System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")' - nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { try { $$p = $$_.Path; if ($$p) { $$p.StartsWith($$env:COCKATRICE_GUARD_DIR, [StringComparison]::OrdinalIgnoreCase) } else { $$false } } catch { $$false } } | ForEach-Object { try { $$null = (New-Object -ComObject WScript.Shell).AppActivate($$_.Id) } catch { }; $$null = $$_.CloseMainWindow() }"' - Pop $R3 -FunctionEnd - -Function ForceCloseApps - ; stop the matching instances by PID once the grace period is over. Only - ; used for the console applications ($AllowForceClose), which have no window - ; to close and hold no unsaved user data. Same strict filter as above. - System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")' - nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { try { $$p = $$_.Path; if ($$p) { $$p.StartsWith($$env:COCKATRICE_GUARD_DIR, [StringComparison]::OrdinalIgnoreCase) } else { $$false } } catch { $$false } } | ForEach-Object { Stop-Process -Id $$_.Id -Force -ErrorAction SilentlyContinue }"' - Pop $R3 -FunctionEnd - -Function WaitForAppToClose - ; usage: set $R1 to the display name (e.g. "cockatrice.exe") and $R2 to the - ; base image name (e.g. "cockatrice") - Call IsAppRunning - !insertmacro CockLog "guard $R1: state=$R0 (dir=$GuardDir)" - ; Only a definite "running" is worth waiting for. "Not running" is done, and - ; "could not determine" must not keep the installer waiting on an answer it - ; will never get - but it cannot simply be waved through either, see - ; HandleUnknownAppState. - ${If} $R0 = 0 - Return - ${EndIf} - ${If} $R0 = 2 - Call HandleUnknownAppState - Return - ${EndIf} - - ${If} ${Silent} - ; ask the application to close gracefully (WM_CLOSE), then wait for it to exit - DetailPrint "Closing $R1 ..." - Call CloseMatchingApps - ; wall-clock deadline: each poll has to spawn a fresh powershell.exe, so - ; a bare loop counter would not bound the real wait time to 60 s. - System::Call 'kernel32::GetTickCount()i.s' - Pop $R8 - ck_wait_loop: - Sleep 500 - Call IsAppRunning - ${If} $R0 = 0 - !insertmacro CockLog "guard $R1: closed gracefully" - DetailPrint "$R1 closed." - Return - ${EndIf} - ${If} $R0 = 2 - Call HandleUnknownAppState - Return - ${EndIf} - System::Call 'kernel32::GetTickCount()i.s' - Pop $R9 - IntOp $R9 $R9 - $R8 - ${If} $R9 < 60000 - Goto ck_wait_loop - ${EndIf} - ; The grace period is over. Cockatrice is never force-stopped: it may be - ; showing a "really leave this game?" confirmation or holding an unsaved - ; deck, and nothing has been replaced yet, so aborting just defers the - ; update and preserves the user's data. - ${If} $AllowForceClose = 0 - !insertmacro CockLog "guard $R1: still running after 60s, aborting (no force close)" - DetailPrint "$R1 is still running after 60 s; aborting the update so that no data is lost." - StrCpy $AbortReason \ - "$R1 did not close within 60 seconds, so the installation was aborted to avoid losing unsaved data.$\r$\n$\r$\nPlease close $R1 and run the installer again." - Call AbortInstall - ${EndIf} - ; Oracle and Servatrice are console applications: CloseMainWindow() - ; returned false for them and waiting longer cannot help. They hold no - ; unsaved user data, so stop them by PID. - !insertmacro CockLog "guard $R1: still running after 60s, force-stopping" - DetailPrint "Force-stopping $R1 ..." - Call ForceCloseApps - Sleep 2000 - Call IsAppRunning - ${If} $R0 = 0 - !insertmacro CockLog "guard $R1: force-stopped" - DetailPrint "$R1 closed." - Return - ${EndIf} - ${If} $R0 = 2 - Call HandleUnknownAppState - Return - ${EndIf} - !insertmacro CockLog "guard $R1: could not be stopped, aborting" - DetailPrint "$R1 is still running after 60 s; aborting the update." - StrCpy $AbortReason \ - "$R1 could not be stopped, so the installation was aborted.$\r$\n$\r$\nPlease close $R1 and run the installer again." - Call AbortInstall - ${Else} - ck_wait_prompt: - MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ - "$R1 is still running.$\r$\n$\r$\nPlease close it, then click Retry.$\r$\nClick Cancel to abort." \ - IDCANCEL ck_abort_install - Call IsAppRunning - ${If} $R0 = 0 - Return - ${EndIf} - ${If} $R0 = 2 - Call HandleUnknownAppState - Return - ${EndIf} - Goto ck_wait_prompt - ck_abort_install: - Abort - ${EndIf} -FunctionEnd - -; A process lookup came back "could not determine": powershell.exe ran but the -; answer was not usable, e.g. the process could not be inspected at all. That -; state is fine to carry on from when the user is at the keyboard - they can -; close the applications by hand and the stale-runtime check still stands -; between a failed guard and a mismatched install. +; The second lookup is not redundant. Restart Manager answers "who has this +; file open", and a running instance does not have to have one open: its own +; image and the modules it loaded are mapped into it rather than opened by it, +; and a mapped image is not reliably reported. That is the state the failed +; updates this probe exists for were in - every DLL still on disk, an instance +; running from the directory, and "nothing is holding them any more" as the +; answer. A guard built on file handles alone therefore misses exactly the +; instance it exists to find; it would pass, the purge would fail, and the +; install would end in a warning with no offer to close and try again. ; -; It is not fine on a silent upgrade. There the whole point of this guard is to -; know that nothing holds locks on the files about to be replaced, and this is -; exactly the state where that is unknown, with no one to click a dialog: the -; old code returned and let the install carry on into the mixed Qt DLL set that -; made the next start fail. Cancel the update instead; the next attempt runs -; with the applications closed and succeeds. -Function HandleUnknownAppState - ${If} $ReinstallMode = 1 +; Why not enumerate processes from this script: the NSIS stub is a 32-bit +; process, so a bare "powershell" started from it is the 32-bit Windows +; PowerShell, whose Get-Process cannot read the path of a 64-bit process. A +; guard built on that misses exactly the running instance it exists to find. +; The probe opens each process with PROCESS_QUERY_LIMITED_INFORMATION and calls +; QueryFullProcessImageNameW, which has no bitness mismatch, and Restart +; Manager is a system API rather than a view of this process's architecture. +; +; The files that count are the payload the purge removes and the installer +; replaces: the three executables, the top-level DLLs, qt.conf and everything +; below Plugins. A document, a log or a screenshot the user happens to have +; open in the install directory is not part of it and cannot block an upgrade; +; the image lookup matches on the directory a process runs from, so it does not +; widen that set. +; +; Nothing is force-stopped anywhere: Cockatrice may be holding an unsaved deck +; or showing a "really leave this game?" confirmation, Oracle writes the card +; database in place and killing it mid-write would corrupt it, and Servatrice +; has no window to ask. A close is a request (WM_CLOSE), made automatically once +; because that is what the user started the installer to do, and again whenever +; they ask for it from the prompt; when it is not answered the user is told which +; program is in the way instead of having it killed. + +; Emitted once for the installer and once for the uninstaller: the uninstaller +; cannot call installer functions, and its own copy of the probe keeps a running +; 64-bit instance from being missed there too. +!macro CockHolderProbeFunctions prefix tag +Function ${prefix}ExtractHolderProbe + InitPluginsDir + IfFileExists "$PLUGINSDIR\installer-holder-probe.exe" ck_probe_ready_${tag} + File "/oname=$PLUGINSDIR\installer-holder-probe.exe" "..\..\..\installer-holder-probe.exe" + ck_probe_ready_${tag}: +FunctionEnd + +; usage: set $GuardDir to the directory to check, then call this. Sets +; $GuardExit to the probe's exit code and $GuardOutput to its one-line answer. +; 0 = nothing holds the payload and nothing runs from the directory, 1 = the +; line names what does, anything else = the probe could not answer (2 = no +; directory was given, 3 = the Restart Manager call failed and no process runs +; from the directory either, "timeout" = it did not return in time). +Function ${prefix}RunHolderProbe + Call ${prefix}ExtractHolderProbe + ; The directory travels in an environment variable, never on the command + ; line: a path with spaces, quotes or a trailing backslash then does not + ; have to survive NSIS quoting. + System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")' + ; /TIMEOUT because this can run before the first page: a Restart Manager + ; that does not answer must not leave the installer with no window and no + ; way out. + nsExec::ExecToStack /TIMEOUT=20000 '"$PLUGINSDIR\installer-holder-probe.exe"' + Pop $GuardExit + Pop $GuardOutput +FunctionEnd + +; usage: set $GuardDir to the directory being replaced, then call this. Asks the running +; Cockatrice to close, and waits for it to exit. +; +; The set is what the probe names: the processes holding a file of the version being +; replaced, plus the processes running from the directory. tools\installer-holder-probe +; closes that set by pid, which is why nothing here has to match an executable path. Only +; cockatrice.exe is asked: Oracle writes the card database in place and Servatrice has no +; window, so both stay the user's job, as the prompt says. +; +; Each instance is brought to the foreground before WM_CLOSE is posted to its main window. +; WM_CLOSE is answered, not obeyed - Cockatrice asks before it quits while a card database +; update is running, a game is open or a deck is unsaved - and that question is only useful +; if it is in front of the installer instead of behind it. +; +; The probe waits up to 30 s for the processes it asked to exit, so the check that follows +; measures a settled directory rather than sleeping for a fixed time. Its line goes into the +; log as it stands: it names every holder, including the ones left to the user, so a close +; that did nothing can be told apart from one that was never sent. +; +; Never a force-close: the application decides whether to quit, and one that does not answer +; is left to the user instead of being killed. +Function ${prefix}CloseHolders + Call ${prefix}ExtractHolderProbe + System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")' + ; The probe waits 30 s itself. The timeout here only bounds a probe that never returns; + ; the Restart Manager call inside it is the same one the holder listing makes. + nsExec::ExecToStack /TIMEOUT=40000 '"$PLUGINSDIR\installer-holder-probe.exe" --close-holders' + Pop $R3 + Pop $R4 + ; Not the CockLog macro: this function exists twice and the uninstaller copy may only + ; Call un. functions, so the macro's two lines are spelled with the prefix here. + Push "close-holders: probe exit=$R3 [$R4]" + Call ${prefix}CockLogWrite + DetailPrint "Closing Cockatrice: $R4" +FunctionEnd +!macroend + +!insertmacro CockHolderProbeFunctions "" install +!insertmacro CockHolderProbeFunctions "un." uninstall + +; usage: set $GuardDir to the directory to guard, then call this. +; +; Asks Cockatrice to close once, then prompts until the payload is free. The automatic +; close is the same request clicking the window's close button makes, so the common case - +; the client was still shutting down - never turns into a question. The prompt is what +; remains when the close cannot reach everything: Oracle writes the card database in +; place, Servatrice has no window, and Cockatrice may still be waiting for an answer in +; its own confirmation. It offers one more close (Yes), a plain re-check for after the +; user closed them by hand (No), and Cancel. +; +; An error is always shown, in every mode. A /S install without /R has nobody at +; the keyboard, but this installer does not promise unattended runs: when an +; error needs a decision, asking for one is better than silently leaving a +; half-updated install behind. The happy path has no dialog, so a /S reinstall +; that has nothing to report stays silent. +Function GuardAppsNotRunning + !insertmacro CockLog "guard: checking $GuardDir (launcherExited=$LauncherExited)" + StrCpy $GuardCloseTried 0 + guard_check: + Call RunHolderProbe + ${If} $GuardExit == "0" + !insertmacro CockLog "guard: nothing is holding the payload" + DetailPrint "No running Cockatrice, Oracle or Servatrice instance was found." + Return + ${EndIf} + ${If} $GuardExit == "1" + !insertmacro CockLog "guard: still in use by $GuardOutput" + ; Close it for the user first. The probe knows exactly which cockatrice.exe holds + ; these files, and asking it to close is the same request as clicking the window's + ; close button - the application still answers its own "really quit?" prompt, so + ; nothing is lost. One attempt per guard call keeps the common case (the client was + ; still shutting down) from turning into a question, without hiding the case where + ; the close did not help: the pass after it names what is still holding the files. + ${If} $GuardCloseTried = 0 + StrCpy $GuardCloseTried 1 + !insertmacro CockLog "guard: asking cockatrice to close, then checking again" + Call CloseHolders + Goto guard_check + ${EndIf} + DetailPrint "The install directory is in use by: $GuardOutput" + ; Three answers, and the close is one of them: the automatic attempt above ran + ; before this prompt and could not have answered Cockatrice's own confirmation, so a + ; second close is often exactly what is needed once the user has dealt with that + ; window. NSIS does not let a MessageBox relabel its buttons, so the text says what + ; Yes, No and Cancel do. + MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ + "Cockatrice, Oracle or Servatrice is still using files of the installation:$\r$\n$\r$\n$GuardOutput$\r$\n$\r$\nChoose Yes to close Cockatrice and check again. It may ask you to confirm quitting - it does while a card database update is running, a game is open or a deck is unsaved - so answer that window.$\r$\n$\r$\nClose Oracle, Servatrice and any other program listed above by hand. Choose No to check again after that, or Cancel to cancel the installation." \ + IDYES guard_close IDNO guard_check + !insertmacro CockLog "guard: cancelled by user" StrCpy $AbortReason \ - "The installation was cancelled because it could not determine whether Cockatrice is still running.$\r$\n$\r$\nPlease close Cockatrice, Oracle and Servatrice and run the installer again." + "The installation was cancelled because Cockatrice, Oracle or Servatrice is still running and holding files of the previous version.$\r$\n$\r$\nClose them and run the installer again." Call AbortInstall + ; Only reached through Yes: AbortInstall does not return. + guard_close: + !insertmacro CockLog "guard: closing cockatrice on request, then checking again" + Call CloseHolders + Goto guard_check ${EndIf} - DetailPrint "Could not determine whether $R1 is running; close Cockatrice, Oracle and Servatrice manually if the installation reports a problem." - ${IfNot} ${Silent} - MessageBox MB_OK|MB_ICONEXCLAMATION \ - "This installer could not determine whether $R1 is still running.$\r$\n$\r$\nPlease close Cockatrice, Oracle and Servatrice manually before continuing." - ${EndIf} + ; Not an answer at all: the probe was not configured, the Restart Manager + ; call failed and nothing runs from the directory, or it timed out. Carrying + ; on from that is a decision, so it is asked for rather than assumed - the + ; stale-runtime check after the purge still stands between a failed guard + ; and a mismatched install, but it only fires after files have been + ; replaced, and on an upgrade the launcher asked for nobody is watching the + ; installer's exit code. Retry asks the probe again (a Restart Manager that + ; was busy answers the second time), Ignore installs anyway, Abort cancels + ; instead of writing a payload next to files the guard could not rule out. + !insertmacro CockLog "guard: the holder probe could not answer ($GuardExit): $GuardOutput" + DetailPrint "Could not check whether Cockatrice is still running: $GuardOutput" + MessageBox MB_ABORTRETRYIGNORE|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ + "This installer could not check whether Cockatrice, Oracle or Servatrice is still running.$\r$\n$\r$\nThe check reported: $GuardOutput$\r$\n$\r$\nClose them, then choose Retry to check again. Choose Ignore to install anyway, or Abort to cancel the installation." \ + IDRETRY guard_check IDABORT guard_unknown_abort + !insertmacro CockLog "guard: continuing without an answer from the probe" + Return + guard_unknown_abort: + StrCpy $AbortReason \ + "The installation was cancelled because it could not check whether Cockatrice, Oracle or Servatrice is still running.$\r$\n$\r$\nThe check reported: $GuardOutput$\r$\n$\r$\nPlease close them and run the installer again." + Call AbortInstall FunctionEnd Function EnsureAppsNotRunning @@ -919,67 +915,55 @@ FunctionEnd ; Aborts the installation, showing $AbortReason to the user. ; -; Every abort goes through here so the unattended case is handled the same way -; everywhere: the in-app updater runs this installer silent and detached and -; never looks at its exit code, so a plain Abort would leave the user with no -; update and no explanation, while a /S install without /R is unattended - by a -; package manager, for instance - and a message box there would block forever. -; Only the first of those two gets a dialog; the second reports through the log -; and the exit code. +; Every abort goes through here so no failure ends silently: the in-app updater +; runs this installer silent and detached and never looks at its exit code, and +; a /S install without /R has nobody watching its exit code either, so a plain +; Abort would leave the user with no update and no explanation. An error always +; shows its dialog, in every mode; a silent run stays silent only for as long +; as nothing goes wrong. The log path is part of the dialog, so a report can be +; acted on without asking the user to find the file. Function AbortInstall !insertmacro CockLog "ABORT: $AbortReason" !insertmacro CockLog "full installer log: $InstallLogPath" - ; Show a dialog whenever someone is at the keyboard to see it: an interactive - ; install, or the in-app updater's /R upgrade. The /R upgrade is silent, but - ; the client that launched the installer has already quit, so the user is - ; still there. Only a /S install without /R is attended by a package manager - ; and must go straight to the exit code. - ${If} $ReinstallMode = 1 - ${OrIfNot} ${Silent} - MessageBox MB_OK|MB_ICONEXCLAMATION "$AbortReason$\r$\n$\r$\nA detailed installer log was written to:$\r$\n$InstallLogPath" - ; Offer to send the installer log to Sentry so aborted installs can be diagnosed. This block already runs - ; only when someone is at the keyboard (attended install, or the updater's /R upgrade whose launcher has - ; quit), so a silent or package-manager install never reaches the prompt and its behavior is unchanged. - ; Only a package built with a baked DSN offers to send, so the prompt can only appear when a send - ; can actually go out. A preprocessor guard rather than a runtime test: a package built without a - ; DSN compiles the block out entirely, so it cannot even carry the helper or its baked endpoint. - ; No /SD here: a /SD would suppress this prompt on the silent /R upgrade, which is exactly the - ; attended-update case this offer exists for. The runtime gate above (ReinstallMode/Silent) is the - ; hang protection — a package-manager /S install without /R never reaches this box, matching the - ; two sibling MessageBox sites in this file. - !if "@CPACK_SENTRY_ENABLED@" == "1" - MessageBox MB_YESNO|MB_ICONQUESTION "Send the installer log to the developers to help fix this? The log contains file paths from this machine, which may include your Windows user name." IDYES sentry_send - !insertmacro CockLog "sentry send: declined by user" - Goto sentry_done - sentry_send: - ; The abort reason is already in the log file (CockLog writes "ABORT: ..." as the - ; first thing AbortInstall does), so the helper reads it from there. Nothing - ; reason-shaped crosses the command line, which keeps quoting trivial and needs - ; no NSIS string functions. - InitPluginsDir - ; Embedded by the same relative path that resolves NSIS.definitions.nsh above (both are - ; configure_file'd into the build root, which is three levels up from project.nsi). An - ; absolute CMake path here carries forward slashes, which makensis does not treat as - ; absolute and so fails to find the file at package time. - File "/oname=$PLUGINSDIR\send-installer-log.ps1" "..\..\..\SendInstallerLog.ps1" - ; The send can take up to the /TIMEOUT below, so put something on the details pane - ; the moment the user says yes rather than leaving the window blank until it returns. - DetailPrint "Sending installer log..." - nsExec::ExecToStack /TIMEOUT=60000 'powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\send-installer-log.ps1" -LogPath "$InstallLogPath"' - Pop $SentryResult - Pop $SentryOutput - ; $SentryOutput is a single line by construction: the helper flattens its message - ; and this log file is uploaded verbatim, so a stray newline must not be able to - ; forge extra lines into the artifact we send. - !insertmacro CockLog "sentry send: result=$SentryResult output=$SentryOutput" - ${If} $SentryResult == "0" - MessageBox MB_OK|MB_ICONINFORMATION "The installer log was sent. Thank you for helping improve Cockatrice!" - ${Else} - MessageBox MB_OK|MB_ICONEXCLAMATION "The installer log could not be sent. You can still share it manually; it was written to:$\r$\n$InstallLogPath" - ${EndIf} - sentry_done: - !endif - ${EndIf} + MessageBox MB_OK|MB_ICONEXCLAMATION "$AbortReason$\r$\n$\r$\nA detailed installer log was written to:$\r$\n$InstallLogPath" + ; Offer to send the installer log to Sentry so aborted installs can be diagnosed. + ; Only a package built with a baked DSN offers to send, so the prompt can only appear + ; when a send can actually go out. A preprocessor guard rather than a runtime test: a + ; package built without a DSN compiles the block out entirely, so it cannot even carry + ; the helper or its baked endpoint. No /SD: the abort is exactly the case the offer + ; exists for, including on the silent /R upgrade. + !if "@CPACK_SENTRY_ENABLED@" == "1" + MessageBox MB_YESNO|MB_ICONQUESTION "Send the installer log to the developers to help fix this? The log contains file paths from this machine, which may include your Windows user name." IDYES sentry_send + !insertmacro CockLog "sentry send: declined by user" + Goto sentry_done + sentry_send: + ; The abort reason is already in the log file (CockLog writes "ABORT: ..." as the + ; first thing AbortInstall does), so the helper reads it from there. Nothing + ; reason-shaped crosses the command line, which keeps quoting trivial and needs + ; no NSIS string functions. + InitPluginsDir + ; Embedded by the same relative path that resolves NSIS.definitions.nsh above (both are + ; configure_file'd into the build root, which is three levels up from project.nsi). An + ; absolute CMake path here carries forward slashes, which makensis does not treat as + ; absolute and so fails to find the file at package time. + File "/oname=$PLUGINSDIR\send-installer-log.ps1" "..\..\..\SendInstallerLog.ps1" + ; The send can take up to the /TIMEOUT below, so put something on the details pane + ; the moment the user says yes rather than leaving the window blank until it returns. + DetailPrint "Sending installer log..." + nsExec::ExecToStack /TIMEOUT=60000 'powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\send-installer-log.ps1" -LogPath "$InstallLogPath"' + Pop $SentryResult + Pop $SentryOutput + ; $SentryOutput is a single line by construction: the helper flattens its message + ; and this log file is uploaded verbatim, so a stray newline must not be able to + ; forge extra lines into the artifact we send. + !insertmacro CockLog "sentry send: result=$SentryResult output=$SentryOutput" + ${If} $SentryResult == "0" + MessageBox MB_OK|MB_ICONINFORMATION "The installer log was sent. Thank you for helping improve Cockatrice!" + ${Else} + MessageBox MB_OK|MB_ICONEXCLAMATION "The installer log could not be sent. You can still share it manually; it was written to:$\r$\n$InstallLogPath" + ${EndIf} + sentry_done: + !endif SetErrorLevel 1 Abort FunctionEnd @@ -989,16 +973,15 @@ FunctionEnd ; ; This is the authority for the one question the rest of the installer can only ; approximate: is the process that started us still holding its Qt DLLs mapped? -; A mapped module cannot be deleted, so an answer of "not running" obtained by -; enumerating process names is what allows the update to write a new payload on +; A mapped module cannot be deleted, so an answer of "nothing is holding them" +; obtained from file handles is what allows the update to write a new payload on ; top of files it could not remove. Waiting on a real handle has no such gap, -; needs no powershell.exe, and is unaffected by a process whose path cannot be -; read - the case the name based check has to treat as "not ours" so it never -; closes a third party application. +; needs no process enumeration at all, and is unaffected by a process the holder +; probe cannot inspect. ; ; Sets $LauncherExited to 0 when no usable /PID= was given, which is the older ; client that does not pass one; the caller then has no authority available and -; falls back to the name based guard. +; falls back to the holder probe. Function WaitForLauncherToExit StrCpy $LauncherExited 0 !insertmacro CockLog "wait-launcher: pid='$WaitPid'" @@ -1014,6 +997,17 @@ Function WaitForLauncherToExit ; $RN. The caller reads $R0/$R1, so the uppercase .RN form is required; the ; original .r0/.r1 wrote $0/$1 and left $R1 empty, which made the "did not ; exit" test below compare "" against "0" and abort on every /PID= upgrade. + ; Reopened on every Retry below, so a launcher that exited in the meantime answers + ; "already exited" instead of being waited on again. + ; + ; Ten seconds, not the full grace: a client that started an update exits in well + ; under a second, so at ten seconds it is either flushing a card database or - far + ; more often - sitting on its own "quit anyway?" confirmation with nobody looking at + ; it. Silence for a minute is what makes that read as a hung installer. The full + ; grace is spent after the first question, so a user who answers Cockatrice's + ; confirmation is not asked again while they do it. + StrCpy $WaitSlice 10000 + wait_launcher_open: ClearErrors System::Call 'kernel32::OpenProcess(i 0x00100000, i 0, i $WaitPid) p.R0' ; OpenProcess returns a null handle for a process that is already gone. The @@ -1033,17 +1027,39 @@ Function WaitForLauncherToExit Return ${EndIf} DetailPrint "Waiting for the application that started this installer to exit ..." - ; Same 60 s grace period the name based check uses, so a client that is slow - ; to flush its card database does not get less room than it used to. ; WAIT_OBJECT_0 is 0; WAIT_TIMEOUT is 258 and a failed wait reports 0xFFFFFFFF. - System::Call 'kernel32::WaitForSingleObject(p R0, i 60000) i.R1' + System::Call 'kernel32::WaitForSingleObject(p R0, i $WaitSlice) i.R1' System::Call 'kernel32::CloseHandle(p R0)' ${If} $R1 != 0 - !insertmacro CockLog "wait-launcher: did not exit within 60s (wait=$R1)" - DetailPrint "The application that started this installer did not exit within 60 s." + !insertmacro CockLog "wait-launcher: did not exit within $WaitSlice ms (wait=$R1)" + DetailPrint "The application that started this installer did not exit in time." + ; Two answers, and "carry on anyway" is not one of them. A live process has its + ; image and its Qt DLLs mapped, so writing the payload over them is the broken + ; install this wait exists to prevent - and the guard that runs next names the + ; same process by its image path and stops there anyway, so an "ignore" would + ; only put the same question a moment later, after a close attempt nobody asked + ; for. Waiting again is what helps: the client is most likely showing its own + ; "quit anyway?" confirmation, and that window is the thing to answer. + MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ + "Cockatrice has not finished closing, so the installation is paused rather than replace files it is still using.$\r$\n$\r$\nIt may be waiting for you to answer its own question about an unsaved deck, an open game or a card database update. Answer that window - choose Retry to bring it to the front, ask it to close again and keep waiting. If it does not answer at all, close it from Task Manager first.$\r$\n$\r$\nChoose Retry to wait again, or Cancel to cancel the installation." \ + IDRETRY wait_launcher_retry + !insertmacro CockLog "wait-launcher: cancelled by user" StrCpy $AbortReason \ "The application that started this update has not exited yet, so the installation was cancelled rather than replace files it is still using.$\r$\n$\r$\nPlease close Cockatrice and run the installer again." Call AbortInstall + ; Only reached through Retry: AbortInstall does not return. + wait_launcher_retry: + ; The question has been asked and answered once; from here the wait is the full + ; grace again, so a slow flush or a confirmation being answered does not turn + ; into a stream of prompts. + StrCpy $WaitSlice 60000 + ; The same request the window's close button makes, and the same close the guard + ; would send a moment later. It brings the client forward as well, which is the + ; part that helps when the confirmation is behind something else. Nothing is + ; force-stopped: a client that is waiting for an answer keeps the last word. + StrCpy $GuardDir $INSTDIR + Call CloseHolders + Goto wait_launcher_open ${EndIf} !insertmacro CockLog "wait-launcher: launcher exited" DetailPrint "The application that started this installer has exited." @@ -1068,57 +1084,6 @@ Function EnsureOldInstallNotRunning Call GuardAppsNotRunning FunctionEnd -Function GuardAppsNotRunning - ; usage: set $GuardDir to the directory to guard, then call this - Call IsProcessLookupAvailable - !insertmacro CockLog "guard: powershell available=$R0 dir=$GuardDir launcherExited=$LauncherExited" - ${If} $R0 = 0 - ; Without PowerShell we cannot tell whether a running instance is - ; holding locks on the files we are about to replace. - !insertmacro CockLog "guard: powershell unavailable, skipping instance check" - DetailPrint "Could not start powershell.exe; skipping the running instance check. Close Cockatrice, Oracle and Servatrice manually if the installation reports a problem." - ; On a silent upgrade that used to be a warning and nothing more, which - ; left the very process that launched the installer free to go on - ; replacing files it still had mapped - the one outcome this guard exists - ; to prevent - and then writing the new payload on top of them. Cancel - ; instead. An interactive install can still continue: the user is at the - ; keyboard, and refusing outright would leave them with an installer that - ; can never complete. - ; - ; Unless the launcher has already been waited on directly: WaitForLauncherToExit - ; needs no powershell.exe, so when it has run, Cockatrice is known to be - ; gone and the lookup failing to run costs nothing but the Oracle and - ; Servatrice checks. An older client that passes no /PID= leaves no such - ; authority, and there the upgrade is cancelled as before. - ${If} $LauncherExited != 1 - ${AndIf} $ReinstallMode = 1 - StrCpy $AbortReason \ - "The installation was cancelled because it could not check whether Cockatrice is still running, because powershell.exe could not be started.$\r$\n$\r$\nPlease close Cockatrice, Oracle and Servatrice and run the installer again." - Call AbortInstall - ${EndIf} - ; The user has to be told to close the applications by hand, but a /S - ; install without /R is unattended and would block on this forever. - ${If} $ReinstallMode = 1 - ${OrIfNot} ${Silent} - MessageBox MB_OK|MB_ICONEXCLAMATION "This installer could not check whether Cockatrice is still running, because powershell.exe could not be started.$\r$\n$\r$\nPlease close Cockatrice, Oracle and Servatrice manually before continuing." - ${EndIf} - Return - ${EndIf} - StrCpy $R1 "cockatrice.exe" - StrCpy $R2 "cockatrice" - ; a GUI application with unsaved user data: graceful close only - StrCpy $AllowForceClose 0 - Call WaitForAppToClose - StrCpy $R1 "oracle.exe" - StrCpy $R2 "oracle" - StrCpy $AllowForceClose 1 - Call WaitForAppToClose - StrCpy $R1 "servatrice.exe" - StrCpy $R2 "servatrice" - StrCpy $AllowForceClose 1 - Call WaitForAppToClose -FunctionEnd - ; Sets $R0 to 1 if $INSTDIR looks like an existing Cockatrice install. Used to ; guard the stale-runtime-DLL purge so a first-time install into a pre-existing ; directory with an unrelated "Plugins" folder is never touched, while an @@ -1321,16 +1286,18 @@ FunctionEnd ; survived the purge" said which files were present but never which one was held, ; and never whether the cause was a lock or a permission. ; -; The probe opens each survivor for DELETE access and then closes it again -; without deleting anything. The code is read by the plugin right after that call -; (?e), so a failure reports what Windows refused; clearing LastError first makes -; a zero code mean "the open worked". That is all it means: Windows lets a DLL a -; running process has mapped be opened for DELETE, and DeleteFile still refuses -; it until the image is unloaded, so a zero code is not evidence that the -; failure was transient - the holders line is what names a running process. The -; plugin parses the call's arguments between the clear and the call, so a -; non-zero code is reported as what Windows said rather than as proof of a -; cause - the wording below never claims one. +; Each survivor is opened for DELETE access and then asked the question DeleteFile +; asks: SetFileInformationByHandle(FileDispositionInfo) marks it for deletion on +; close, and the mark is cleared again right away, so nothing is deleted (and if +; that cancel ever failed, the file would go when the handle closes - a file the +; purge already tried to delete, and the line says so). That separates the three +; causes a failed Delete cannot tell apart: another handle that does not share +; delete refuses the mark, a read-only attribute refuses it, and a file nobody +; holds accepts it and would have been deleted. The mark is read through ?e, so a +; refusal reports Windows' own code; clearing LastError first keeps a zero code +; meaning "accepted". The wording below reports what Windows said and never claims +; a cause the code cannot prove. Which process is holding a survivor, if any, is +; what the holders line after the loop answers. Function LogStaleRuntimeDiagnostics FindFirst $StaleFindHandle $StaleFindName "$INSTDIR\*.dll" ${If} $StaleFindHandle != "" @@ -1347,22 +1314,52 @@ Function LogStaleRuntimeDiagnostics ${Else} Pop $R2 ${If} $R2 = 0 - ; A valid handle: close it. The open says nothing about Delete: - ; a DLL a running process has mapped opens for DELETE and still - ; refuses DeleteFile, and a read-only file does the same until its - ; attribute is cleared (the purge clears it before every attempt - ; now, so a survivor that is read-only had it set again). Report - ; that standing cause instead of calling the failure transient. - System::Call 'kernel32::CloseHandle(p R1)' - System::Call 'kernel32::GetFileAttributesW(w "$INSTDIR\$StaleFindName") i.R3' - ${If} $R3 = -1 - StrCpy $R0 "a delete-access open now succeeds, but its attributes could not be read" + ; The open worked, which says nothing about Delete: a DLL a running + ; process has mapped opens for DELETE and still refuses DeleteFile, and + ; a read-only file does the same until its attribute is cleared. Ask + ; Windows the question the purge actually asks - mark the file for + ; deletion on close, then cancel that again. This is the same request + ; DeleteFile makes on a temporary handle, so its answer says whether a + ; delete would succeed now. + ClearErrors + System::Call '*(&i1 1) p.R4' + System::Call 'kernel32::SetFileInformationByHandle(p R1, i 4, p R4, i 1) i.R5 ?e' + ${If} ${Errors} + StrCpy $R0 "a delete-access open now succeeds, but Windows could not be asked whether it would delete it" ${Else} + Pop $R6 + ${If} $R5 != 0 + ; Accepted: cancel the mark again, so this stays a question and + ; does not turn into the deletion it is asking about. A cancel + ; that fails leaves the file to go when the handle closes, which + ; is what the purge wanted anyway; the line says so. + ClearErrors + System::Call '*(&i1 0) p.R4' + System::Call 'kernel32::SetFileInformationByHandle(p R1, i 4, p R4, i 1) i.R5 ?e' + ${If} ${Errors} + StrCpy $R0 "a delete would succeed now, but the check could not undo the mark it set" + ${Else} + Pop $R6 + ${If} $R5 != 0 + StrCpy $R0 "a delete would succeed now - the earlier failure was transient" + ${Else} + StrCpy $R0 "a delete would succeed now, and it is marked for deletion from here on" + ${EndIf} + ${EndIf} + ${Else} + StrCpy $0 $R6 + Call GetWin32ErrorText + StrCpy $R0 "a delete-access open now succeeds, but Windows refused to mark it for deletion: win32 error $R6: $R0" + ${EndIf} + ${EndIf} + System::Call 'kernel32::CloseHandle(p R1)' + ; The read-only attribute is a cause on its own, and the purge clears it + ; before every attempt, so a survivor that is read-only had it set again. + System::Call 'kernel32::GetFileAttributesW(w "$INSTDIR\$StaleFindName") i.R3' + ${If} $R3 != -1 IntOp $R3 $R3 & 1 ${If} $R3 = 1 - StrCpy $R0 "a delete-access open now succeeds, but it is read-only, which stops Delete" - ${Else} - StrCpy $R0 "a delete-access open now succeeds, but a DLL a running process has mapped still refuses Delete - see holders" + StrCpy $R0 "$R0; it is read-only again" ${EndIf} ${EndIf} ${Else} @@ -1380,45 +1377,34 @@ Function LogStaleRuntimeDiagnostics Call LogProcessesUsingInstallDir FunctionEnd -; Names the applications and services that still have the install directory's files -; open or mapped. Writes one line to the log and sets $HolderFound (1 when anything was -; named), so the abort message can repeat that line to the user. +; Names the applications and services that still have the payload open or mapped. +; Writes one line to the log and sets $HolderFound (1 when anything was named), so the +; abort message can repeat that line to the user. ; -; The lookup is a small executable built from tools/installer-holder-probe, extracted to -; $PLUGINSDIR only when the purge has already failed: the successful path pays nothing -; for it, and $PLUGINSDIR takes it away again when the installer exits. It is compiled by -; the same Windows build that compiles this installer, which is what lets it call the -; Restart Manager API directly. The same call made from this script would have to -; marshal RM_PROCESS_INFO through System::Call as a table of hand-computed offsets, and -; that table is precisely what cannot be checked without a Windows toolchain; in -; compiled code the layout belongs to the Windows SDK header. +; The lookup is tools/installer-holder-probe, the same executable the guard runs before +; anything is replaced; here it answers the other question, about files a purge could not +; remove. It asks the Restart Manager (RmRegisterResources/RmGetList), which names a +; process holding a plain handle on a file - an antivirus scanner - and names services as +; well as user programs, and it adds every process running from the directory, because a +; running instance holds its payload as mapped images rather than as open handles and the +; Restart Manager does not reliably report those. Its remaining blind spot is a handle +; taken in kernel mode, where there is no process to name; then the per-file codes logged +; above are the whole story, and the probe says so by naming nothing rather than by +; failing. ; -; Restart Manager is what makes the answer complete. Walking processes can only name one -; whose image lives here or that loaded one of these libraries, while Restart Manager -; names a process that holds a plain handle on a file - an antivirus scanner - and names -; services as well as user programs. Its blind spot is a handle taken in kernel mode, -; where there is no process to name; then the codes logged above are the whole story, and -; the probe says so by naming nothing rather than by failing. -; -; $INSTDIR travels through the same COCKATRICE_GUARD_DIR environment variable the -; running-instance guard uses, so a path with spaces or apostrophes never has to be -; embedded in a command line. +; $INSTDIR travels through the same COCKATRICE_GUARD_DIR environment variable the guard +; uses, so a path with spaces or apostrophes never has to be embedded in a command line. Function LogProcessesUsingInstallDir StrCpy $HolderExit 0 StrCpy $HolderOutput "" StrCpy $HolderFound 0 - InitPluginsDir - ; Embedded by the same relative path that resolves NSIS.definitions.nsh above (both - ; are built into the build root, which is three levels up from project.nsi). - File "/oname=$PLUGINSDIR\installer-holder-probe.exe" "..\..\..\installer-holder-probe.exe" - System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$INSTDIR")' - ; /TIMEOUT because this runs on the abort path, ahead of the message box: one call - ; into the Restart Manager cannot take the minute a walk over every process can, but - ; if that service stalls it must not leave the installer hanging with nothing on - ; screen. On a timeout nsExec reports it through the exit code like any other failure. - nsExec::ExecToStack /TIMEOUT=20000 '"$PLUGINSDIR\installer-holder-probe.exe"' - Pop $HolderExit - Pop $HolderOutput + ; The directory that just failed the purge, not whatever a check before the directory + ; page looked at: a portable install into a custom folder reaches here with the two + ; being different. + StrCpy $GuardDir $INSTDIR + Call RunHolderProbe + StrCpy $HolderExit $GuardExit + StrCpy $HolderOutput $GuardOutput ; The exit code, not the text, decides what happened: it cannot be mangled by what ; nsExec leaves on the end of the captured output. 1 = the line below names what is ; holding the files, 0 = nothing holds them any more, anything else = the probe could @@ -1426,85 +1412,69 @@ Function LogProcessesUsingInstallDir ${If} $HolderExit == "1" StrCpy $HolderFound 1 ${ElseIf} $HolderExit == "0" - StrCpy $HolderOutput "nothing is holding them any more" + StrCpy $HolderOutput "nothing is holding them and no program is running from the directory" ${Else} StrCpy $HolderOutput "the holder probe could not run: $HolderOutput" ${EndIf} !insertmacro CockLog "holders: $HolderOutput" FunctionEnd -; Called after a purge left files behind and LogStaleRuntimeDiagnostics named who -; is holding them. Instead of aborting straight away - which on /R strands the -; user, since the old version is already uninstalled - ask, close what this -; installer owns, purge again, and retry while the user keeps asking. Sets -; $StaleRuntimeAfterPurge to the last purge's verdict, so the caller still aborts -; when the files survive. +; Called after a purge left files behind and LogStaleRuntimeDiagnostics named who is +; holding them. Instead of aborting straight away - which on /R strands the user, since +; the old version is already uninstalled - ask, close what this installer owns, purge +; again, and retry while the user keeps asking. Sets $StaleRuntimeAfterPurge to the last +; purge's verdict, so the caller still aborts when the files survive. ; -; Cockatrice is asked to close (WM_CLOSE, the same request as clicking the -; window's close button) and keeps the last word: it may be waiting for an answer -; to its own "really quit?" confirmation (a running card database update, an open -; game, an unsaved deck), and that stays its decision, exactly as the -; running-instance guard treats it. The instance is brought forward first, so that -; confirmation is on screen rather than behind this installer's prompt. Oracle and -; Servatrice have no window to send a close to at all, and nothing here is -; force-stopped: Oracle writes the card database in place and killing it mid-write -; would corrupt it. Everything the probe named that this installer cannot close - -; those two included - is left to the user, whom the prompt names it to. +; Cockatrice is asked to close (WM_CLOSE, the same request as clicking the window's close +; button) and keeps the last word: it may be waiting for an answer to its own "really +; quit?" confirmation (a running card database update, an open game, an unsaved deck), and +; that stays its decision. The instance is brought forward first, so that confirmation is +; on screen rather than behind this installer's prompt. Oracle and Servatrice have no +; window to send a close to at all, and nothing here is force-stopped: Oracle writes the +; card database in place and killing it mid-write would corrupt it. Everything the probe +; named that this installer cannot close - those two included - is left to the user, whom +; the prompt names it to. ; ; Restart Manager's RmShutdown was the alternative and was rejected: it shuts down -; services as well as applications, and the only way to bring them back is -; RmRestart, which would restart this very application into files that are about -; to be replaced. +; services as well as applications, and the only way to bring them back is RmRestart, +; which would restart this very application into files that are about to be replaced. ; -; No offer when nobody is at the keyboard (a /S install without /R, e.g. a package -; manager): a dialog there would block forever. /R is the in-app updater, whose -; client has already quit, so the user is there - the same reasoning AbortInstall -; uses for its message box. No offer either when the probe named nobody: there is -; nothing to close, and the abort's per-file diagnostics already record what -; Windows said. +; Always offered, in every mode: a /S install without /R is the only case without someone +; at the keyboard, and this installer does not promise unattended runs - if an error needs +; a decision, it asks for one. No offer when the probe named nobody: there is nothing to +; close, and the abort's per-file diagnostics already record what Windows said. ; -; The first round offers the close; every later one stops offering it and says -; what to do instead. Repeating the same question is what made an accepted close -; look like a Yes that was ignored: when Cockatrice was blocked on its own -; confirmation, the second Yes was delivered to a window that dialog had disabled, -; so nothing closed, the holders were unchanged, and the identical prompt came -; back with no hint of what had happened. -Function OfferToCloseAndRetryPurge - ${If} $ReinstallMode = 0 - ${AndIf} ${Silent} - !insertmacro CockLog "close-holders: unattended install, not offering to close holders" - Return - ${EndIf} +; Every round offers all three answers: Yes closes Cockatrice and purges again, No purges +; again without asking it to close (for when the user closed things by hand), and Cancel +; gives up. The close stays on offer after one that did nothing because the usual reason is +; Cockatrice's own confirmation still waiting for an answer - the probe waits for that, the +; user can answer it in the application's window, and the next round can then collect the +; process the earlier attempt could not. Each round re-reads the holders, so the prompt +; names what survived the last attempt rather than repeating the first listing. +Function RecoverFromHolders ${If} $HolderFound != 1 - !insertmacro CockLog "close-holders: no holder was named, nothing to offer" + !insertmacro CockLog "recover-holders: no holder was named, nothing to offer" Return ${EndIf} - StrCpy $HolderCloseRounds 0 - ocrp_prompt: - ${If} $HolderCloseRounds = 0 - MessageBox MB_YESNO|MB_ICONEXCLAMATION|MB_DEFBUTTON2 \ - "Files of the previous version of Cockatrice are still in use by:$\r$\n$\r$\n$HolderOutput$\r$\n$\r$\nThe installer can close Cockatrice for you. It may first ask you to confirm quitting - it does while a card database update is running, a game is open or a deck is unsaved - so answer that window. Close Oracle, Servatrice and any other program listed above by hand.$\r$\n$\r$\nChoose Yes to close Cockatrice and try again, or No to cancel the installation." \ - IDYES ocrp_close - !insertmacro CockLog "close-holders: declined by user" - Return - ${EndIf} - MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ - "Files of the previous version of Cockatrice are still in use by:$\r$\n$\r$\n$HolderOutput$\r$\n$\r$\nCockatrice may be waiting for an answer in its own window - it asks before quitting while a card database update is running, a game is open or a deck is unsaved. Switch to it and confirm, then close Oracle, Servatrice and any other program listed above.$\r$\n$\r$\nChoose Retry once they are closed, or Cancel to cancel the installation." \ - IDRETRY ocrp_close - !insertmacro CockLog "close-holders: declined by user after $HolderCloseRounds round(s)" + rfh_prompt: + MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ + "Files of the previous version of Cockatrice are still in use by:$\r$\n$\r$\n$HolderOutput$\r$\n$\r$\nChoose Yes to close Cockatrice and try the removal again. It may ask you to confirm quitting - it does while a card database update is running, a game is open or a deck is unsaved - so answer that window. Close Oracle, Servatrice and any other program listed above by hand.$\r$\n$\r$\nChoose No to try again after closing the programs yourself, or Cancel to cancel the installation." \ + IDYES rfh_close IDNO rfh_retry + !insertmacro CockLog "recover-holders: declined by user" Return - ocrp_close: - IntOp $HolderCloseRounds $HolderCloseRounds + 1 - !insertmacro CockLog "close-holders: accepted (round $HolderCloseRounds), holders were [$HolderOutput]" - ; The close is a request, not a fact: AskCockatriceAppsToClose waits for the - ; instances it asked to exit, so a confirmation of Cockatrice's that is still up is - ; reported as still running instead of being measured as a successful close. - Call AskCockatriceAppsToClose + rfh_close: + !insertmacro CockLog "recover-holders: closing cockatrice on request, holders were [$HolderOutput]" + ; The close is a request, not a fact: CloseHolders waits for the instances it asked to + ; exit, so a confirmation of Cockatrice's that is still up is reported as still running + ; instead of being measured as a successful close. + StrCpy $GuardDir $INSTDIR + Call CloseHolders + rfh_retry: Call RetryDllPurge StrCpy $StaleRuntimeAfterPurge $R0 - !insertmacro CockLogListing "after-close-purge" "*.dll" - !insertmacro CockLogListing "after-close-plugins" "Plugins\*.*" - !insertmacro CockLog "close-holders: stale-runtime after retry=$StaleRuntimeAfterPurge" + !insertmacro CockLogListing "after-retry-purge" "*.dll" + !insertmacro CockLogListing "after-retry-plugins" "Plugins\*.*" + !insertmacro CockLog "recover-holders: stale-runtime after retry=$StaleRuntimeAfterPurge" ${If} $StaleRuntimeAfterPurge = 0 DetailPrint "Files of the previous version were removed; continuing the installation." Return @@ -1515,179 +1485,12 @@ Function OfferToCloseAndRetryPurge ${If} $HolderFound != 1 Return ${EndIf} - Goto ocrp_prompt -FunctionEnd - -; Asks the running Cockatrice to close, and waits for it to exit. -; -; The set is the Restart Manager answer from LogProcessesUsingInstallDir: exactly the -; processes holding a file of the version being replaced. tools\installer-holder-probe -; closes that set by pid, which is why nothing here has to match an executable path or -; depend on powershell.exe. Only cockatrice.exe is asked: Oracle writes the card database -; in place and Servatrice has no window, so both stay the user's job, as the prompt says. -; -; Each instance is brought to the foreground before WM_CLOSE is posted to its main -; window. WM_CLOSE is answered, not obeyed - Cockatrice asks before it quits while a card -; database update is running, a game is open or a deck is unsaved - and that question is -; only useful if it is in front of the installer instead of behind it. -; -; The probe waits up to 30 s for the processes it asked to exit, so the purge that follows -; measures a settled directory rather than sleeping for a fixed time. Its line goes into -; the log as it stands: it names every holder, including the ones left to the user, so a -; close that did nothing can be told apart from one that was never sent. -Function AskCockatriceAppsToClose - InitPluginsDir - ; LogProcessesUsingInstallDir extracted it already; the check keeps a single copy of - ; the executable in the installer and still works if that order ever changes. - IfFileExists "$PLUGINSDIR\installer-holder-probe.exe" ck_close_probe_ready - File "/oname=$PLUGINSDIR\installer-holder-probe.exe" "..\..\..\installer-holder-probe.exe" - ck_close_probe_ready: - System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$INSTDIR")' - ; The probe waits 30 s itself. The timeout here only bounds a probe that never - ; returns; the Restart Manager call inside it is the same one the holder listing makes. - nsExec::ExecToStack /TIMEOUT=40000 '"$PLUGINSDIR\installer-holder-probe.exe" --close-holders' - Pop $R3 - Pop $R4 - !insertmacro CockLog "close-holders: probe exit=$R3 [$R4]" - DetailPrint "Closing Cockatrice: $R4" -FunctionEnd - -; Uninstaller copies of the same routines (the uninstaller gets its own -; function set compiled in, it cannot call the installer functions). -Function un.IsAppRunning - ; An unset $GuardDir would match every process that merely shares the image - ; name, see IsAppRunning in the installer. - ${If} $GuardDir == "" - StrCpy $R0 2 - Return - ${EndIf} - ; Pass the directory to guard to PowerShell via an environment variable so - ; it never has to be embedded into the -Command string: apostrophes in a - ; path would terminate the command early, and the path in a -like pattern - ; would treat [ and ] as wildcard characters. - System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")' - ; Exit the process explicitly so nsExec's pop reflects whether a matching - ; process was found: powershell.exe exits 0 whether or not the pipeline - ; produced objects. 0 = not running, 1 = running, anything else = could not - ; be determined (see un.IsProcessLookupAvailable). - ; Strict match: a readable path under $GuardDir, same as in the installer. - nsExec::ExecToLog 'powershell -NoProfile -Command "if (Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { try { $$p = $$_.Path; if ($$p) { $$p.StartsWith($$env:COCKATRICE_GUARD_DIR, [StringComparison]::OrdinalIgnoreCase) } else { $$false } } catch { $$false } } | Select-Object -First 1) { exit 1 } else { exit 0 }"' - Pop $R0 - ${If} $R0 == "0" - StrCpy $R0 0 - ${ElseIf} $R0 == "1" - StrCpy $R0 1 - ${Else} - ; powershell.exe could not be run at all (nsExec pushes the string - ; "error"), or died with an unexpected code. That is "could not - ; determine", which has to stay distinguishable from "definitely - ; running": a state that can never clear would block the uninstaller - ; forever. un.IsProcessLookupAvailable warns about that case once. - StrCpy $R0 2 - ${EndIf} -FunctionEnd - -Function un.IsProcessLookupAvailable - nsExec::ExecToLog 'powershell -NoProfile -Command "exit 0"' - Pop $R0 - ${If} $R0 == "0" - StrCpy $R0 1 - ${Else} - StrCpy $R0 0 - ${EndIf} -FunctionEnd - -Function un.CloseMatchingApps - ; gracefully ask every matching instance to close (sends WM_CLOSE) - ; - ; Strict filter, same as in the installer: never close a process whose path - ; cannot be read, it is not necessarily one of ours. - System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")' - nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { try { $$p = $$_.Path; if ($$p) { $$p.StartsWith($$env:COCKATRICE_GUARD_DIR, [StringComparison]::OrdinalIgnoreCase) } else { $$false } } catch { $$false } } | ForEach-Object { $$null = $$_.CloseMainWindow() }"' - Pop $R3 -FunctionEnd - -Function un.ForceCloseApps - ; stop the matching instances by PID once the grace period is over, see - ; ForceCloseApps in the installer. - System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")' - nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { try { $$p = $$_.Path; if ($$p) { $$p.StartsWith($$env:COCKATRICE_GUARD_DIR, [StringComparison]::OrdinalIgnoreCase) } else { $$false } } catch { $$false } } | ForEach-Object { Stop-Process -Id $$_.Id -Force -ErrorAction SilentlyContinue }"' - Pop $R3 -FunctionEnd - -Function un.WaitForAppToClose - Call un.IsAppRunning - ; Only a definite "running" is worth waiting for, see WaitForAppToClose. - ${If} $R0 != 1 - Return - ${EndIf} - - ${If} ${Silent} - DetailPrint "Closing $R1 ..." - Call un.CloseMatchingApps - ; wall-clock deadline: each poll has to spawn a fresh powershell.exe, so - ; a bare loop counter would not bound the real wait time to 60 s. - System::Call 'kernel32::GetTickCount()i.s' - Pop $R8 - un_ck_wait_loop: - Sleep 500 - Call un.IsAppRunning - ${If} $R0 = 0 - DetailPrint "$R1 closed." - Return - ${EndIf} - ${If} $R0 != 1 - Return - ${EndIf} - System::Call 'kernel32::GetTickCount()i.s' - Pop $R9 - IntOp $R9 $R9 - $R8 - ${If} $R9 < 60000 - Goto un_ck_wait_loop - ${EndIf} - ; The grace period is over. Cockatrice is never force-stopped, see - ; WaitForAppToClose in the installer. A silent uninstall only reports - ; through the log and its exit code: it is also run unattended (the /R - ; updater uses it via QuietUninstallString, package managers use it - ; directly), and a message box would block there. The installer that ran - ; us reports the incomplete result to the user itself. - ${If} $AllowForceClose = 0 - DetailPrint "$R1 is still running after 60 s; aborting so that no data is lost." - SetErrorLevel 1 - Abort - ${EndIf} - DetailPrint "Force-stopping $R1 ..." - Call un.ForceCloseApps - Sleep 2000 - Call un.IsAppRunning - ${If} $R0 = 0 - DetailPrint "$R1 closed." - Return - ${EndIf} - ${If} $R0 != 1 - Return - ${EndIf} - DetailPrint "$R1 is still running after 60 s; aborting." - SetErrorLevel 1 - Abort - ${Else} - un_ck_wait_prompt: - MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ - "$R1 is still running.$\r$\n$\r$\nPlease close it, then click Retry.$\r$\nClick Cancel to abort." \ - IDCANCEL un_ck_abort_install - Call un.IsAppRunning - ${If} $R0 = 0 - Return - ${EndIf} - ${If} $R0 != 1 - Return - ${EndIf} - Goto un_ck_wait_prompt - un_ck_abort_install: - Abort - ${EndIf} + Goto rfh_prompt FunctionEnd +; Uninstaller copy of the guard (the uninstaller gets its own function set compiled in, it +; cannot call the installer functions). ExtractHolderProbe and RunHolderProbe come from +; CockHolderProbeFunctions above; only the policy differs. Function un.EnsureAppsNotRunning ; guards the install directory; the uninstaller only ever has to check this one StrCpy $GuardDir $INSTDIR @@ -1695,29 +1498,54 @@ Function un.EnsureAppsNotRunning FunctionEnd Function un.GuardAppsNotRunning - Call un.IsProcessLookupAvailable - ${If} $R0 = 0 - ; Same reasoning as in the installer: without PowerShell we cannot tell - ; whether a running instance still holds file locks, and refusing to - ; uninstall would leave the user with an uninstaller that never - ; completes. Warn once and continue. - DetailPrint "Could not start powershell.exe; skipping the running instance check. Close Cockatrice, Oracle and Servatrice manually if the uninstall reports a problem." - MessageBox MB_OK|MB_ICONEXCLAMATION "This uninstaller could not check whether Cockatrice is still running, because powershell.exe could not be started.$\r$\n$\r$\nPlease close Cockatrice, Oracle and Servatrice manually before continuing." + StrCpy $GuardCloseTried 0 + un_guard_check: + !insertmacro CockLogUn "uninst-guard: checking $GuardDir" + Call un.RunHolderProbe + ${If} $GuardExit == "0" + !insertmacro CockLogUn "uninst-guard: nothing is holding the payload" Return ${EndIf} - StrCpy $R1 "cockatrice.exe" - StrCpy $R2 "cockatrice" - ; a GUI application with unsaved user data: graceful close only - StrCpy $AllowForceClose 0 - Call un.WaitForAppToClose - StrCpy $R1 "oracle.exe" - StrCpy $R2 "oracle" - StrCpy $AllowForceClose 1 - Call un.WaitForAppToClose - StrCpy $R1 "servatrice.exe" - StrCpy $R2 "servatrice" - StrCpy $AllowForceClose 1 - Call un.WaitForAppToClose + ${If} $GuardExit == "1" + !insertmacro CockLogUn "uninst-guard: still in use by $GuardOutput" + ; Same convenience as in the installer: ask Cockatrice to close once, then report + ; what the close could not reach. + ${If} $GuardCloseTried = 0 + StrCpy $GuardCloseTried 1 + !insertmacro CockLogUn "uninst-guard: asking cockatrice to close, then checking again" + Call un.CloseHolders + Goto un_guard_check + ${EndIf} + DetailPrint "The install directory is in use by: $GuardOutput" + ; Same three answers as the installer's guard; the automatic close above already + ; ran once before this prompt. + MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ + "Cockatrice, Oracle or Servatrice is still using files of the installation:$\r$\n$\r$\n$GuardOutput$\r$\n$\r$\nChoose Yes to close Cockatrice and check again. It may ask you to confirm quitting - it does while a card database update is running, a game is open or a deck is unsaved - so answer that window.$\r$\n$\r$\nClose Oracle, Servatrice and any other program listed above by hand. Choose No to check again after that, or Cancel to cancel the uninstallation." \ + IDYES un_guard_close IDNO un_guard_check + !insertmacro CockLogUn "uninst-guard: cancelled by user" + SetErrorLevel 1 + Abort + ; Only reached through Yes: Abort does not return. + un_guard_close: + !insertmacro CockLogUn "uninst-guard: closing cockatrice on request, then checking again" + Call un.CloseHolders + Goto un_guard_check + ${EndIf} + ; Same reasoning as in the installer: without an answer from the probe we cannot tell + ; whether a running instance still holds file locks, and refusing outright would leave + ; the user with an uninstaller that never completes. Same three answers too, so an + ; uninstall that hits a Restart Manager that was busy can simply be retried. + !insertmacro CockLogUn "uninst-guard: the holder probe could not answer ($GuardExit): $GuardOutput" + DetailPrint "Could not check whether Cockatrice is still running: $GuardOutput" + MessageBox MB_ABORTRETRYIGNORE|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ + "This uninstaller could not check whether Cockatrice, Oracle or Servatrice is still running.$\r$\n$\r$\nThe check reported: $GuardOutput$\r$\n$\r$\nClose them, then choose Retry to check again. Choose Ignore to uninstall anyway, or Abort to cancel the uninstallation." \ + IDRETRY un_guard_check IDABORT un_guard_unknown_abort + !insertmacro CockLogUn "uninst-guard: continuing without an answer from the probe" + Return + un_guard_unknown_abort: + !insertmacro CockLogUn "uninst-guard: cancelled after the probe could not answer" + SetErrorLevel 1 + Abort FunctionEnd Function PortableModePageCreate @@ -1984,24 +1812,43 @@ ${If} $StaleRuntimeAfterPurge = 1 ; answers the question the message can only guess at, and so the prompt can ; name them and offer to close the ones this installer owns. Call LogStaleRuntimeDiagnostics - ; On /R the previous version is already uninstalled at this point, so an abort - ; leaves the user with no working Cockatrice. Offer to close and purge again, - ; repeating while the user keeps saying Yes; this updates - ; $StaleRuntimeAfterPurge with the last retry's verdict. - Call OfferToCloseAndRetryPurge + ${If} $HolderFound = 1 + ; On /R the previous version is already uninstalled at this point, so an abort + ; leaves the user with no working Cockatrice. Offer to close and purge again, + ; repeating while the user keeps saying Yes; this updates + ; $StaleRuntimeAfterPurge with the last retry's verdict. + Call RecoverFromHolders + ${Else} + ; Nothing is holding them. The three attempts in RetryDllPurge were two seconds + ; apart and all failed with no process to blame, which is the transient case a + ; virus scanner or a background indexer creates - or a permission problem that + ; will not clear. Give the delete one last chance before giving up: it costs a + ; second on a path that is about to abort anyway, and it keeps a lock that was + ; released in the meantime from turning into a failed upgrade. + !insertmacro CockLog "section: no holder named, one final purge attempt" + Sleep 1000 + Call RetryDllPurge + StrCpy $StaleRuntimeAfterPurge $R0 + !insertmacro CockLog "section: final-purge stale-runtime=$StaleRuntimeAfterPurge" + ${EndIf} ${EndIf} ${If} $StaleRuntimeAfterPurge = 1 !insertmacro CockLog "section: aborting, files of a previous version survived the purge" DetailPrint "Files of a previous version could not be removed; aborting the installation." - ; The in-app updater runs this installer silent and detached and never looks - ; at its exit code, so the user has to be told what happened. A /S install - ; without /R is unattended (a package manager, for instance) and a message - ; box there would block forever, so that case only reports through the log - ; and the exit code. AbortInstall owns that decision. + ; Say what is known and nothing more: the guard ran before anything was replaced, + ; so "most likely still running" is no longer a fair guess and would send the user + ; to close programs that are already closed. StrCpy $AbortReason \ - "The installation could not remove all files of a previous version of Cockatrice, most likely because it is still running or was started as administrator, and was cancelled rather than leave a mismatched install behind.$\r$\n$\r$\nPlease close Cockatrice, Oracle and Servatrice and run the installer again." + "The installation could not remove all files of a previous version of Cockatrice, and was cancelled rather than leave a mismatched installation behind." ${If} $HolderFound = 1 - StrCpy $AbortReason "$AbortReason$\r$\n$\r$\nStill using the install directory:$\r$\n$HolderOutput" + StrCpy $AbortReason "$AbortReason$\r$\n$\r$\nStill using the install directory:$\r$\n$HolderOutput$\r$\n$\r$\nClose them and run the installer again." + ${ElseIf} $HolderExit == "0" + StrCpy $AbortReason "$AbortReason$\r$\n$\r$\nNo running program was found using those files. A file permission or a virus scanner may be preventing their removal; the installer log records what Windows reported for each file." + ${Else} + ; The check itself failed, so "no running program was found" would be a claim this + ; installer cannot make. Name the failure instead, and leave the per-file codes to + ; the log, where they already are. + StrCpy $AbortReason "$AbortReason$\r$\n$\r$\nThe check for a running program could not be completed: $HolderOutput$\r$\n$\r$\nClose Cockatrice, Oracle and Servatrice and run the installer again; the installer log records what Windows reported for each file." ${EndIf} ; "Run the installer again" is not enough on an in-app update: the old version ; was uninstalled before the purge, so point at the installer that was downloaded. diff --git a/tools/installer-holder-probe/CMakeLists.txt b/tools/installer-holder-probe/CMakeLists.txt index ab3bb3eff..e4def8c39 100644 --- a/tools/installer-holder-probe/CMakeLists.txt +++ b/tools/installer-holder-probe/CMakeLists.txt @@ -1,9 +1,10 @@ -# Windows-only. The installer runs this on the abort path of a failed purge to name the -# applications and services holding the install directory open (Restart Manager); see -# the LogProcessesUsingInstallDir function in cmake/NSIS.template.in. It ships inside -# the installer and is extracted to $PLUGINSDIR there, never installed, so it links -# nothing from this project: no Qt, and a static C runtime because the installed runtime -# is exactly what the purge may have failed to replace. +# Windows-only. The installer runs this before anything is replaced (the running-instance +# guard in .onInit) and on the abort path of a failed purge, to name the applications and +# services holding the payload open (Restart Manager); see the GuardAppsNotRunning and +# LogProcessesUsingInstallDir functions in cmake/NSIS.template.in. It ships inside the +# installer and the uninstaller and is extracted to $PLUGINSDIR there, never installed, so +# it links nothing from this project: no Qt, and a static C runtime because the installed +# runtime is exactly what the purge may have failed to replace. add_executable(installer-holder-probe close_holders.cpp holder_line.cpp main.cpp restart_manager_holders.cpp) target_link_libraries(installer-holder-probe PRIVATE rstrtmgr) diff --git a/tools/installer-holder-probe/main.cpp b/tools/installer-holder-probe/main.cpp index 269d22821..db46035d3 100644 --- a/tools/installer-holder-probe/main.cpp +++ b/tools/installer-holder-probe/main.cpp @@ -2,6 +2,7 @@ #include "holder_line.h" #include "restart_manager_holders.h" +#include #include #include #include @@ -22,7 +23,9 @@ constexpr unsigned long CLOSE_WAIT_MILLISECONDS = 30000; /// Exit codes the installer reads. "Something was found" is deliberately not 0, so an /// answer of "nothing holds the files" stays distinguishable from a probe that failed. /// In the close mode 1 means a close was actually sent and 0 that every holder was left -/// to the user, which is what the report line next to the code spells out. +/// to the user, which is what the report line next to the code spells out. 3 is only for +/// a lookup that failed and named nobody: one that failed but still named a process +/// running from the directory is answered with that process. enum ExitCode { EXIT_NO_HOLDERS = 0, @@ -33,13 +36,17 @@ enum ExitCode /// The probe has two modes, selected by the first argument: /// -/// (none) name what holds the install directory, one line, for the abort -/// dialog and the log +/// (none) name what holds the payload, one line, for the guard prompt and the +/// abort dialog/log /// --close-holders bring the running Cockatrice forward, ask it to close, and wait for /// it to exit, for the offer to close after a failed purge /// -/// Both answer from the same Restart Manager query, so the close can only ever reach a -/// process that is holding a file of the version being replaced. +/// Both answer from two lookups that cover each other's blind spot: the Restart Manager +/// names whoever has a payload file open, and every process running from the directory is +/// named as well, because a running instance holds its own image and the modules it +/// loaded as mappings rather than open files and the Restart Manager does not always +/// report those. Neither lookup alone is the guard's authority: an instance the file +/// handles do not name is exactly the case that left a half-replaced install behind. bool closeModeRequested(int argumentCount, char **arguments) { return argumentCount > 1 && std::string(arguments[1]) == "--close-holders"; @@ -85,9 +92,26 @@ int main(int argumentCount, char **arguments) return EXIT_NOT_CONFIGURED; } - std::vector holders; - const unsigned long error = findProcessesUsingFiles(collectProbeFiles(installDir), holders); - if (error != ERROR_SUCCESS) { + // Processes running from the directory first: they are the instance the user is asked + // to close, and the Restart Manager may not name any of them (see + // findProcessesRunningFrom). The file holders follow, deduplicated by process, so an + // instance that both runs from the directory and holds a payload file is named once. + std::vector holders = findProcessesRunningFrom(installDir); + std::vector fileHolders; + const unsigned long error = findProcessesUsingFiles(collectPayloadFiles(installDir), fileHolders); + for (const Holder &holder : fileHolders) { + const bool alreadyNamed = std::any_of(holders.begin(), holders.end(), [&holder](const Holder &named) { + return named.processId == holder.processId; + }); + if (!alreadyNamed) { + holders.push_back(holder); + } + } + + // A failed lookup only makes the probe unable to answer when it also named nobody: with + // a process running from the directory the installer still knows what to close, and + // that is worth more to the guard than the code of the query that missed it. + if (error != ERROR_SUCCESS && holders.empty()) { writeLine(L"the Restart Manager lookup failed with win32 error " + std::to_wstring(error)); return EXIT_PROBE_FAILED; } diff --git a/tools/installer-holder-probe/restart_manager_holders.cpp b/tools/installer-holder-probe/restart_manager_holders.cpp index 39f6a0049..c00e5da8c 100644 --- a/tools/installer-holder-probe/restart_manager_holders.cpp +++ b/tools/installer-holder-probe/restart_manager_holders.cpp @@ -5,10 +5,12 @@ // here is a requirement; Regroup would sort them the other way round. #include #include +#include // clang-format on #include #include +#include namespace { @@ -20,6 +22,9 @@ constexpr std::size_t MAX_PROBED_FILES = 2000; /// How deep the Plugins walk goes. Its real shape is Plugins\\.dll. constexpr int MAX_PLUGIN_DEPTH = 4; +/// The payload files the installer replaces by name; the DLLs are matched by mask below. +constexpr const wchar_t *PAYLOAD_FILES[] = {L"cockatrice.exe", L"oracle.exe", L"servatrice.exe", L"qt.conf"}; + void collectFilesOf(const std::wstring &directory, int depth, int maxDepth, std::vector &files) { if (files.size() >= MAX_PROBED_FILES) { @@ -76,6 +81,30 @@ std::wstring fileNameOf(const std::wstring &path) return separator == std::wstring::npos ? path : path.substr(separator + 1); } +/// @p directory with exactly one trailing separator, so that a sibling whose name merely +/// begins with the same letters ("Cockatrice-old" next to "Cockatrice") cannot pass for a +/// subdirectory of it. +std::wstring directoryPrefixOf(const std::wstring &directory) +{ + std::wstring prefix = directory; + while (!prefix.empty() && (prefix.back() == L'\\' || prefix.back() == L'/')) { + prefix.pop_back(); + } + prefix += L'\\'; + return prefix; +} + +/// Whether @p path lies inside @p prefix, which is a directory with a trailing separator. +/// Ordinal and case-insensitive, as Windows compares paths. +bool isUnderDirectory(const std::wstring &path, const std::wstring &prefix) +{ + if (path.size() < prefix.size()) { + return false; + } + const int length = static_cast(prefix.size()); + return CompareStringOrdinal(path.c_str(), length, prefix.c_str(), length, TRUE) == CSTR_EQUAL; +} + /// Copies a fixed-size, NUL-terminated WCHAR array the API filled in. template std::wstring fixedString(const wchar_t (&text)[N]) { @@ -96,13 +125,39 @@ Holder holderOf(const RM_PROCESS_INFO &info) } } // namespace -std::vector collectProbeFiles(const std::wstring &installDir) +std::vector collectPayloadFiles(const std::wstring &installDir) { std::vector files; - // depth 0, maxDepth 0: the install root's files and nothing below it. Its data - // directory holds downloaded pictures, which the purge does not touch and which - // would turn this into a walk over thousands of files. - collectFilesOf(installDir, 0, 0, files); + for (const wchar_t *name : PAYLOAD_FILES) { + const std::wstring path = installDir + L"\\" + name; + const DWORD attributes = GetFileAttributesW(path.c_str()); + if (attributes != INVALID_FILE_ATTRIBUTES && (attributes & FILE_ATTRIBUTE_DIRECTORY) == 0) { + files.push_back(path); + } + } + + // Every top-level DLL: the set the purge deletes and therefore the set a running + // instance can have mapped. Matched by mask rather than by a list of expected names, + // so a transitive dependency nobody thought of is covered as well. + WIN32_FIND_DATAW entry{}; + const HANDLE dlls = FindFirstFileW((installDir + L"\\*.dll").c_str(), &entry); + if (dlls != INVALID_HANDLE_VALUE) { + do { + if (files.size() >= MAX_PROBED_FILES) { + break; + } + if ((entry.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0 || + (entry.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) { + continue; + } + files.push_back(installDir + L"\\" + entry.cFileName); + } while (FindNextFileW(dlls, &entry) != 0); + FindClose(dlls); + } + + // Plugins is the other tree the purge removes. Its data directory is not: it holds + // downloaded pictures, which the purge does not touch and which would turn this into + // a walk over thousands of files. if (files.size() < MAX_PROBED_FILES) { collectFilesOf(installDir + L"\\Plugins", 0, MAX_PLUGIN_DEPTH, files); } @@ -158,3 +213,44 @@ unsigned long findProcessesUsingFiles(const std::vector &files, st RmEndSession(session); return error; } + +std::vector findProcessesRunningFrom(const std::wstring &installDir) +{ + std::vector holders; + if (installDir.empty()) { + return holders; + } + + const std::wstring prefix = directoryPrefixOf(installDir); + // Never the probe itself: it runs from the directory being replaced when the + // uninstaller asks in place or the installer was copied next to the payload, and + // naming it would ask the user to close the process that is doing the asking. + const DWORD self = GetCurrentProcessId(); + + const HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if (snapshot == INVALID_HANDLE_VALUE) { + return holders; + } + PROCESSENTRY32W entry{}; + entry.dwSize = sizeof(entry); + if (Process32FirstW(snapshot, &entry) != FALSE) { + do { + if (entry.th32ProcessID == self) { + continue; + } + // One process at a time, because the path is per process; a process that + // refuses to be read is not named here and is still named by Restart + // Manager when it holds a handle. The idle process has no image at all. + const std::wstring imagePath = imagePathOf(entry.th32ProcessID); + if (!isUnderDirectory(imagePath, prefix)) { + continue; + } + Holder holder; + holder.processId = entry.th32ProcessID; + holder.imageName = fileNameOf(imagePath); + holders.push_back(std::move(holder)); + } while (Process32NextW(snapshot, &entry) != FALSE); + } + CloseHandle(snapshot); + return holders; +} diff --git a/tools/installer-holder-probe/restart_manager_holders.h b/tools/installer-holder-probe/restart_manager_holders.h index d69fc0259..6dcd91108 100644 --- a/tools/installer-holder-probe/restart_manager_holders.h +++ b/tools/installer-holder-probe/restart_manager_holders.h @@ -7,17 +7,19 @@ #include /** - * @brief Lists the files a failed purge can be holding up, as full paths. + * @brief Lists the payload files, as full paths. * - * Everything directly in @p installDir plus everything below its Plugins directory: - * those are the two things the purge removes, so they are the two sets of files a - * holder can be holding. Restart Manager refuses a directory with ERROR_ACCESS_DENIED, - * which is why the probe registers files and this has to name them first. + * The set is what the installer replaces and the purge removes: the three executables, + * qt.conf, every top-level DLL and everything below Plugins. A document, a log or a + * screenshot the user happens to have open in the install directory is not part of it, + * so it cannot be mistaken for a running instance and block an upgrade. * - * Missing or unreadable directories are skipped rather than reported, and the list is - * capped: this runs on the abort path and must not walk a tree of downloaded pictures. + * Restart Manager refuses a directory with ERROR_ACCESS_DENIED, which is why the probe + * registers files and this has to name them first. Missing files and unreadable + * directories are skipped rather than reported, and the list is capped: this runs before + * the first page and on the abort path, and must not walk a tree of downloaded pictures. */ -std::vector collectProbeFiles(const std::wstring &installDir); +std::vector collectPayloadFiles(const std::wstring &installDir); /** * @brief Names the applications and services that have one of @p files open or mapped. @@ -32,4 +34,27 @@ std::vector collectProbeFiles(const std::wstring &installDir); */ unsigned long findProcessesUsingFiles(const std::vector &files, std::vector &holders); +/** + * @brief Lists every process whose image lives in @p installDir, whether or not it has a + * payload file open. + * + * Restart Manager answers the question "who has this file open", and a running instance + * does not necessarily have one open: its own image and the modules it loaded are mapped + * into it, not opened by it, and RmGetList does not reliably report a mapping. That is + * the state the failed updates this probe exists for were in - every DLL still on disk, + * an instance running from the directory, and "nothing is holding them any more" as the + * answer - so an answer built on file handles alone cannot be the guard's authority. + * + * Reading the image path needs PROCESS_QUERY_LIMITED_INFORMATION and + * QueryFullProcessImageNameW, neither of which depends on the bitness of either process, + * so a 64-bit instance is named from this 32-bit stub. The comparison is on the + * directory, not on an image name, so a process that merely shares a name (an Oracle + * database server next to a Cockatrice install) is never named. + * + * @param installDir the directory being replaced, as a full path. + * @return one entry per process running from it, in snapshot order, without the probe + * itself; empty when the directory is empty or no process runs from it. + */ +std::vector findProcessesRunningFrom(const std::wstring &installDir); + #endif From aa42cfbccc093549b35538d9220241d9e89c4871 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:12:37 +0100 Subject: [PATCH 038/108] [VisualDeckEditor] Create menus before central frame to prevent Windows crash in setParent_sys (#7673) Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> --- .../widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp index be7255dc3..964babcae 100644 --- a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp +++ b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp @@ -61,8 +61,8 @@ TabDeckEditorVisual::TabDeckEditorVisual(TabSupervisor *_tabSupervisor) : Abstra { setObjectName("TabDeckEditorVisual"); - createCentralFrame(); createMenus(); + createCentralFrame(); installEventFilter(this); @@ -105,7 +105,6 @@ void TabDeckEditorVisual::createCentralFrame() centralFrame->addWidget(tabContainer); setCentralWidget(centralWidget); - setDockOptions(QMainWindow::AnimatedDocks | QMainWindow::AllowNestedDocks | QMainWindow::AllowTabbedDocks); } /** @brief Updates the visual deck, analytics, and sample hand after a deck change. */ From 92d082e7353a9b0f2bc4fc14f2cb1cf6493a31aa Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:14:52 +0100 Subject: [PATCH 039/108] [VisualDeckStorage] Guard against widget deletion during tag edit dialog (#7674) Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> --- .../deck_preview/deck_preview_deck_tags_display_widget.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.cpp index 4e6e7cc05..1a06c0b18 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.cpp @@ -12,6 +12,7 @@ #include #include +#include #include #include #include @@ -117,8 +118,13 @@ void DeckPreviewDeckTagsDisplayWidget::openTagEditDlg() void DeckPreviewDeckTagsDisplayWidget::execTagDialog(const QStringList &knownTags) { + QPointer self(this); DeckPreviewTagDialog dialog(knownTags, currentTags); if (dialog.exec() == QDialog::Accepted) { + // Guard against the widget being destroyed during dialog.exec()'s nested event loop. + if (!self) { + return; + } QStringList updatedTags = dialog.getActiveTags(); if (updatedTags != currentTags) { setTags(updatedTags); From 30d3afc7b4c8e8ca2aae12ec53f513d8d080a590 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:19:49 +0100 Subject: [PATCH 040/108] [Updater] Close a running Cockatrice when the installer asks (#7672) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Updater] Refuse a close that arrives while one is already asking MainWindow::closeEvent() had a re-entrancy guard that returned without touching the event. A QCloseEvent is accepted unless something ignores it, so a second close request - an installer replacing our files posts one every 30 s - accepted the close while the first one was still blocked on its own confirmation. close() then hid this window, hid the confirmation dialog with it, and left the process running inside the nested loop that dialog had opened: no window, no way to answer it, and every later close request accepted into the same dead end. That is what an update looked like when it could not close a running client, and it is why the installer could name the process, bring it forward and ask it to close for minutes without anything happening. Track how far the shutdown has got instead. While a confirmation is up, a close from anywhere else is refused, which leaves the question in front of the user; once the shutdown has been accepted, a duplicate close (Qt delivers the event twice on some platforms) is accepted again so it cannot cancel the quit. * [Installer] Leave an instance that is asking a question alone An instance that answered a close by hiding rather than quitting - the state the client used to get into - has no visible window left, and the confirmation it is waiting on is hidden with the window that owns it. The close probe only ever looked at visible windows, so it restored the main window, posted WM_CLOSE into the same dead end, and reported "still running" without saying why. Show every window the instance left hidden before choosing the one to close, and skip the close when the window it would reach is disabled: a modal dialog of the instance's own is up, and posting the close then only hides the question again. Name what is left of the window in the report as well - gone, still open, hidden again, and whether the thread answers at all - so the log tells a refused close from one that was never delivered. * [Installer] Offer to end a Cockatrice that will not close A close is a request, and an instance that does not answer it - waiting on a question nobody is looking at, or stuck where a close cannot reach - leaves the user with no way forward: the files cannot be replaced while it runs, and clicking Yes again only repeats the request. After a second close that changed nothing, offer the only thing that can free the files: end it. The prompt says what it costs - an unsaved deck, a running game or a setting it has not written yet - the default button is the safe one, and it is a separate answer rather than a relabelled "Yes", so it is not reached by clicking through the close prompt. Only cockatrice.exe is ever ended: Oracle writes the card database in place and Servatrice is a service, so both are named and left to the user, exactly as they are for the close. The probe gains an --end-holders mode for it, and the uninstaller's guard gets the same offer. * [Installer] Never name the process that is doing the asking The old uninstaller is started with _?= so it runs in place, from the very directory it is emptying, and an installer can be downloaded into the folder it installs to. Both are then processes running from the directory, so the probe named them - and the guard asked the user to close the program that was asking. That question cannot be answered: a close and the end offer both skip anything that is not cockatrice.exe, so the guard repeated itself until the update or the uninstall was cancelled. The probe and the processes that started it are now never named, by either lookup. The walk up the parent chain stops at the first program the payload installs, because in an in-app update the installer's own parent is the Cockatrice instance being replaced - the one the guard exists to name. The Windows check held its locked file in the script that starts the probe, so that holder is excluded by design now and the check read it as "nothing found". It holds the file from a process of its own instead, and asserts the exclusion itself: a file that only the asking process holds comes back as nothing. * [Installer] Restore only the windows a user could answer Showing every hidden window an instance owns put Qt's own bookkeeping windows on screen: the tray icon keeps one to receive the TaskbarCreated message, styled WS_OVERLAPPED and titled QTrayIconMessageWindow. Restored, it is an empty white rectangle in the taskbar with no title bar to close it - it has no window procedure that would hide it again, and the process is busy in the very loop the restore is meant to break, so it stayed until the process was force-ended. Only framed, non-empty windows are restored and considered for the close now. That also keeps the close off Qt's tray window, which answers WM_CLOSE by asking the application to terminate - a quit that skips the confirmation the close is supposed to reach. * [Installer] Uninstall the previous version before installing over it A regular install over an existing one asked "a previous version must be uninstalled", then ran the old uninstaller interactively. That uninstaller put its confirmation page behind the installer window, where it could not be answered, and it only removed the installation after the installer had already written the new one over it. Run the previous version's uninstaller silently instead. The installer has already asked the question the user answered, so a second, hidden confirmation is pure risk: with /S there is no window to be behind, and ExecWait is then waiting for something that cannot outlive it. QuietUninstallString is used when the old installation has one, otherwise /S is appended. ExecWait only waits while the uninstaller runs in place: one that did not get a usable _?= copies itself to %TEMP% and returns at once, while the copy keeps deleting - which is how an uninstall can finish after the install it was supposed to precede. Log the exit code and wait for the old cockatrice.exe to disappear before restoring user data or writing anything, and abort with a message instead of installing into a directory that is still being emptied. Cancelling the "must be uninstalled" prompt now quits the installer; it used to skip the page and install over the old version anyway. * [Installer] Do not show windows the application has hidden The guard restored every hidden window an instance owned before asking it to close, so that a confirmation hidden together with its window would come back. Windows does as it is told and shows them; the application does not, because Qt still believes they are hidden and never paints them. What the user gets is a blank white rectangle, on screen and on the taskbar, that answers nothing - and the question it was meant to bring back is not answerable in it either. An instance with no window a user could answer is named and left to the user instead, and the prompt says what to do about it: open it from the notification area. Ending it stays the last resort, offered once a close has been asked for twice. The abort after a failed uninstall now says what it saw - the old version is still there after its uninstaller ran - instead of promising a removal that is not happening, which is the state a cancelled old uninstaller leaves behind. --------- Co-authored-by: Lukas Brübach --- .ci/compile.sh | 56 ++++- cmake/NSIS.template.in | 170 +++++++++++-- cockatrice/src/interface/window_main.cpp | 32 ++- cockatrice/src/interface/window_main.h | 12 +- .../installer-holder-probe/close_holders.cpp | 232 +++++++++++++++--- tools/installer-holder-probe/close_holders.h | 48 +++- tools/installer-holder-probe/main.cpp | 53 +++- .../restart_manager_holders.cpp | 68 ++++- .../restart_manager_holders.h | 25 +- 9 files changed, 606 insertions(+), 90 deletions(-) diff --git a/.ci/compile.sh b/.ci/compile.sh index e85e2b5ba..1a30a0fd3 100755 --- a/.ci/compile.sh +++ b/.ci/compile.sh @@ -474,6 +474,10 @@ if [[ $RUNNER_OS == Windows && ! $MAKE_NO_CLIENT ]]; then # FileShare.None is exactly that case, so this asserts the reason the probe was # added instead of only that the Restart Manager answers at all. It needs no NSIS # and no packaging, so it runs on every Windows build that gets this far. + # The holder is a process of its own: the probe never names the process that started it, + # which is what keeps an in-place uninstaller from being named as a holder of the + # directory it is emptying. The same DLL held by the script that starts the probe + # therefore has to come back as nothing, and that is the second case below. probe_check="./holder-probe-check.ps1" cat >"$probe_check" <<'POWERSHELL' $ErrorActionPreference = "Stop" @@ -500,17 +504,61 @@ function Invoke-Probe([string] $guardDir) { return @{ Code = $code; Output = $output } } +function Wait-UntilLocked([string] $path) { + $deadline = (Get-Date).AddSeconds(15) + while ((Get-Date) -lt $deadline) { + try { + $open = [IO.File]::Open($path, "Open", "ReadWrite", "None") + $open.Dispose() + } catch { + return + } + Start-Sleep -Milliseconds 100 + } + throw "the holder never locked $path" +} + try { $file = Join-Path $dir "locked.dll" [IO.File]::WriteAllText($file, "x") + + # The holder is a process of its own, not this script: the probe never names the process + # that started it (the case below asserts that), so a file held here would be left out on + # purpose and the check would pass for the wrong reason. + $holderScript = Join-Path $dir "hold.ps1" + Set-Content -LiteralPath $holderScript -Value @' +param([string] $Path) +$handle = [IO.File]::Open($Path, "Open", "ReadWrite", "None") +Start-Sleep -Seconds 120 +'@ + $powershell = Join-Path $env:SystemRoot "System32\WindowsPowerShell\v1.0\powershell.exe" + $holder = Start-Process -FilePath $powershell -PassThru -ArgumentList @( + "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "`"$holderScript`"", "-Path", "`"$file`"") + try { + Wait-UntilLocked $file + $locked = Invoke-Probe $dir + if ($locked.Code -ne 1) { throw "expected exit 1 for a locked file, got $($locked.Code) : $($locked.Output)" } + if ($locked.Output -notmatch "pid\s+$($holder.Id)\b") { + throw "the lookup did not name the process holding the file: $($locked.Output)" + } + } finally { + try { $holder.Kill() } catch { } + try { $holder.WaitForExit(5000) | Out-Null } catch { } + } + + # The probe is started by the installer, or by an uninstaller that runs in place from the + # directory it is emptying, so the asking process holds files there itself. Naming it would + # ask the user to close the program that is asking - a question neither the close nor the + # end offer can answer, so the guard repeated it until the update was cancelled. A holder + # that is only the asking process has to come back as nothing. $handle = [IO.File]::Open($file, "Open", "ReadWrite", "None") try { - $locked = Invoke-Probe $dir + $self = Invoke-Probe $dir } finally { $handle.Dispose() } - if ($locked.Code -ne 1) { throw "expected exit 1 for a locked file, got $($locked.Code) : $($locked.Output)" } - if ($locked.Output -notmatch "pid\s+$PID\b") { throw "the lookup did not name this process: $($locked.Output)" } + if ($self.Code -ne 0) { throw "expected exit 0 when the asking process is the only holder, got $($self.Code) : $($self.Output)" } + if ($self.Output -match "pid\s+$PID\b") { throw "the lookup named the process that started it: $($self.Output)" } $empty = Join-Path $dir "empty" New-Item -ItemType Directory -Force $empty | Out-Null $nothing = Invoke-Probe $empty @@ -535,7 +583,7 @@ try { if ($byImage.Output -notmatch "fake-cockatrice\.exe \(pid\s+$($running.Id)") { throw "the lookup did not name the process running from the directory: $($byImage.Output)" } - Write-Host "holder lookup named pid $PID for a locked file, named nothing for an empty directory, and named pid $($running.Id) for a process running from the directory" + Write-Host "holder lookup named pid $($holder.Id) for a file another process holds, named nothing for a file only the asking process holds, named nothing for an empty directory, and named pid $($running.Id) for a process running from the directory" } finally { Remove-Item -Recurse -Force $dir -ErrorAction SilentlyContinue } diff --git a/cmake/NSIS.template.in b/cmake/NSIS.template.in index d54111a84..72d5287fc 100644 --- a/cmake/NSIS.template.in +++ b/cmake/NSIS.template.in @@ -42,7 +42,9 @@ Var HolderFound ; whether the payload is free, while the abort dialog reports what survived. Var GuardExit Var GuardOutput -; Whether the guard already asked Cockatrice to close once (see GuardAppsNotRunning) +; How many closes the guard has asked for: 0 before the automatic one, 1 after it, and one +; more for every "Yes" at the prompt. From the second one on, the prompt offers to end the +; instance instead (see GuardAppsNotRunning). Var GuardCloseTried Var WaitPid Var LauncherExited @@ -391,7 +393,9 @@ StrCpy $OldInstallDir $INSTDIR StrCmp $R0 "" done32 DetailPrint "Removing previous version (32-bit)..." Call BackupUserDataBeforeUninstall -ExecWait '$R0 _?=$OldInstallDir' +ExecWait '$R0 _?=$OldInstallDir' $R5 +!insertmacro CockLog "uninstall (32-bit): exit=$R5" +Call WaitForOldPayloadToDisappear Call RestoreUserDataAfterUninstall done32: @@ -406,7 +410,9 @@ ${If} ${RunningX64} StrCmp $R0 "" done64 DetailPrint "Removing previous version (64-bit)..." Call BackupUserDataBeforeUninstall - ExecWait '$R0 _?=$OldInstallDir' + ExecWait '$R0 _?=$OldInstallDir' $R5 + !insertmacro CockLog "uninstall (64-bit): exit=$R5" + Call WaitForOldPayloadToDisappear Call RestoreUserDataAfterUninstall done64: @@ -414,6 +420,44 @@ ${EndIf} FunctionEnd +; Waits for the previous installation's payload to be gone, so the sections below never +; write into a directory an old uninstaller is still emptying. +; +; ExecWait only waits for as long as the uninstaller runs in place: an NSIS uninstaller +; that did not get a usable _?= copies itself to %TEMP%, starts that copy and exits, so +; ExecWait returns at once while the copy keeps deleting - and it finishes after this +; window has closed, removing the version that was just installed. Waiting for +; cockatrice.exe to disappear covers both cases, and stops with a message instead of +; installing over a directory that is still being emptied. +Function WaitForOldPayloadToDisappear +${If} $OldInstallDir == "" + Return +${EndIf} +StrCpy $R2 0 +old_uninstall_wait: +IfFileExists "$OldInstallDir\cockatrice.exe" 0 old_uninstall_gone +IntOp $R2 $R2 + 1 +${If} $R2 > 60 + !insertmacro CockLog "old uninstall: $OldInstallDir still holds cockatrice.exe after 30 s" + StrCpy $AbortReason \ + "The previous version in $OldInstallDir is still there after its uninstaller ran.$\r$\n$\r$\nIf that uninstaller was cancelled or reported an error, close anything still using those files and run this installer again." + Call AbortInstall +${EndIf} +Sleep 500 +Goto old_uninstall_wait +old_uninstall_gone: +${If} $R2 > 0 + ; ExecWait returned while the old payload was still there: that uninstaller + ; copied itself to %TEMP%, and the copy is still working through its file list. + ; cockatrice.exe is gone before the rest of it, so let the copy settle before + ; the sections below start writing files it would otherwise remove. + Sleep 1000 + !insertmacro CockLog "old uninstall: ExecWait returned early, settled 1 s after $R2 * 500 ms" +${Else} + !insertmacro CockLog "old uninstall: $OldInstallDir was already clear when ExecWait returned" +${EndIf} +FunctionEnd + ; BackupUserDataBeforeUninstall / RestoreUserDataAfterUninstall ; ; The uninstallers shipped in 3.1.0/3.1.1 run a blanket "RMDir /r $INSTDIR". @@ -748,13 +792,15 @@ FunctionEnd ; the image lookup matches on the directory a process runs from, so it does not ; widen that set. ; -; Nothing is force-stopped anywhere: Cockatrice may be holding an unsaved deck -; or showing a "really leave this game?" confirmation, Oracle writes the card -; database in place and killing it mid-write would corrupt it, and Servatrice +; Nothing is force-stopped without the user asking for it: Cockatrice may be holding +; an unsaved deck or showing a "really leave this game?" confirmation, Oracle writes +; the card database in place and killing it mid-write would corrupt it, and Servatrice ; has no window to ask. A close is a request (WM_CLOSE), made automatically once ; because that is what the user started the installer to do, and again whenever -; they ask for it from the prompt; when it is not answered the user is told which -; program is in the way instead of having it killed. +; they ask for it from the prompt. When it is not answered the user is told which +; program is in the way, and once a close has been asked for twice without freeing +; the files they are offered to end Cockatrice themselves - the last resort, with +; what it costs spelled out, and never applied to Oracle or Servatrice. ; Emitted once for the installer and once for the uninstaller: the uninstaller ; cannot call installer functions, and its own copy of the probe keeps a running @@ -806,8 +852,9 @@ FunctionEnd ; log as it stands: it names every holder, including the ones left to the user, so a close ; that did nothing can be told apart from one that was never sent. ; -; Never a force-close: the application decides whether to quit, and one that does not answer -; is left to the user instead of being killed. +; Never a force-close here: the application decides whether to quit. Ending it is a separate +; answer the prompt offers once a close has been asked for twice and ignored, spelled out +; with what it costs - see EndHolders - and it is only ever applied to cockatrice.exe. Function ${prefix}CloseHolders Call ${prefix}ExtractHolderProbe System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")' @@ -822,6 +869,27 @@ Function ${prefix}CloseHolders Call ${prefix}CockLogWrite DetailPrint "Closing Cockatrice: $R4" FunctionEnd + +; usage: set $GuardDir to the directory being replaced, then call this. Ends the running +; Cockatrice instances the probe names. +; +; This is the only place anything is stopped without being asked to stop, so it is only +; reached after the user picked it from a prompt that says what it costs: a client that is +; still running may have an unsaved deck, a game or a settings write in flight. The probe +; ends cockatrice.exe and nothing else - Oracle writes the card database in place and +; Servatrice is a service, so both stay the user's job, exactly as they do for the close. +Function ${prefix}EndHolders + Call ${prefix}ExtractHolderProbe + System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")' + ; The probe waits up to 5 s for what it ended to be gone, so the check that follows + ; measures a settled directory instead of racing the processes it just terminated. + nsExec::ExecToStack /TIMEOUT=20000 '"$PLUGINSDIR\installer-holder-probe.exe" --end-holders' + Pop $R3 + Pop $R4 + Push "end-holders: probe exit=$R3 [$R4]" + Call ${prefix}CockLogWrite + DetailPrint "Ending Cockatrice: $R4" +FunctionEnd !macroend !insertmacro CockHolderProbeFunctions "" install @@ -835,7 +903,8 @@ FunctionEnd ; remains when the close cannot reach everything: Oracle writes the card database in ; place, Servatrice has no window, and Cockatrice may still be waiting for an answer in ; its own confirmation. It offers one more close (Yes), a plain re-check for after the -; user closed them by hand (No), and Cancel. +; user closed them by hand (No), and Cancel. Once a close has been asked for twice without +; freeing the files, it offers to end Cockatrice instead - the last resort, see EndHolders. ; ; An error is always shown, in every mode. A /S install without /R has nobody at ; the keyboard, but this installer does not promise unattended runs: when an @@ -867,13 +936,35 @@ Function GuardAppsNotRunning Goto guard_check ${EndIf} DetailPrint "The install directory is in use by: $GuardOutput" + ; A close that has been asked for twice and changed nothing is where asking again + ; stops being a plan: the instance is either waiting on a question the user has not + ; answered, or stuck where a close cannot reach it. Offer to end it - the only thing + ; left that can free the files - with the cost in the text, because a client that is + ; still running may have an unsaved deck, a game or a settings write in flight. + ${If} $GuardCloseTried >= 2 + MessageBox MB_YESNO|MB_ICONEXCLAMATION|MB_DEFBUTTON2 \ + "Cockatrice is still running after being asked to close twice.$\r$\n$\r$\nChoose Yes to end it now - an unsaved deck, a running game or a setting it has not written yet will be lost - or No to ask it to close again.$\r$\n$\r$\n$GuardOutput" \ + IDYES guard_end IDNO guard_close + !insertmacro CockLog "guard: cancelled by user" + StrCpy $AbortReason \ + "The installation was cancelled because Cockatrice, Oracle or Servatrice is still running and holding files of the previous version.$\r$\n$\r$\nClose them and run the installer again." + Call AbortInstall + ; Only reached through Yes: AbortInstall does not return. + guard_end: + !insertmacro CockLog "guard: ending cockatrice on request, then checking again" + Call EndHolders + ; Back to the ordinary prompt afterwards: Oracle and Servatrice are never ended, + ; so what is left may still have to be closed by hand. + StrCpy $GuardCloseTried 1 + Goto guard_check + ${EndIf} ; Three answers, and the close is one of them: the automatic attempt above ran ; before this prompt and could not have answered Cockatrice's own confirmation, so a ; second close is often exactly what is needed once the user has dealt with that ; window. NSIS does not let a MessageBox relabel its buttons, so the text says what ; Yes, No and Cancel do. MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ - "Cockatrice, Oracle or Servatrice is still using files of the installation:$\r$\n$\r$\n$GuardOutput$\r$\n$\r$\nChoose Yes to close Cockatrice and check again. It may ask you to confirm quitting - it does while a card database update is running, a game is open or a deck is unsaved - so answer that window.$\r$\n$\r$\nClose Oracle, Servatrice and any other program listed above by hand. Choose No to check again after that, or Cancel to cancel the installation." \ + "Cockatrice, Oracle or Servatrice is still using files of the installation:$\r$\n$\r$\n$GuardOutput$\r$\n$\r$\nChoose Yes to close Cockatrice and check again. It may ask you to confirm quitting - it does while a card database update is running, a game is open or a deck is unsaved - so answer that window. If it has no window on screen, open it from the notification area first.$\r$\n$\r$\nClose Oracle, Servatrice and any other program listed above by hand. Choose No to check again after that, or Cancel to cancel the installation." \ IDYES guard_close IDNO guard_check !insertmacro CockLog "guard: cancelled by user" StrCpy $AbortReason \ @@ -881,6 +972,7 @@ Function GuardAppsNotRunning Call AbortInstall ; Only reached through Yes: AbortInstall does not return. guard_close: + IntOp $GuardCloseTried $GuardCloseTried + 1 !insertmacro CockLog "guard: closing cockatrice on request, then checking again" Call CloseHolders Goto guard_check @@ -1517,16 +1609,33 @@ Function un.GuardAppsNotRunning Goto un_guard_check ${EndIf} DetailPrint "The install directory is in use by: $GuardOutput" + ; Same escalation as in the installer's guard: after a second close that changed + ; nothing, offer to end the instance, with what it costs in the text. + ${If} $GuardCloseTried >= 2 + MessageBox MB_YESNO|MB_ICONEXCLAMATION|MB_DEFBUTTON2 \ + "Cockatrice is still running after being asked to close twice.$\r$\n$\r$\nChoose Yes to end it now - an unsaved deck, a running game or a setting it has not written yet will be lost - or No to ask it to close again.$\r$\n$\r$\n$GuardOutput" \ + IDYES un_guard_end IDNO un_guard_close + !insertmacro CockLogUn "uninst-guard: cancelled by user" + SetErrorLevel 1 + Abort + ; Only reached through Yes: Abort does not return. + un_guard_end: + !insertmacro CockLogUn "uninst-guard: ending cockatrice on request, then checking again" + Call un.EndHolders + StrCpy $GuardCloseTried 1 + Goto un_guard_check + ${EndIf} ; Same three answers as the installer's guard; the automatic close above already ; ran once before this prompt. MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ - "Cockatrice, Oracle or Servatrice is still using files of the installation:$\r$\n$\r$\n$GuardOutput$\r$\n$\r$\nChoose Yes to close Cockatrice and check again. It may ask you to confirm quitting - it does while a card database update is running, a game is open or a deck is unsaved - so answer that window.$\r$\n$\r$\nClose Oracle, Servatrice and any other program listed above by hand. Choose No to check again after that, or Cancel to cancel the uninstallation." \ + "Cockatrice, Oracle or Servatrice is still using files of the installation:$\r$\n$\r$\n$GuardOutput$\r$\n$\r$\nChoose Yes to close Cockatrice and check again. It may ask you to confirm quitting - it does while a card database update is running, a game is open or a deck is unsaved - so answer that window. If it has no window on screen, open it from the notification area first.$\r$\n$\r$\nClose Oracle, Servatrice and any other program listed above by hand. Choose No to check again after that, or Cancel to cancel the uninstallation." \ IDYES un_guard_close IDNO un_guard_check !insertmacro CockLogUn "uninst-guard: cancelled by user" SetErrorLevel 1 Abort ; Only reached through Yes: Abort does not return. un_guard_close: + IntOp $GuardCloseTried $GuardCloseTried + 1 !insertmacro CockLogUn "uninst-guard: closing cockatrice on request, then checking again" Call un.CloseHolders Goto un_guard_check @@ -1601,7 +1710,10 @@ ${If} $PortableMode = 0 ${If} $ReinstallMode = 0 MessageBox MB_OKCANCEL|MB_ICONEXCLAMATION "A previous version of Cockatrice must be uninstalled before installing the new one." IDOK uninst32 - Abort + ; Abort would only skip this page and install over the old version anyway; + ; the message said the old one must go first, so Cancel means cancel. + !insertmacro CockLog "componentsPagePre: cancelled at the previous-version prompt" + Quit ${Else} Goto uninst32 ${EndIf} @@ -1618,10 +1730,24 @@ ${If} $PortableMode = 0 StrCpy $OldUninstallString $R0 Call EnsureOldInstallNotRunning StrCpy $OldInstallDir $GuardDir + ; Run it silently. The question above is the one the user answered; the + ; uninstaller's own confirmation page would be a second one, and that one opens + ; behind this window, where nobody can answer it. The installer then looks hung, + ; the user clicks Next on it, the new version is installed, and the uninstaller + ; removes everything once this window closes. /S leaves no window to be behind, + ; so ExecWait is waiting for something that cannot outlive it. + ReadRegStr $R1 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "QuietUninstallString" + ${If} $R1 == "" + StrCpy $OldUninstallString "$OldUninstallString /S" + ${Else} + StrCpy $OldUninstallString $R1 + ${EndIf} !insertmacro CockLog "componentsPagePre: old 32-bit uninstall '$OldUninstallString _?=$OldInstallDir' (oldDir=$OldInstallDir InstDir=$INSTDIR)" ClearErrors Call BackupUserDataBeforeUninstall - ExecWait "$OldUninstallString _?=$OldInstallDir" + ExecWait "$OldUninstallString _?=$OldInstallDir" $R5 + !insertmacro CockLog "componentsPagePre: old 32-bit uninstall exit=$R5" + Call WaitForOldPayloadToDisappear Call RestoreUserDataAfterUninstall done32: @@ -1635,7 +1761,8 @@ ${If} $PortableMode = 0 ${If} $ReinstallMode = 0 MessageBox MB_OKCANCEL|MB_ICONEXCLAMATION "A previous version of Cockatrice must be uninstalled before installing the new one." IDOK uninst64 - Abort + !insertmacro CockLog "componentsPagePre: cancelled at the previous-version prompt" + Quit ${Else} Goto uninst64 ${EndIf} @@ -1644,10 +1771,19 @@ ${If} $PortableMode = 0 StrCpy $OldUninstallString $R0 Call EnsureOldInstallNotRunning StrCpy $OldInstallDir $GuardDir + ; Silent, for the reason given in the 32-bit branch above. + ReadRegStr $R1 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "QuietUninstallString" + ${If} $R1 == "" + StrCpy $OldUninstallString "$OldUninstallString /S" + ${Else} + StrCpy $OldUninstallString $R1 + ${EndIf} !insertmacro CockLog "componentsPagePre: old 64-bit uninstall '$OldUninstallString _?=$OldInstallDir' (oldDir=$OldInstallDir InstDir=$INSTDIR)" ClearErrors Call BackupUserDataBeforeUninstall - ExecWait "$OldUninstallString _?=$OldInstallDir" + ExecWait "$OldUninstallString _?=$OldInstallDir" $R5 + !insertmacro CockLog "componentsPagePre: old 64-bit uninstall exit=$R5" + Call WaitForOldPayloadToDisappear Call RestoreUserDataAfterUninstall done64: diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index a3446e42b..a44176f38 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -923,11 +923,28 @@ void MainWindow::actShow() void MainWindow::closeEvent(QCloseEvent *event) { - // workaround Qt bug where closeEvent gets called twice - if (bClosingDown) { - return; + switch (shutdownState) { + case ShutdownState::Accepted: + // A duplicate close for a shutdown that already went through (Qt delivers the event + // twice on some platforms). Accepting it again keeps the quit that is already under + // way; ignoring it would cancel it. + event->accept(); + return; + case ShutdownState::Confirming: + // A second close request arrived while one of our own confirmations is up. Another + // program asking us to close - an installer replacing our files, for one - looks + // exactly like this, and accepting it would be destructive: close() hides this + // window, hides the dialog with it, and leaves the process running inside the nested + // loop that dialog opened. Nothing can reach that loop afterwards, so the process + // stays alive with no window and every later close request is accepted into the same + // dead end. Refuse this one and leave the question in front of the user; the shutdown + // that is already running finishes as soon as they answer it. + event->ignore(); + return; + case ShutdownState::Idle: + break; } - bClosingDown = true; + shutdownState = ShutdownState::Confirming; if (cardUpdateProcess && cardUpdateProcess->state() != QProcess::NotRunning) { const QString runningFor = cardUpdateStatusBar ? cardUpdateStatusBar->statusText() : QString(); @@ -939,19 +956,20 @@ void MainWindow::closeEvent(QCloseEvent *event) .arg(runningFor); if (!QtUtils::confirm(this, tr("Are you sure?"), message, QMessageBox::No)) { event->ignore(); - bClosingDown = false; + shutdownState = ShutdownState::Idle; return; } } if (!tabSupervisor->close()) { event->ignore(); - bClosingDown = false; + shutdownState = ShutdownState::Idle; return; } tip->close(); event->accept(); + shutdownState = ShutdownState::Accepted; SettingsCache::instance().layouts().setMainWindowGeometry(saveGeometry()); tabSupervisor->deleteLater(); } @@ -962,7 +980,7 @@ bool MainWindow::closeForUpdate() // closeEvent() is blocked on a user prompt). close() would hit the re-entrancy guard and return // true without any shutdown having happened, so report faithfully instead: the caller must keep // the process alive until the user resolves whatever is blocking the close. - if (bClosingDown) { + if (shutdownState != ShutdownState::Idle) { return false; } return close(); diff --git a/cockatrice/src/interface/window_main.h b/cockatrice/src/interface/window_main.h index bcf709cb1..98ef6a9c1 100644 --- a/cockatrice/src/interface/window_main.h +++ b/cockatrice/src/interface/window_main.h @@ -194,7 +194,17 @@ private: LagMonitor lagMonitor; ///< watches the main thread for event loop stalls LatencyStatusWidget *latencyStatus = nullptr; ///< status bar widget with live round-trip stats and history graph bool bHasActivated, askedForDbUpdater; - bool bClosingDown = false; ///< guards closeEvent() against re-entrancy + /** + * How far a shutdown that was asked for has got. A close request that arrives while one is + * already asking the user something must not be accepted - see closeEvent(). + */ + enum class ShutdownState + { + Idle, ///< no shutdown has been asked for + Confirming, ///< asking about a card database update, open games or an unsaved deck + Accepted, ///< the close was accepted and the window is hiding + }; + ShutdownState shutdownState = ShutdownState::Idle; bool skipStartupAutoConnect = false; bool startupAutoConnectAttempted = false; bool firstRunWizardActive = false; diff --git a/tools/installer-holder-probe/close_holders.cpp b/tools/installer-holder-probe/close_holders.cpp index 508d9b3c1..d5bd5251f 100644 --- a/tools/installer-holder-probe/close_holders.cpp +++ b/tools/installer-holder-probe/close_holders.cpp @@ -44,39 +44,123 @@ std::wstring processLabel(const Holder &holder) return label; } +/// Joins the per-holder lines into the one line the installer logs and cuts it to what one +/// NSIS string holds. +std::wstring joinReport(const std::vector &lines) +{ + std::wstring report; + for (const std::wstring &line : lines) { + if (!report.empty()) { + report += L"; "; + } + report += line; + } + if (report.size() > REPORT_CHAR_MAX) { + report.erase(REPORT_CHAR_MAX); + report += L"..."; + } + return report; +} + struct WindowSearch { DWORD processId = 0; - HWND topmost = nullptr; ///< first visible window in Z-order: a dialog when one is up - HWND main = nullptr; ///< first visible window without an owner: the one to close - HWND any = nullptr; ///< first window at all, used when nothing is visible + std::vector windows; ///< every top-level window of the process, in Z-order }; -BOOL CALLBACK findProcessWindow(HWND window, LPARAM parameter) +BOOL CALLBACK collectProcessWindow(HWND window, LPARAM parameter) { auto &search = *reinterpret_cast(parameter); DWORD processId = 0; GetWindowThreadProcessId(window, &processId); - if (processId != search.processId) { - return TRUE; - } - if (search.any == nullptr) { - search.any = window; - } - // A tray icon leaves the main window hidden, so a window that is not visible is still - // kept as a fallback. EnumWindows walks the Z-order top down, so the first visible - // window is the one the user would see in front. - if (IsWindowVisible(window) != FALSE) { - if (search.topmost == nullptr) { - search.topmost = window; - } - if (search.main == nullptr && GetWindow(window, GW_OWNER) == nullptr) { - search.main = window; - } + if (processId == search.processId) { + search.windows.push_back(window); } return TRUE; } +/// Whether a window is one the user could be asked to answer. +/// +/// Only a framed, non-empty window qualifies. Qt keeps hidden top-level windows of its own +/// next to the application's: the tray icon creates one to receive the TaskbarCreated +/// message, styled WS_OVERLAPPED - which is no frame at all - and titled +/// QTrayIconMessageWindow. Showing one of those puts an empty white rectangle on the +/// taskbar that nothing ever closes again: it has no window procedure of its own to hide it, +/// and the process is busy in the loop this is meant to break, so it stays until the process +/// dies. Posting a close to one is worse than useless too: Qt's tray window answers WM_CLOSE +/// by asking the application to terminate, without going through the confirmation the close +/// is supposed to reach. +bool isAnswerableWindow(HWND window) +{ + if ((GetWindowLongPtr(window, GWL_EXSTYLE) & WS_EX_TOOLWINDOW) != 0) { + return false; + } + if ((GetWindowLongPtr(window, GWL_STYLE) & WS_CAPTION) == 0) { + return false; + } + RECT bounds{}; + if (GetWindowRect(window, &bounds) == FALSE) { + return false; + } + return bounds.right > bounds.left && bounds.bottom > bounds.top; +} + +/// Picks the window to post the close to and the one the user should end up looking at. +struct CloseTarget +{ + HWND main = nullptr; ///< first visible window without an owner: the application's own + HWND toRaise = nullptr; ///< topmost visible window: a dialog when one is up + bool modalOpen = false; ///< the window a close would reach is disabled, so a dialog of its own is up +}; + +/// Only the windows the user could answer are candidates - see isAnswerableWindow. An +/// instance left with nothing but Qt's own bookkeeping windows visible has no window to ask: +/// the caller reports that instead of posting a close the application would answer with a +/// terminate that skips its own confirmation. +CloseTarget chooseCloseTarget(const std::vector &windows) +{ + CloseTarget target; + HWND topmost = nullptr; + for (const HWND window : windows) { + if (IsWindowVisible(window) == FALSE || !isAnswerableWindow(window)) { + continue; + } + if (topmost == nullptr) { + topmost = window; + } + if (target.main == nullptr && GetWindow(window, GW_OWNER) == nullptr) { + target.main = window; + } + } + target.toRaise = topmost != nullptr ? topmost : target.main; + // A modal dialog disables the windows behind it, so a disabled main window is the + // instance asking a question of its own. + target.modalOpen = + target.toRaise != nullptr && IsWindowEnabled(target.main != nullptr ? target.main : target.toRaise) == FALSE; + return target; +} + +/// What is left of the window the close was posted to, so a log line tells an instance that +/// answered by hiding from one that never answered at all. A process that hid its window and +/// stayed alive is the one that needs the next close to bring it back into view. +std::wstring describeWindowState(HWND window) +{ + if (window == nullptr || IsWindow(window) == FALSE) { + return L", its window is gone"; + } + // WM_NULL is the cheapest way to ask whether the thread behind the window still dispatches. + // A hung instance does not answer at all, which is a different problem from one that + // answered the close and stayed open, and the two want different words in the prompt. + DWORD_PTR ignored = 0; + const bool responding = + SendMessageTimeoutW(window, WM_NULL, 0, 0, SMTO_ABORTIFHUNG | SMTO_BLOCK, 1000, &ignored) != 0; + std::wstring state = IsWindowVisible(window) != FALSE ? L", window still open" : L", window hidden again"; + if (!responding) { + state += L" and not responding"; + } + return state; +} + /// Restores a minimized window and takes the foreground, so a confirmation Cockatrice /// answers the close with is in front of the installer instead of behind it. /// @@ -133,6 +217,7 @@ CloseHoldersResult closeCockatriceHolders(const std::vector &holders, un std::vector lines; std::vector asked; std::vector askedLines; + std::vector askedWindows; for (const Holder &holder : holders) { if (holder.isService || !isCockatriceImage(holder.imageName)) { @@ -144,23 +229,41 @@ CloseHoldersResult closeCockatriceHolders(const std::vector &holders, un WindowSearch search; search.processId = holder.processId; - EnumWindows(findProcessWindow, reinterpret_cast(&search)); - if (search.any == nullptr) { + EnumWindows(collectProcessWindow, reinterpret_cast(&search)); + if (search.windows.empty()) { lines.push_back(processLabel(holder) + L" has no window to close, left to you"); continue; } - const HWND target = - search.main != nullptr ? search.main : (search.topmost != nullptr ? search.topmost : search.any); - const HWND toRaise = search.topmost != nullptr ? search.topmost : target; + const CloseTarget target = chooseCloseTarget(search.windows); + if (target.main == nullptr) { + // Nothing visible that a user could answer: the instance is hidden - in the + // notification area, say - or has nothing but Qt's own bookkeeping windows on + // screen. Neither is a window a close could reach, and showing one from here does + // not help: Windows would display it while the application still believes it is + // hidden, so it stays blank and answers nothing. The instance is named and left to + // the user, who can open it from the notification area. + lines.push_back(processLabel(holder) + L" has no window to ask, left to you"); + continue; + } std::wstring line = processLabel(holder); - line += bringForward(toRaise) ? L" brought forward" : L" could not be brought forward"; + line += bringForward(target.toRaise) ? L" brought forward" : L" could not be brought forward"; + + // Posting the close to an instance that is asking a question of its own is what made + // an earlier attempt useless: the client accepts it, hides this window and the dialog + // with it, and stays alive inside the nested loop the dialog opened, where nothing can + // reach it any more. The question is the request now, so it is brought forward and + // left alone. + if (target.modalOpen) { + lines.push_back(line + L", and is waiting for an answer to a question of its own"); + continue; + } // Kept open across the wait so a pid reused after the process exits cannot be // mistaken for the instance that was asked to close. const HANDLE process = OpenProcess(SYNCHRONIZE, FALSE, holder.processId); const DWORD openError = process == nullptr ? GetLastError() : ERROR_SUCCESS; - if (PostMessageW(target, WM_CLOSE, 0, 0) == FALSE) { + if (PostMessageW(target.main, WM_CLOSE, 0, 0) == FALSE) { // Read the error before CloseHandle, which is free to reset it. const DWORD postError = GetLastError(); if (process != nullptr) { @@ -177,6 +280,7 @@ CloseHoldersResult closeCockatriceHolders(const std::vector &holders, un } else { askedLines.push_back(lines.size()); asked.push_back(process); + askedWindows.push_back(target.main); } lines.push_back(std::move(line)); } @@ -186,8 +290,12 @@ CloseHoldersResult closeCockatriceHolders(const std::vector &holders, un const bool allExited = waitForExit(asked, waitMilliseconds, elapsed); for (std::size_t index = 0; index < asked.size(); ++index) { const bool exited = allExited || WaitForSingleObject(asked[index], 0) == WAIT_OBJECT_0; - lines[askedLines[index]] += exited ? L", exited after " + std::to_wstring(elapsed) + L" ms" - : L", still running after " + std::to_wstring(elapsed) + L" ms"; + if (exited) { + lines[askedLines[index]] += L", exited after " + std::to_wstring(elapsed) + L" ms"; + } else { + lines[askedLines[index]] += L", still running after " + std::to_wstring(elapsed) + L" ms" + + describeWindowState(askedWindows[index]); + } } } for (const HANDLE process : asked) { @@ -198,15 +306,61 @@ CloseHoldersResult closeCockatriceHolders(const std::vector &holders, un result.report = L"nothing is holding the files"; return result; } - for (const std::wstring &line : lines) { - if (!result.report.empty()) { - result.report += L"; "; - } - result.report += line; - } - if (result.report.size() > REPORT_CHAR_MAX) { - result.report.erase(REPORT_CHAR_MAX); - result.report += L"..."; - } + result.report = joinReport(lines); + return result; +} + +EndHoldersResult endCockatriceHolders(const std::vector &holders, unsigned long waitMilliseconds) +{ + EndHoldersResult result; + std::vector lines; + std::vector ended; + std::vector endedLines; + + for (const Holder &holder : holders) { + if (holder.isService || !isCockatriceImage(holder.imageName)) { + // The same split as the close: Oracle writes the card database in place and a + // service has nothing to ask, so neither is ever ended here. + lines.push_back(processLabel(holder) + L" left to you"); + continue; + } + + const HANDLE process = OpenProcess(PROCESS_TERMINATE | SYNCHRONIZE, FALSE, holder.processId); + if (process == nullptr) { + lines.push_back(processLabel(holder) + L" could not be ended (win32 " + std::to_wstring(GetLastError()) + + L")"); + continue; + } + if (TerminateProcess(process, 1) == FALSE) { + // Read the error before CloseHandle, which is free to reset it. + const DWORD error = GetLastError(); + CloseHandle(process); + lines.push_back(processLabel(holder) + L" could not be ended (win32 " + std::to_wstring(error) + L")"); + continue; + } + ++result.ended; + endedLines.push_back(lines.size()); + lines.push_back(processLabel(holder) + L" ended"); + ended.push_back(process); + } + + if (!ended.empty() && waitMilliseconds > 0) { + unsigned long long elapsed = 0; + const bool allExited = waitForExit(ended, waitMilliseconds, elapsed); + for (std::size_t index = 0; index < ended.size(); ++index) { + const bool exited = allExited || WaitForSingleObject(ended[index], 0) == WAIT_OBJECT_0; + lines[endedLines[index]] += exited ? L", exited after " + std::to_wstring(elapsed) + L" ms" + : L", still running after " + std::to_wstring(elapsed) + L" ms"; + } + } + for (const HANDLE process : ended) { + CloseHandle(process); + } + + if (lines.empty()) { + result.report = L"nothing is holding the files"; + return result; + } + result.report = joinReport(lines); return result; } diff --git a/tools/installer-holder-probe/close_holders.h b/tools/installer-holder-probe/close_holders.h index 427da5ea6..4f9f5f0af 100644 --- a/tools/installer-holder-probe/close_holders.h +++ b/tools/installer-holder-probe/close_holders.h @@ -28,19 +28,55 @@ struct CloseHoldersResult * asked to close. Oracle and Servatrice are left to the user - Oracle writes the card * database in place - and so is every service. * - * Two things happen per instance. Its top-level window is restored and brought to the - * foreground, because Cockatrice may answer the close with a confirmation of its own that - * would otherwise open behind the installer's prompt, and then WM_CLOSE is posted to the - * main window, the same request as clicking its close button. Stopping the process is - * never attempted. + * One thing happens per instance: its main window is brought to the foreground, because + * Cockatrice may answer the close with a confirmation of its own that would otherwise open + * behind the installer's prompt. Then WM_CLOSE is posted to the main window, the same + * request as clicking its close button - unless the instance is asking a question of its + * own, which disables that window: posting the close then only hides the question, so it is + * brought forward and left to the user. An instance with no window a user could answer - + * one hidden in the notification area, say - is named and left to the user as well; showing + * its windows from here does not help, because the application would still believe they are + * hidden and never paint them. Stopping the process is never attempted. * * Once the requests are out, this waits up to @c waitMilliseconds for the processes to * exit, so the caller can measure the purge immediately afterwards instead of sleeping - * for a fixed time or re-asking a question this call already answered. + * for a fixed time or re-asking a question this call already answered. A process that is + * still running is described further: whether its window is gone, still open, hidden + * again, and whether it answers at all, so the log can tell an instance that refused the + * close from one that never received it. * * @param holders the Restart Manager answer, one entry per application or service * @param waitMilliseconds how long to wait for the asked instances to exit, 0 to not wait */ CloseHoldersResult closeCockatriceHolders(const std::vector &holders, unsigned long waitMilliseconds); +/** + * @brief What ending the holders did, in the form the installer logs. + * + * @c report is one line naming every holder, including the ones this deliberately leaves + * alone, because the installer repeats it in its log. + */ +struct EndHoldersResult +{ + unsigned int ended = 0; ///< holders TerminateProcess was accepted for + std::wstring report; ///< one line, narrowed to ASCII when written +}; + +/** + * @brief Ends every running Cockatrice the probe named. + * + * This is the last resort behind the installer's "end it now" answer, never a first move: a + * client that is still running may have an unsaved deck, an open game or a settings write in + * flight, so the caller only gets here after the user picked it from a prompt that says so. + * Only cockatrice.exe is ended - Oracle writes the card database in place and Servatrice is a + * service, so both are named and left to the user, exactly as they are for the close. + * + * Waits up to @c waitMilliseconds for what it ended to be gone, so the caller can measure the + * purge immediately afterwards instead of racing a process that is still exiting. + * + * @param holders what the probe named, one entry per application or service + * @param waitMilliseconds how long to wait for the ended instances to be gone, 0 to not wait + */ +EndHoldersResult endCockatriceHolders(const std::vector &holders, unsigned long waitMilliseconds); + #endif diff --git a/tools/installer-holder-probe/main.cpp b/tools/installer-holder-probe/main.cpp index db46035d3..3f1556b75 100644 --- a/tools/installer-holder-probe/main.cpp +++ b/tools/installer-holder-probe/main.cpp @@ -20,6 +20,11 @@ constexpr wchar_t INSTALL_DIR_VARIABLE[] = L"COCKATRICE_GUARD_DIR"; /// exit it is waiting for. constexpr unsigned long CLOSE_WAIT_MILLISECONDS = 30000; +/// How long the end mode waits for what it terminated to be gone. TerminateProcess is +/// asynchronous, and the installer measures the purge right after this returns, so a process +/// that is still tearing down would otherwise be reported as a holder for one more round. +constexpr unsigned long END_WAIT_MILLISECONDS = 5000; + /// Exit codes the installer reads. "Something was found" is deliberately not 0, so an /// answer of "nothing holds the files" stays distinguishable from a probe that failed. /// In the close mode 1 means a close was actually sent and 0 that every holder was left @@ -34,22 +39,40 @@ enum ExitCode EXIT_PROBE_FAILED = 3, }; -/// The probe has two modes, selected by the first argument: +/// The probe has three modes, selected by the first argument: /// /// (none) name what holds the payload, one line, for the guard prompt and the /// abort dialog/log /// --close-holders bring the running Cockatrice forward, ask it to close, and wait for /// it to exit, for the offer to close after a failed purge +/// --end-holders end the running Cockatrice and wait for it to be gone, for the last +/// resort the guard prompt offers after a close was asked for twice /// -/// Both answer from two lookups that cover each other's blind spot: the Restart Manager +/// All three answer from two lookups that cover each other's blind spot: the Restart Manager /// names whoever has a payload file open, and every process running from the directory is /// named as well, because a running instance holds its own image and the modules it /// loaded as mappings rather than open files and the Restart Manager does not always /// report those. Neither lookup alone is the guard's authority: an instance the file /// handles do not name is exactly the case that left a half-replaced install behind. -bool closeModeRequested(int argumentCount, char **arguments) +enum class Mode { - return argumentCount > 1 && std::string(arguments[1]) == "--close-holders"; + Name, ///< name the holders + Close, ///< ask the holders to close + End, ///< end the holders +}; + +Mode requestedMode(int argumentCount, char **arguments) +{ + if (argumentCount > 1) { + const std::string mode(arguments[1]); + if (mode == "--close-holders") { + return Mode::Close; + } + if (mode == "--end-holders") { + return Mode::End; + } + } + return Mode::Name; } /// Writes one line to the pipe nsExec reads. Narrowed to ASCII on purpose: the bytes @@ -96,10 +119,17 @@ int main(int argumentCount, char **arguments) // to close, and the Restart Manager may not name any of them (see // findProcessesRunningFrom). The file holders follow, deduplicated by process, so an // instance that both runs from the directory and holds a payload file is named once. + // Neither lookup may name the probe or the process that started it, whichever one found + // it: an installer or uninstaller running from the directory it is replacing would + // otherwise be named as a holder, and closing it is a request nobody can answer. std::vector holders = findProcessesRunningFrom(installDir); + const std::vector asking = askingProcessIds(); std::vector fileHolders; const unsigned long error = findProcessesUsingFiles(collectPayloadFiles(installDir), fileHolders); for (const Holder &holder : fileHolders) { + if (std::find(asking.begin(), asking.end(), holder.processId) != asking.end()) { + continue; + } const bool alreadyNamed = std::any_of(holders.begin(), holders.end(), [&holder](const Holder &named) { return named.processId == holder.processId; }); @@ -116,20 +146,25 @@ int main(int argumentCount, char **arguments) return EXIT_PROBE_FAILED; } - const bool closeMode = closeModeRequested(argumentCount, arguments); + const Mode mode = requestedMode(argumentCount, arguments); if (holders.empty()) { // No output in the naming mode: the installer words the "nothing is holding them" - // case, and it owns the message either way. The close mode says it itself, because - // its line is logged verbatim next to what it was asked to do. - if (closeMode) { + // case, and it owns the message either way. The acting modes say it themselves, + // because their line is logged verbatim next to what they were asked to do. + if (mode != Mode::Name) { writeLine(L"nothing is holding the files"); } return EXIT_NO_HOLDERS; } - if (!closeMode) { + if (mode == Mode::Name) { writeLine(formatHolders(holders)); return EXIT_HOLDERS_FOUND; } + if (mode == Mode::End) { + const EndHoldersResult result = endCockatriceHolders(holders, END_WAIT_MILLISECONDS); + writeLine(result.report); + return result.ended > 0 ? EXIT_HOLDERS_FOUND : EXIT_NO_HOLDERS; + } const CloseHoldersResult result = closeCockatriceHolders(holders, CLOSE_WAIT_MILLISECONDS); writeLine(result.report); diff --git a/tools/installer-holder-probe/restart_manager_holders.cpp b/tools/installer-holder-probe/restart_manager_holders.cpp index c00e5da8c..1abfcb352 100644 --- a/tools/installer-holder-probe/restart_manager_holders.cpp +++ b/tools/installer-holder-probe/restart_manager_holders.cpp @@ -22,9 +22,28 @@ constexpr std::size_t MAX_PROBED_FILES = 2000; /// How deep the Plugins walk goes. Its real shape is Plugins\\.dll. constexpr int MAX_PLUGIN_DEPTH = 4; +/// How many parents askingProcessIds() walks up before it stops on its own. A real chain +/// is the probe, the installer or uninstaller, and the shell that started it; the bound +/// only keeps a snapshot with a cycle in it from walking forever. +constexpr int MAX_ASKER_LEVELS = 8; + /// The payload files the installer replaces by name; the DLLs are matched by mask below. constexpr const wchar_t *PAYLOAD_FILES[] = {L"cockatrice.exe", L"oracle.exe", L"servatrice.exe", L"qt.conf"}; +/// The payload programs, by image name. A process running one of these is what the guard +/// exists to name, so askingProcessIds() stops its walk up the parent chain at one. +constexpr const wchar_t *PAYLOAD_PROGRAMS[] = {L"cockatrice.exe", L"oracle.exe", L"servatrice.exe"}; + +bool isPayloadProgram(const std::wstring &imageName) +{ + for (const wchar_t *program : PAYLOAD_PROGRAMS) { + if (CompareStringOrdinal(imageName.c_str(), -1, program, -1, TRUE) == CSTR_EQUAL) { + return true; + } + } + return false; +} + void collectFilesOf(const std::wstring &directory, int depth, int maxDepth, std::vector &files) { if (files.size() >= MAX_PROBED_FILES) { @@ -214,6 +233,45 @@ unsigned long findProcessesUsingFiles(const std::vector &files, st return error; } +std::vector askingProcessIds() +{ + std::vector asking; + asking.push_back(GetCurrentProcessId()); + + // One snapshot for the whole walk: the parent ids come from it, so a level costs no + // second lookup. + std::vector processes; + const HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if (snapshot == INVALID_HANDLE_VALUE) { + return asking; + } + PROCESSENTRY32W entry{}; + entry.dwSize = sizeof(entry); + if (Process32FirstW(snapshot, &entry) != FALSE) { + do { + processes.push_back(entry); + } while (Process32NextW(snapshot, &entry) != FALSE); + } + CloseHandle(snapshot); + + unsigned long current = asking.back(); + for (int level = 0; level < MAX_ASKER_LEVELS; ++level) { + const auto named = std::find_if(processes.begin(), processes.end(), [current](const PROCESSENTRY32W &process) { + return process.th32ProcessID == current; + }); + if (named == processes.end() || named->th32ParentProcessID == 0) { + break; + } + const unsigned long parent = named->th32ParentProcessID; + if (isPayloadProgram(fileNameOf(imagePathOf(parent)))) { + break; + } + asking.push_back(parent); + current = parent; + } + return asking; +} + std::vector findProcessesRunningFrom(const std::wstring &installDir) { std::vector holders; @@ -222,10 +280,10 @@ std::vector findProcessesRunningFrom(const std::wstring &installDir) } const std::wstring prefix = directoryPrefixOf(installDir); - // Never the probe itself: it runs from the directory being replaced when the - // uninstaller asks in place or the installer was copied next to the payload, and - // naming it would ask the user to close the process that is doing the asking. - const DWORD self = GetCurrentProcessId(); + // Never the probe, and never the installer or uninstaller that started it: either can + // run from the directory being replaced, and naming one asks the user to close the + // program that is asking (see askingProcessIds). + const std::vector asking = askingProcessIds(); const HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); if (snapshot == INVALID_HANDLE_VALUE) { @@ -235,7 +293,7 @@ std::vector findProcessesRunningFrom(const std::wstring &installDir) entry.dwSize = sizeof(entry); if (Process32FirstW(snapshot, &entry) != FALSE) { do { - if (entry.th32ProcessID == self) { + if (std::find(asking.begin(), asking.end(), entry.th32ProcessID) != asking.end()) { continue; } // One process at a time, because the path is per process; a process that diff --git a/tools/installer-holder-probe/restart_manager_holders.h b/tools/installer-holder-probe/restart_manager_holders.h index 6dcd91108..044cbe449 100644 --- a/tools/installer-holder-probe/restart_manager_holders.h +++ b/tools/installer-holder-probe/restart_manager_holders.h @@ -52,9 +52,30 @@ unsigned long findProcessesUsingFiles(const std::vector &files, st * database server next to a Cockatrice install) is never named. * * @param installDir the directory being replaced, as a full path. - * @return one entry per process running from it, in snapshot order, without the probe - * itself; empty when the directory is empty or no process runs from it. + * @return one entry per process running from it, in snapshot order, without the processes + * named by askingProcessIds(); empty when the directory is empty or no process + * runs from it. */ std::vector findProcessesRunningFrom(const std::wstring &installDir); +/** + * @brief The processes that must never be named as holders: the probe and the ones that + * started it. + * + * The installer or uninstaller doing the asking can legitimately run from the directory it + * is replacing - the old uninstaller is started with _?= so it stays in $INSTDIR, and an + * installer can be downloaded into the folder - and naming it asks the user to close the + * program that is asking. That cannot be answered: the close skips it, the end offer skips + * it, and the guard loops until the user cancels, which is what a run that named itself + * did. + * + * The walk up the parent chain stops at the first program the payload installs, because in + * an in-app update the installer's own parent is the Cockatrice instance being replaced - + * the one the guard exists to name. + * + * @return process ids, the probe first, then its ancestors while they are not payload + * programs; only the probe itself when the snapshot fails. + */ +std::vector askingProcessIds(); + #endif From bf3562a85af384f1eda194db83fadbdf24846841 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:09:21 +0100 Subject: [PATCH 041/108] [Installer] Offer to end running Cockatrice immediately when auto-close fails (#7675) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Installer] Offer to end running Cockatrice immediately when auto-close fails * [Installer] Keep the way out that costs nothing in the first prompt The prompt now offers to end Cockatrice, so what the deleted close-again text said has to survive in it: an instance the probe reports as waiting for an answer to a question of its own has been brought forward, and answering that window is the way out that ends nothing. Say so, and say where to go when Yes leaves it listed - Task Manager - so an end that cannot take effect does not loop on the same dialog. * [Installer] Bring the comments back in line with the code GuardCloseTried no longer counts closes, and the file header, CloseHolders and GuardAppsNotRunning still described an escalation after a second close, as did the probe's --end-holders note. * [Installer] End Oracle and Servatrice too when the guard's end answer is confirmed The guard's end answer only ever stopped cockatrice.exe, so an Oracle holding the card database or a Servatrice service kept the payload locked and the prompt came back with nothing left that could free it. The probe now ends every program the payload installs, services included: isPayloadProgram() - the list restart_manager_holders already used for its parent walk - decides the set, so a scanner holding a DLL or a program the user keeps in the install directory is still named and left to the user. A service is terminated rather than sent a stop through the SCM, which notices the process exit and marks it stopped afterwards. The guard prompt now says what ending costs: an unsaved deck, an unwritten setting, players on a running Servatrice, and an Oracle mid-way through the card database. It also keeps the notification-area hint, which the probe's "has no window to ask" answer depends on, and drops the claim that Oracle and Servatrice had been brought forward. * [Installer] Give every running-program prompt the same end-or-retry answers Four prompts meet a running program and each offered a different set: the guard ended it, the launcher wait only kept waiting, and the recovery prompt after a failed purge could only ask Cockatrice to close. With Oracle or Servatrice holding the files, that last one had no answer that could free anything - Yes and No both retried the purge and Cancel aborted an install whose old version was already gone. All four now follow the guard's shape: one automatic attempt, then a prompt whose Yes ends the programs it names and whose No retries, with Cancel to give up. - RecoverFromHolders closes once and purges before it asks anything, then offers EndHolders - the same end answer as the guard - instead of a close the user had to trigger himself. - WaitForLauncherToExit can end the launcher by pid, the one process it holds a pid for, rather than looping 60 s waits with Cancel as the only way out. - END_COST and AFTER_CLOSING_GUIDANCE are !defines, so the cost of ending and the guidance for the answers that end nothing read the same in every prompt. * [Installer] End the launcher through the handle it was waited on The "end it" answer used to look the launcher's pid up again after a dialog that can stay open as long as the user takes, so a pid that had been reused in the meantime - or a /PID= that was never the launcher - would be terminated instead. The handle the wait already holds names that process and cannot be handed to another, so the terminate answer now acts on it. PROCESS_TERMINATE is requested along with PROCESS_SYNCHRONIZE; when only the synchronize right is granted the launcher is still waited on and the answer falls back to asking it to close, instead of reporting a live launcher as gone and writing the payload over it. * [Installer] Say what the end answer ends, and report a failed end The prompt offered "end every program listed above", but EndHolders only ends the programs the payload installs: a scanner holding a payload DLL, or a service the Restart Manager names by its long name, was listed and then silently left alone, so Yes looked like it did nothing and the same dialog came back unchanged. The wording now says which programs Yes ends and that anything else has to be closed by hand, and the round after an end that did not free the files says so, so the same dialog no longer reappears word for word. The three prompts are one macro so they cannot drift apart again. The added sentences eat into the 1024 characters one NSIS string holds, so the holder line's budget drops from 400 to 320 characters to keep the longest message (cancel line included) inside it: 977 characters, measured against the worst case the probe can produce. --------- Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> Co-authored-by: Lukas Brübach --- cmake/NSIS.template.in | 355 +++++++++++------- .../installer-holder-probe/close_holders.cpp | 13 +- tools/installer-holder-probe/close_holders.h | 17 +- tools/installer-holder-probe/holder_line.h | 13 +- tools/installer-holder-probe/main.cpp | 7 +- .../restart_manager_holders.cpp | 20 +- .../restart_manager_holders.h | 12 + 7 files changed, 269 insertions(+), 168 deletions(-) diff --git a/cmake/NSIS.template.in b/cmake/NSIS.template.in index 72d5287fc..6b861a370 100644 --- a/cmake/NSIS.template.in +++ b/cmake/NSIS.template.in @@ -42,16 +42,27 @@ Var HolderFound ; whether the payload is free, while the abort dialog reports what survived. Var GuardExit Var GuardOutput -; How many closes the guard has asked for: 0 before the automatic one, 1 after it, and one -; more for every "Yes" at the prompt. From the second one on, the prompt offers to end the -; instance instead (see GuardAppsNotRunning). +; Whether the guard has already asked Cockatrice to close by itself: 0 before the automatic +; close, 1 after it. The prompt that follows offers to end the instance rather than asking +; a second time (see GuardAppsNotRunning). Var GuardCloseTried +; Set when the user answered Yes to ending the programs and the files were still in use +; afterwards, so the prompt that follows says the end did not help instead of reappearing +; word for word (see CockEndPrompt). +Var GuardEndFailed +; The question CockEndPrompt assembles: one sentence in it is conditional, and the result +; has to stay inside the characters one NSIS string holds. +Var GuardPrompt Var WaitPid Var LauncherExited ; How long one WaitForSingleObject on the launcher's handle waits before asking the user ; (see WaitForLauncherToExit). A variable because the first slice is short and the ones ; after the first question are not. Var WaitSlice +; Whether that handle carries PROCESS_TERMINATE as well as SYNCHRONIZE, and can therefore +; be ended from the answer the dialog offers. Cleared when only the synchronize right could +; be had (see WaitForLauncherToExit). +Var WaitCanEnd Var InstallLogPath Var LogHandle Var LogMessage @@ -304,10 +315,10 @@ ${EndIf} ; ; Then make sure no *other* application instance is still running (and holding ; file locks) before the previous version is uninstalled. The probe names them -; and the user is asked to close them; nothing is ever force-stopped, because -; Cockatrice may hold an unsaved deck and Oracle writes the card database in -; place. On /R the old version is about to be removed, so an answer the probe -; cannot give cancels the upgrade before it starts rather than after it. +; and the user is asked to close them; nothing is force-stopped without that +; answer, because Cockatrice may hold an unsaved deck and Oracle writes the card +; database in place. On /R the old version is about to be removed, so an answer +; the probe cannot give cancels the upgrade before it starts rather than after it. ; Only needed this early for /R, where AutoUninstallIfNeeded runs below; for ; interactive installs the section re-checks after the directory page, when ; $INSTDIR is the directory the user actually picked. @@ -794,13 +805,49 @@ FunctionEnd ; ; Nothing is force-stopped without the user asking for it: Cockatrice may be holding ; an unsaved deck or showing a "really leave this game?" confirmation, Oracle writes -; the card database in place and killing it mid-write would corrupt it, and Servatrice -; has no window to ask. A close is a request (WM_CLOSE), made automatically once -; because that is what the user started the installer to do, and again whenever -; they ask for it from the prompt. When it is not answered the user is told which -; program is in the way, and once a close has been asked for twice without freeing -; the files they are offered to end Cockatrice themselves - the last resort, with -; what it costs spelled out, and never applied to Oracle or Servatrice. +; the card database in place and can be killed mid-write, and Servatrice may be serving +; a game. A close is a request (WM_CLOSE), made automatically once because that is what +; the user started the installer to do, and only Cockatrice can answer one - the other +; two have no window. When the close is not answered the user is told which program is +; in the way and is offered to end all three themselves - the last resort, with what it +; costs spelled out in the prompt. + +; One sentence for every prompt whose Yes ends the whole set, so that answer reads the same +; wherever the user meets it: this guard before anything is replaced, the uninstaller's, and +; the recovery prompt after a failed purge. +!define END_COST "An unsaved deck, a running game or an unwritten setting is lost, Servatrice drops its players, and an Oracle that is mid-write can damage the card database." + +; The other half of that answer - what is left to do when nothing is ended - is shared by +; every prompt that names the holders, for the same reason. +!define AFTER_CLOSING_GUIDANCE "Answer the confirmation Cockatrice may be showing - open it from the notification area if it is hidden there - and use Task Manager for a program that would not end." + +; usage: set the variable named by the second argument to the probe's listing, then call +; this. Sets $GuardPrompt to the question the guard, the uninstaller's guard and the +; recovery path all ask. +; +; One macro rather than three copies, because the wording is a promise: Yes ends the +; programs the payload installs - which is exactly the set EndHolders acts on - and anything +; else the probe named has to be closed by hand, so a listing that includes a scanner or a +; service hosted elsewhere does not come with an answer that cannot do what it says. +; +; The assembled string has to fit the characters one NSIS string holds, which is what +; bounds HOLDER_LINE_CHAR_MAX in tools\installer-holder-probe\holder_line.h: with the +; longest listing that can arrive and the sentence only a failed end adds, it stays inside +; that budget instead of being cut off at the tail, where the Cancel line is. +; +; @param header the sentence that introduces the listing, with its trailing colon +; @param listing the variable holding the probe's line, $GuardOutput or $HolderOutput +; @param cancelWord what Cancel gives up, "installation" or "uninstallation" +!macro CockEndPrompt header listing cancelWord + StrCpy $GuardPrompt "${header}$\r$\n$\r$\n${listing}" + ; Said only after the previous answer was Yes: without it the same dialog reappears word + ; for word, which is what "nothing happened" looked like - the end can fail on a process + ; the installer may not open, and it never touches a program that is not one of ours. + ${If} $GuardEndFailed = 1 + StrCpy $GuardPrompt "$GuardPrompt$\r$\n$\r$\nEnding them did not free the files." + ${EndIf} + StrCpy $GuardPrompt "$GuardPrompt$\r$\n$\r$\nChoose Yes to end the Cockatrice, Oracle and Servatrice programs listed above. ${END_COST}$\r$\n$\r$\nChoose No to try again after closing anything else listed above by hand. ${AFTER_CLOSING_GUIDANCE}$\r$\n$\r$\nChoose Cancel to cancel the ${cancelWord}." +!macroend ; Emitted once for the installer and once for the uninstaller: the uninstaller ; cannot call installer functions, and its own copy of the probe keeps a running @@ -840,7 +887,7 @@ FunctionEnd ; replaced, plus the processes running from the directory. tools\installer-holder-probe ; closes that set by pid, which is why nothing here has to match an executable path. Only ; cockatrice.exe is asked: Oracle writes the card database in place and Servatrice has no -; window, so both stay the user's job, as the prompt says. +; window to ask, so both are left alone here and only ended by the explicit answer. ; ; Each instance is brought to the foreground before WM_CLOSE is posted to its main window. ; WM_CLOSE is answered, not obeyed - Cockatrice asks before it quits while a card database @@ -852,9 +899,9 @@ FunctionEnd ; log as it stands: it names every holder, including the ones left to the user, so a close ; that did nothing can be told apart from one that was never sent. ; -; Never a force-close here: the application decides whether to quit. Ending it is a separate -; answer the prompt offers once a close has been asked for twice and ignored, spelled out -; with what it costs - see EndHolders - and it is only ever applied to cockatrice.exe. +; Never a force-close here: the application decides whether to quit. Ending is a separate +; answer the guard prompt offers when a close did not free the files, spelled out with what +; it costs - see EndHolders - and it ends every program the payload installs. Function ${prefix}CloseHolders Call ${prefix}ExtractHolderProbe System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")' @@ -871,13 +918,14 @@ Function ${prefix}CloseHolders FunctionEnd ; usage: set $GuardDir to the directory being replaced, then call this. Ends the running -; Cockatrice instances the probe names. +; Cockatrice, Oracle and Servatrice instances the probe names. ; ; This is the only place anything is stopped without being asked to stop, so it is only ; reached after the user picked it from a prompt that says what it costs: a client that is -; still running may have an unsaved deck, a game or a settings write in flight. The probe -; ends cockatrice.exe and nothing else - Oracle writes the card database in place and -; Servatrice is a service, so both stay the user's job, exactly as they do for the close. +; still running may have an unsaved deck, a game or a settings write in flight, Oracle may +; be part-way through writing the card database, and Servatrice may be serving a game. The +; probe ends the three programs the payload installs, services included, and nothing else: +; a scanner or a program the user keeps in the directory is named and left to the user. Function ${prefix}EndHolders Call ${prefix}ExtractHolderProbe System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")' @@ -888,7 +936,7 @@ Function ${prefix}EndHolders Pop $R4 Push "end-holders: probe exit=$R3 [$R4]" Call ${prefix}CockLogWrite - DetailPrint "Ending Cockatrice: $R4" + DetailPrint "Ending Cockatrice, Oracle and Servatrice: $R4" FunctionEnd !macroend @@ -902,9 +950,9 @@ FunctionEnd ; the client was still shutting down - never turns into a question. The prompt is what ; remains when the close cannot reach everything: Oracle writes the card database in ; place, Servatrice has no window, and Cockatrice may still be waiting for an answer in -; its own confirmation. It offers one more close (Yes), a plain re-check for after the -; user closed them by hand (No), and Cancel. Once a close has been asked for twice without -; freeing the files, it offers to end Cockatrice instead - the last resort, see EndHolders. +; its own confirmation. It offers to end the programs the payload installs - and only those, +; which the text says - with what that costs (Yes), a plain re-check for after the user +; closed things by hand (No), and Cancel - ending is the last resort, see EndHolders. ; ; An error is always shown, in every mode. A /S install without /R has nobody at ; the keyboard, but this installer does not promise unattended runs: when an @@ -914,6 +962,7 @@ FunctionEnd Function GuardAppsNotRunning !insertmacro CockLog "guard: checking $GuardDir (launcherExited=$LauncherExited)" StrCpy $GuardCloseTried 0 + StrCpy $GuardEndFailed 0 guard_check: Call RunHolderProbe ${If} $GuardExit == "0" @@ -936,45 +985,31 @@ Function GuardAppsNotRunning Goto guard_check ${EndIf} DetailPrint "The install directory is in use by: $GuardOutput" - ; A close that has been asked for twice and changed nothing is where asking again - ; stops being a plan: the instance is either waiting on a question the user has not - ; answered, or stuck where a close cannot reach it. Offer to end it - the only thing - ; left that can free the files - with the cost in the text, because a client that is - ; still running may have an unsaved deck, a game or a settings write in flight. - ${If} $GuardCloseTried >= 2 - MessageBox MB_YESNO|MB_ICONEXCLAMATION|MB_DEFBUTTON2 \ - "Cockatrice is still running after being asked to close twice.$\r$\n$\r$\nChoose Yes to end it now - an unsaved deck, a running game or a setting it has not written yet will be lost - or No to ask it to close again.$\r$\n$\r$\n$GuardOutput" \ - IDYES guard_end IDNO guard_close - !insertmacro CockLog "guard: cancelled by user" - StrCpy $AbortReason \ - "The installation was cancelled because Cockatrice, Oracle or Servatrice is still running and holding files of the previous version.$\r$\n$\r$\nClose them and run the installer again." - Call AbortInstall - ; Only reached through Yes: AbortInstall does not return. - guard_end: - !insertmacro CockLog "guard: ending cockatrice on request, then checking again" - Call EndHolders - ; Back to the ordinary prompt afterwards: Oracle and Servatrice are never ended, - ; so what is left may still have to be closed by hand. - StrCpy $GuardCloseTried 1 - Goto guard_check - ${EndIf} - ; Three answers, and the close is one of them: the automatic attempt above ran - ; before this prompt and could not have answered Cockatrice's own confirmation, so a - ; second close is often exactly what is needed once the user has dealt with that - ; window. NSIS does not let a MessageBox relabel its buttons, so the text says what - ; Yes, No and Cancel do. - MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ - "Cockatrice, Oracle or Servatrice is still using files of the installation:$\r$\n$\r$\n$GuardOutput$\r$\n$\r$\nChoose Yes to close Cockatrice and check again. It may ask you to confirm quitting - it does while a card database update is running, a game is open or a deck is unsaved - so answer that window. If it has no window on screen, open it from the notification area first.$\r$\n$\r$\nClose Oracle, Servatrice and any other program listed above by hand. Choose No to check again after that, or Cancel to cancel the installation." \ - IDYES guard_close IDNO guard_check + ; The automatic close already ran once. Asking Cockatrice to close again is unlikely + ; to help: an instance that did not answer the first one is usually sitting on a + ; confirmation of its own, and the close probe leaves such an instance alone so that a + ; second WM_CLOSE cannot hide the window the question is in - which is how a pre-3.1.2 + ; update used to leave a headless process behind. An instance with no window has + ; nothing to ask at all. Ending is what is left that can free the files, so the prompt + ; offers it here, with what it costs in the text: an unsaved deck, a game, a settings + ; write, and an Oracle that may be mid-way through the card database. The answers that + ; end nothing stay reachable from the same dialog: the instance's own confirmation, + ; and closing anything by hand. + !insertmacro CockEndPrompt "Cockatrice, Oracle or Servatrice is still using files of the installation:" $GuardOutput "installation" + MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON2 "$GuardPrompt" IDYES guard_end IDNO guard_check !insertmacro CockLog "guard: cancelled by user" StrCpy $AbortReason \ "The installation was cancelled because Cockatrice, Oracle or Servatrice is still running and holding files of the previous version.$\r$\n$\r$\nClose them and run the installer again." Call AbortInstall ; Only reached through Yes: AbortInstall does not return. - guard_close: - IntOp $GuardCloseTried $GuardCloseTried + 1 - !insertmacro CockLog "guard: closing cockatrice on request, then checking again" - Call CloseHolders + guard_end: + !insertmacro CockLog "guard: ending the payload programs on request, then checking again" + Call EndHolders + ; Everything the payload installs is ended, but a program that could not be opened + ; (another account, a protected process) survives, and a program that is not one of + ; ours is never touched at all, so what is left may still have to be closed by hand. + ; Go back to the same prompt, which says the end did not free the files. + StrCpy $GuardEndFailed 1 Goto guard_check ${EndIf} ; Not an answer at all: the probe was not configured, the Restart Manager @@ -1081,17 +1116,24 @@ Function WaitForLauncherToExit !insertmacro CockLog "wait-launcher: no pid given, falling back to name check" Return ${EndIf} - ; PROCESS_SYNCHRONIZE is the only access this needs - enough to wait on the - ; handle, and the least likely to be refused for a reason that has nothing to - ; do with the upgrade. - ; ; System.dll maps its output registers so that .rN writes $N and .RN writes ; $RN. The caller reads $R0/$R1, so the uppercase .RN form is required; the ; original .r0/.r1 wrote $0/$1 and left $R1 empty, which made the "did not ; exit" test below compare "" against "0" and abort on every /PID= upgrade. - ; Reopened on every Retry below, so a launcher that exited in the meantime answers + ; Reopened on every round below, so a launcher that exited in the meantime answers ; "already exited" instead of being waited on again. ; + ; PROCESS_TERMINATE rides along with PROCESS_SYNCHRONIZE so that the "end it" + ; answer below acts on this very handle: the pid looked up again after a dialog + ; that can stay open as long as the user takes would name whatever process holds + ; that pid by then, and a handle cannot be handed to another process. + ; + ; When those rights are refused - a launcher started by another account, a + ; protected process - the wait still has to happen, so the ask is repeated with + ; synchronize access alone and $WaitCanEnd records that ending is not on offer. + ; Reporting "already exited" instead would be the worse answer: the payload would + ; be written over a live process, which is what this wait exists to prevent. + ; ; Ten seconds, not the full grace: a client that started an update exits in well ; under a second, so at ten seconds it is either flushing a card database or - far ; more often - sitting on its own "quit anyway?" confirmation with nobody looking at @@ -1101,7 +1143,8 @@ Function WaitForLauncherToExit StrCpy $WaitSlice 10000 wait_launcher_open: ClearErrors - System::Call 'kernel32::OpenProcess(i 0x00100000, i 0, i $WaitPid) p.R0' + StrCpy $WaitCanEnd 1 + System::Call 'kernel32::OpenProcess(i 0x00100001, i 0, i $WaitPid) p.R0' ; OpenProcess returns a null handle for a process that is already gone. The ; System plugin does not raise the NSIS error flag from a called function's ; return value, so ${Errors} alone never saw that case and the null handle was @@ -1110,9 +1153,18 @@ Function WaitForLauncherToExit ${If} ${Errors} StrCpy $R0 0 ${EndIf} + ${If} $R0 == 0 + StrCpy $WaitCanEnd 0 + ClearErrors + System::Call 'kernel32::OpenProcess(i 0x00100000, i 0, i $WaitPid) p.R0' + ${If} ${Errors} + StrCpy $R0 0 + ${EndIf} + ${EndIf} ${If} $R0 == 0 ; The launcher is gone - OpenProcess cannot open a dead process - which is - ; exactly the state the wait was for. + ; exactly the state the wait was for. A handle refused for want of the terminate + ; right is not this case; the synchronize-only ask above covers it. !insertmacro CockLog "wait-launcher: already exited" DetailPrint "The application that started this installer has already exited." StrCpy $LauncherExited 1 @@ -1121,26 +1173,42 @@ Function WaitForLauncherToExit DetailPrint "Waiting for the application that started this installer to exit ..." ; WAIT_OBJECT_0 is 0; WAIT_TIMEOUT is 258 and a failed wait reports 0xFFFFFFFF. System::Call 'kernel32::WaitForSingleObject(p R0, i $WaitSlice) i.R1' - System::Call 'kernel32::CloseHandle(p R0)' ${If} $R1 != 0 - !insertmacro CockLog "wait-launcher: did not exit within $WaitSlice ms (wait=$R1)" + ; $R0 is deliberately still open: it is the process that timed out, and the answer + ; below has to act on that process and not on whatever $WaitPid names by then. + !insertmacro CockLog "wait-launcher: did not exit within $WaitSlice ms (wait=$R1, canEnd=$WaitCanEnd)" DetailPrint "The application that started this installer did not exit in time." - ; Two answers, and "carry on anyway" is not one of them. A live process has its - ; image and its Qt DLLs mapped, so writing the payload over them is the broken - ; install this wait exists to prevent - and the guard that runs next names the - ; same process by its image path and stops there anyway, so an "ignore" would - ; only put the same question a moment later, after a close attempt nobody asked - ; for. Waiting again is what helps: the client is most likely showing its own - ; "quit anyway?" confirmation, and that window is the thing to answer. - MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ - "Cockatrice has not finished closing, so the installation is paused rather than replace files it is still using.$\r$\n$\r$\nIt may be waiting for you to answer its own question about an unsaved deck, an open game or a card database update. Answer that window - choose Retry to bring it to the front, ask it to close again and keep waiting. If it does not answer at all, close it from Task Manager first.$\r$\n$\r$\nChoose Retry to wait again, or Cancel to cancel the installation." \ - IDRETRY wait_launcher_retry + ; Same three answers as every other prompt that waits on a running program, and + ; "carry on anyway" is not one of them: a live process has its image and its Qt + ; DLLs mapped, so writing the payload over them is the broken install this wait + ; exists to prevent. Yes ends the launcher, No asks it to close again and keeps + ; waiting, Cancel cancels - the launcher is the client the user started this update + ; from, and ending it is the same offer the guard makes a moment later. + MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON2 \ + "Cockatrice has not finished closing, so the installation is paused rather than replace files it is still using.$\r$\n$\r$\nIt may be waiting for you to answer its own question about an unsaved deck, an open game or a card database update.$\r$\n$\r$\nChoose Yes to end it now - an unsaved deck, a running game or an unwritten setting is lost.$\r$\n$\r$\nChoose No to bring it to the front, ask it to close again and keep waiting, or Cancel to cancel the installation." \ + IDYES wait_launcher_end IDNO wait_launcher_retry !insertmacro CockLog "wait-launcher: cancelled by user" StrCpy $AbortReason \ "The application that started this update has not exited yet, so the installation was cancelled rather than replace files it is still using.$\r$\n$\r$\nPlease close Cockatrice and run the installer again." Call AbortInstall - ; Only reached through Retry: AbortInstall does not return. + ; Only reached through Yes or No: AbortInstall does not return. + wait_launcher_end: + ; End it through the handle waited on above, and through nothing else: no pid is + ; looked up again, so a process that inherited the number in the meantime - or a + ; /PID= that was not the launcher to begin with - cannot be hit by this. + !insertmacro CockLog "wait-launcher: ending the launcher on request" + ${If} $WaitCanEnd == 0 + !insertmacro CockLog "wait-launcher: the launcher could not be opened to end it, asking it to close instead" + Goto wait_launcher_retry + ${EndIf} + System::Call 'kernel32::TerminateProcess(p R0, i 1) i.R3' + System::Call 'kernel32::CloseHandle(p R0)' + StrCpy $R0 0 + !insertmacro CockLog "wait-launcher: ending the launcher returned $R3" + Goto wait_launcher_open wait_launcher_retry: + System::Call 'kernel32::CloseHandle(p R0)' + StrCpy $R0 0 ; The question has been asked and answered once; from here the wait is the full ; grace again, so a slow flush or a confirmation being answered does not turn ; into a stream of prompts. @@ -1148,11 +1216,13 @@ Function WaitForLauncherToExit ; The same request the window's close button makes, and the same close the guard ; would send a moment later. It brings the client forward as well, which is the ; part that helps when the confirmation is behind something else. Nothing is - ; force-stopped: a client that is waiting for an answer keeps the last word. + ; force-stopped here: the answer that does that is Yes. StrCpy $GuardDir $INSTDIR Call CloseHolders Goto wait_launcher_open ${EndIf} + System::Call 'kernel32::CloseHandle(p R0)' + StrCpy $R0 0 !insertmacro CockLog "wait-launcher: launcher exited" DetailPrint "The application that started this installer has exited." StrCpy $LauncherExited 1 @@ -1513,19 +1583,25 @@ FunctionEnd ; Called after a purge left files behind and LogStaleRuntimeDiagnostics named who is ; holding them. Instead of aborting straight away - which on /R strands the user, since -; the old version is already uninstalled - ask, close what this installer owns, purge -; again, and retry while the user keeps asking. Sets $StaleRuntimeAfterPurge to the last -; purge's verdict, so the caller still aborts when the files survive. +; the old version is already uninstalled - it closes once, purges again, and only then +; asks: end what is still holding the files, or try the removal again by hand. Sets +; $StaleRuntimeAfterPurge to the last purge's verdict, so the caller still aborts when the +; files survive. ; -; Cockatrice is asked to close (WM_CLOSE, the same request as clicking the window's close -; button) and keeps the last word: it may be waiting for an answer to its own "really -; quit?" confirmation (a running card database update, an open game, an unsaved deck), and -; that stays its decision. The instance is brought forward first, so that confirmation is -; on screen rather than behind this installer's prompt. Oracle and Servatrice have no -; window to send a close to at all, and nothing here is force-stopped: Oracle writes the -; card database in place and killing it mid-write would corrupt it. Everything the probe -; named that this installer cannot close - those two included - is left to the user, whom -; the prompt names it to. +; The shape is the guard's, deliberately: one automatic attempt at a graceful close, then a +; prompt whose Yes ends the payload programs and whose No retries. A client that was still +; shutting down releases the files in the automatic round and never sees a question, and an +; Oracle or Servatrice - which a close cannot reach - is what the prompt exists for, so the +; user is never left with a dialog whose answers cannot free anything. +; +; The automatic close is a request (WM_CLOSE) and the client keeps the last word: it may be +; waiting for an answer to its own "really quit?" confirmation (a running card database +; update, an open game, an unsaved deck), and that stays its decision. The instance is +; brought forward first, so that confirmation is on screen rather than behind this +; installer's prompt. +; +; Ending is the same last resort the guard offers, with the same cost spelled out, and only +; ever the programs the payload installs - see EndHolders. ; ; Restart Manager's RmShutdown was the alternative and was rejected: it shuts down ; services as well as applications, and the only way to bring them back is RmRestart, @@ -1534,33 +1610,37 @@ FunctionEnd ; Always offered, in every mode: a /S install without /R is the only case without someone ; at the keyboard, and this installer does not promise unattended runs - if an error needs ; a decision, it asks for one. No offer when the probe named nobody: there is nothing to -; close, and the abort's per-file diagnostics already record what Windows said. +; end, and the abort's per-file diagnostics already record what Windows said. ; -; Every round offers all three answers: Yes closes Cockatrice and purges again, No purges -; again without asking it to close (for when the user closed things by hand), and Cancel -; gives up. The close stays on offer after one that did nothing because the usual reason is -; Cockatrice's own confirmation still waiting for an answer - the probe waits for that, the -; user can answer it in the application's window, and the next round can then collect the -; process the earlier attempt could not. Each round re-reads the holders, so the prompt -; names what survived the last attempt rather than repeating the first listing. +; Every round offers all three answers: Yes ends the programs the payload installs - and +; says so, because anything else the probe named is left to the user - and purges again, No +; purges again without ending anything (for when the user closed things by hand), and +; Cancel gives up. Each round re-reads the holders, so the prompt names what survived the +; last attempt rather than repeating the first listing. Function RecoverFromHolders ${If} $HolderFound != 1 !insertmacro CockLog "recover-holders: no holder was named, nothing to offer" Return ${EndIf} - rfh_prompt: - MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ - "Files of the previous version of Cockatrice are still in use by:$\r$\n$\r$\n$HolderOutput$\r$\n$\r$\nChoose Yes to close Cockatrice and try the removal again. It may ask you to confirm quitting - it does while a card database update is running, a game is open or a deck is unsaved - so answer that window. Close Oracle, Servatrice and any other program listed above by hand.$\r$\n$\r$\nChoose No to try again after closing the programs yourself, or Cancel to cancel the installation." \ - IDYES rfh_close IDNO rfh_retry - !insertmacro CockLog "recover-holders: declined by user" - Return - rfh_close: - !insertmacro CockLog "recover-holders: closing cockatrice on request, holders were [$HolderOutput]" - ; The close is a request, not a fact: CloseHolders waits for the instances it asked to - ; exit, so a confirmation of Cockatrice's that is still up is reported as still running - ; instead of being measured as a successful close. + StrCpy $GuardEndFailed 0 + ; The automatic attempt, the same one the guard makes before it asks anything. + !insertmacro CockLog "recover-holders: closing cockatrice, then trying the removal again" StrCpy $GuardDir $INSTDIR Call CloseHolders + Goto rfh_retry + rfh_prompt: + ; The same question the guard asks, so the two cannot drift apart in what they promise. + !insertmacro CockEndPrompt "Files of the previous version of Cockatrice are still in use by:" $HolderOutput "installation" + MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON2 "$GuardPrompt" IDYES rfh_end IDNO rfh_retry + !insertmacro CockLog "recover-holders: cancelled by user" + Return + ; Only reached through Yes: Return does not come back here. + rfh_end: + !insertmacro CockLog "recover-holders: ending the payload programs on request, then trying the removal again" + Call EndHolders + ; The next prompt says the end did not free the files, instead of naming the same + ; survivors word for word. + StrCpy $GuardEndFailed 1 rfh_retry: Call RetryDllPurge StrCpy $StaleRuntimeAfterPurge $R0 @@ -1572,7 +1652,7 @@ Function RecoverFromHolders Return ${EndIf} ; Still stuck: refresh the names, so the next prompt and the log describe what - ; survived the close and not what was there before it. + ; survived the last attempt and not what was there before it. Call LogStaleRuntimeDiagnostics ${If} $HolderFound != 1 Return @@ -1591,6 +1671,7 @@ FunctionEnd Function un.GuardAppsNotRunning StrCpy $GuardCloseTried 0 + StrCpy $GuardEndFailed 0 un_guard_check: !insertmacro CockLogUn "uninst-guard: checking $GuardDir" Call un.RunHolderProbe @@ -1609,35 +1690,27 @@ Function un.GuardAppsNotRunning Goto un_guard_check ${EndIf} DetailPrint "The install directory is in use by: $GuardOutput" - ; Same escalation as in the installer's guard: after a second close that changed - ; nothing, offer to end the instance, with what it costs in the text. - ${If} $GuardCloseTried >= 2 - MessageBox MB_YESNO|MB_ICONEXCLAMATION|MB_DEFBUTTON2 \ - "Cockatrice is still running after being asked to close twice.$\r$\n$\r$\nChoose Yes to end it now - an unsaved deck, a running game or a setting it has not written yet will be lost - or No to ask it to close again.$\r$\n$\r$\n$GuardOutput" \ - IDYES un_guard_end IDNO un_guard_close - !insertmacro CockLogUn "uninst-guard: cancelled by user" - SetErrorLevel 1 - Abort - ; Only reached through Yes: Abort does not return. - un_guard_end: - !insertmacro CockLogUn "uninst-guard: ending cockatrice on request, then checking again" - Call un.EndHolders - StrCpy $GuardCloseTried 1 - Goto un_guard_check - ${EndIf} - ; Same three answers as the installer's guard; the automatic close above already - ; ran once before this prompt. - MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ - "Cockatrice, Oracle or Servatrice is still using files of the installation:$\r$\n$\r$\n$GuardOutput$\r$\n$\r$\nChoose Yes to close Cockatrice and check again. It may ask you to confirm quitting - it does while a card database update is running, a game is open or a deck is unsaved - so answer that window. If it has no window on screen, open it from the notification area first.$\r$\n$\r$\nClose Oracle, Servatrice and any other program listed above by hand. Choose No to check again after that, or Cancel to cancel the uninstallation." \ - IDYES un_guard_close IDNO un_guard_check + ; Same approach as in the installer's guard: the automatic close already ran once, and + ; asking again is unlikely to help an instance that is sitting on its own confirmation + ; or has no window a close could reach. Ending is what is left that can free the files, + ; so the prompt offers it here, with what it costs in the text, while keeping the + ; answers that end nothing - the instance's own question, and closing anything by hand + ; - reachable from the same dialog. CockEndPrompt words it, so this copy and the + ; installer's promise the same thing. + !insertmacro CockEndPrompt "Cockatrice, Oracle or Servatrice is still using files of the installation:" $GuardOutput "uninstallation" + MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON2 "$GuardPrompt" IDYES un_guard_end IDNO un_guard_check !insertmacro CockLogUn "uninst-guard: cancelled by user" SetErrorLevel 1 Abort ; Only reached through Yes: Abort does not return. - un_guard_close: - IntOp $GuardCloseTried $GuardCloseTried + 1 - !insertmacro CockLogUn "uninst-guard: closing cockatrice on request, then checking again" - Call un.CloseHolders + un_guard_end: + !insertmacro CockLogUn "uninst-guard: ending the payload programs on request, then checking again" + Call un.EndHolders + ; Everything the payload installs is ended, but a program that could not be opened + ; (another account, a protected process) survives, and a program that is not one of + ; ours is never touched at all, so what is left may still have to be closed by hand. + ; Go back to the same prompt, which says the end did not free the files. + StrCpy $GuardEndFailed 1 Goto un_guard_check ${EndIf} ; Same reasoning as in the installer: without an answer from the probe we cannot tell @@ -1950,9 +2023,9 @@ ${If} $StaleRuntimeAfterPurge = 1 Call LogStaleRuntimeDiagnostics ${If} $HolderFound = 1 ; On /R the previous version is already uninstalled at this point, so an abort - ; leaves the user with no working Cockatrice. Offer to close and purge again, - ; repeating while the user keeps saying Yes; this updates - ; $StaleRuntimeAfterPurge with the last retry's verdict. + ; leaves the user with no working Cockatrice. It closes once, then offers to end + ; what is left or to try the removal again; this updates $StaleRuntimeAfterPurge + ; with the last retry's verdict. Call RecoverFromHolders ${Else} ; Nothing is holding them. The three attempts in RetryDllPurge were two seconds diff --git a/tools/installer-holder-probe/close_holders.cpp b/tools/installer-holder-probe/close_holders.cpp index d5bd5251f..791347408 100644 --- a/tools/installer-holder-probe/close_holders.cpp +++ b/tools/installer-holder-probe/close_holders.cpp @@ -1,5 +1,7 @@ #include "close_holders.h" +#include "restart_manager_holders.h" + // clang-format off // windows.h has to come before anything that pulls in a Windows type, as everywhere in // this tool. @@ -310,7 +312,7 @@ CloseHoldersResult closeCockatriceHolders(const std::vector &holders, un return result; } -EndHoldersResult endCockatriceHolders(const std::vector &holders, unsigned long waitMilliseconds) +EndHoldersResult endHolders(const std::vector &holders, unsigned long waitMilliseconds) { EndHoldersResult result; std::vector lines; @@ -318,13 +320,16 @@ EndHoldersResult endCockatriceHolders(const std::vector &holders, unsign std::vector endedLines; for (const Holder &holder : holders) { - if (holder.isService || !isCockatriceImage(holder.imageName)) { - // The same split as the close: Oracle writes the card database in place and a - // service has nothing to ask, so neither is ever ended here. + if (!isPayloadProgram(holder.imageName)) { + // Not one of the programs the payload installs: a scanner that has a DLL open, or + // a program the user keeps in the install directory. It is named and left to the + // user, because ending it would destroy something this installer does not own. lines.push_back(processLabel(holder) + L" left to you"); continue; } + // Services included: the installer asked before getting here, and the Service + // Control Manager reports the terminated process as a stopped service afterwards. const HANDLE process = OpenProcess(PROCESS_TERMINATE | SYNCHRONIZE, FALSE, holder.processId); if (process == nullptr) { lines.push_back(processLabel(holder) + L" could not be ended (win32 " + std::to_wstring(GetLastError()) + diff --git a/tools/installer-holder-probe/close_holders.h b/tools/installer-holder-probe/close_holders.h index 4f9f5f0af..0e2b93b26 100644 --- a/tools/installer-holder-probe/close_holders.h +++ b/tools/installer-holder-probe/close_holders.h @@ -63,13 +63,20 @@ struct EndHoldersResult }; /** - * @brief Ends every running Cockatrice the probe named. + * @brief Ends every running Cockatrice, Oracle and Servatrice the probe named. * * This is the last resort behind the installer's "end it now" answer, never a first move: a * client that is still running may have an unsaved deck, an open game or a settings write in - * flight, so the caller only gets here after the user picked it from a prompt that says so. - * Only cockatrice.exe is ended - Oracle writes the card database in place and Servatrice is a - * service, so both are named and left to the user, exactly as they are for the close. + * flight, Oracle may be part-way through writing the card database, and Servatrice may be + * serving a game, so the caller only gets here after the user picked it from a prompt that + * says so. + * + * Every program the payload installs is ended, services included. A service is terminated + * like a process rather than stopped through the Service Control Manager: the installer has + * asked the user first, and the SCM notices the process exit and marks the service stopped + * afterwards, so it can be started again normally. Anything else the Restart Manager named - + * a scanner holding a DLL, a program the user keeps in the install directory - is left to + * the user, because ending it would destroy something this installer does not own. * * Waits up to @c waitMilliseconds for what it ended to be gone, so the caller can measure the * purge immediately afterwards instead of racing a process that is still exiting. @@ -77,6 +84,6 @@ struct EndHoldersResult * @param holders what the probe named, one entry per application or service * @param waitMilliseconds how long to wait for the ended instances to be gone, 0 to not wait */ -EndHoldersResult endCockatriceHolders(const std::vector &holders, unsigned long waitMilliseconds); +EndHoldersResult endHolders(const std::vector &holders, unsigned long waitMilliseconds); #endif diff --git a/tools/installer-holder-probe/holder_line.h b/tools/installer-holder-probe/holder_line.h index 7fcc92375..163112ff6 100644 --- a/tools/installer-holder-probe/holder_line.h +++ b/tools/installer-holder-probe/holder_line.h @@ -26,12 +26,15 @@ inline constexpr std::size_t HOLDER_LINE_MAX = 5; /** * @brief How many characters the summary may spend on the holders it names. * - * The line is not the whole string: the abort dialog puts it after a fixed paragraph and - * before the path of the installer log, inside an NSIS string of 1024 characters. Windows - * caps one application or service name well below this budget, so it bounds the list - * without ever cutting a single holder short. + * The line is not the whole string: every prompt puts it after a fixed paragraph and before + * the answers, and one NSIS string holds 1024 characters. Those paragraphs grew when the + * guard started spelling out what ending the programs costs and what is left to do by hand, + * so this budget is what keeps the longest message - the one that also says the last end + * freed nothing, plus the "; and N more" the summary appends after the budget - complete, + * including the Cancel line at its end. Windows caps one application or service name well + * below this, so it bounds the list without ever cutting a single holder short. */ -inline constexpr std::size_t HOLDER_LINE_CHAR_MAX = 400; +inline constexpr std::size_t HOLDER_LINE_CHAR_MAX = 320; /** * @brief Renders one holder the way the user has to recognise it, e.g. diff --git a/tools/installer-holder-probe/main.cpp b/tools/installer-holder-probe/main.cpp index 3f1556b75..0cbecb9f2 100644 --- a/tools/installer-holder-probe/main.cpp +++ b/tools/installer-holder-probe/main.cpp @@ -45,8 +45,9 @@ enum ExitCode /// abort dialog/log /// --close-holders bring the running Cockatrice forward, ask it to close, and wait for /// it to exit, for the offer to close after a failed purge -/// --end-holders end the running Cockatrice and wait for it to be gone, for the last -/// resort the guard prompt offers after a close was asked for twice +/// --end-holders end the running Cockatrice, Oracle and Servatrice and wait for them to +/// be gone, for the last resort the guard prompt offers when a close did +/// not free the files /// /// All three answer from two lookups that cover each other's blind spot: the Restart Manager /// names whoever has a payload file open, and every process running from the directory is @@ -161,7 +162,7 @@ int main(int argumentCount, char **arguments) return EXIT_HOLDERS_FOUND; } if (mode == Mode::End) { - const EndHoldersResult result = endCockatriceHolders(holders, END_WAIT_MILLISECONDS); + const EndHoldersResult result = endHolders(holders, END_WAIT_MILLISECONDS); writeLine(result.report); return result.ended > 0 ? EXIT_HOLDERS_FOUND : EXIT_NO_HOLDERS; } diff --git a/tools/installer-holder-probe/restart_manager_holders.cpp b/tools/installer-holder-probe/restart_manager_holders.cpp index 1abfcb352..e8c982f26 100644 --- a/tools/installer-holder-probe/restart_manager_holders.cpp +++ b/tools/installer-holder-probe/restart_manager_holders.cpp @@ -34,16 +34,6 @@ constexpr const wchar_t *PAYLOAD_FILES[] = {L"cockatrice.exe", L"oracle.exe", L" /// exists to name, so askingProcessIds() stops its walk up the parent chain at one. constexpr const wchar_t *PAYLOAD_PROGRAMS[] = {L"cockatrice.exe", L"oracle.exe", L"servatrice.exe"}; -bool isPayloadProgram(const std::wstring &imageName) -{ - for (const wchar_t *program : PAYLOAD_PROGRAMS) { - if (CompareStringOrdinal(imageName.c_str(), -1, program, -1, TRUE) == CSTR_EQUAL) { - return true; - } - } - return false; -} - void collectFilesOf(const std::wstring &directory, int depth, int maxDepth, std::vector &files) { if (files.size() >= MAX_PROBED_FILES) { @@ -144,6 +134,16 @@ Holder holderOf(const RM_PROCESS_INFO &info) } } // namespace +bool isPayloadProgram(const std::wstring &imageName) +{ + for (const wchar_t *program : PAYLOAD_PROGRAMS) { + if (CompareStringOrdinal(imageName.c_str(), -1, program, -1, TRUE) == CSTR_EQUAL) { + return true; + } + } + return false; +} + std::vector collectPayloadFiles(const std::wstring &installDir) { std::vector files; diff --git a/tools/installer-holder-probe/restart_manager_holders.h b/tools/installer-holder-probe/restart_manager_holders.h index 044cbe449..c14499941 100644 --- a/tools/installer-holder-probe/restart_manager_holders.h +++ b/tools/installer-holder-probe/restart_manager_holders.h @@ -21,6 +21,18 @@ */ std::vector collectPayloadFiles(const std::wstring &installDir); +/** + * @brief Whether @p imageName is one of the programs the payload installs. + * + * The single place that answers which images are Cockatrice's own, so the guard ends the + * same set it names. Matched case-insensitively on the image name alone: only holders are + * ever tested against it, and a holder is a process that has a payload file open or runs + * from the install directory. + * + * @param imageName the file name of a process image, empty when it could not be read. + */ +bool isPayloadProgram(const std::wstring &imageName); + /** * @brief Names the applications and services that have one of @p files open or mapped. * From a83d82b5e82c5801fc5b9b5e7bd33d191409a836 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:08:54 +0100 Subject: [PATCH 042/108] [Installer] Recover leftover payload files when previous uninstaller leaves them behind (#7676) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Installer] Recover leftover payload files when previous uninstaller leaves them behind * [Installer] Fix leftover recovery on the 64-bit auto-uninstall path - Record the 64-bit uninstaller exit code after ExecWait returns. It was captured before the call (always empty), so WaitForOldPayloadToDisappear always aborted before RemoveOldPayloadLeftovers could run. - Restore the pre-uninstall user-data backup before aborting out of the wait, including the guard's cancel and probe-failure prompts, so the only copy cannot be stranded in %TEMP% for the next run to wipe. - Report the leftover removal as done only when every payload executable is gone and RetryDllPurge reports no stale DLLs, instead of checking cockatrice.exe alone. - Return directly after a successful recovery instead of reusing the poll counter, so the log stops claiming the directory "was already clear" and the normal settle delay is kept. * [Installer] Delete the old uninstaller only after recovery succeeds RemoveOldPayloadLeftovers cleared the attribute and deleted $INSTDIR\uninstall.exe on every attempt, before the attempt was known to work. On failure the installer aborts telling the user to run it again, but UninstallString then points at a missing uninstaller: ExecWait leaves the exit code empty, WaitForOldPayloadToDisappear takes the "cancelled or failed" branch, and RemoveOldPayloadLeftovers is gated on exit code 0, so no later run can ever get past it - a half-removed old install the previous revision never produced. Delete it in rop_done instead, after $OldPayloadRemoved is set. WriteUninstaller recreates it on success anyway, and nothing in the success verdict depends on it being gone. --------- Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> Co-authored-by: Lukas Brübach --- cmake/NSIS.template.in | 158 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 154 insertions(+), 4 deletions(-) diff --git a/cmake/NSIS.template.in b/cmake/NSIS.template.in index 6b861a370..cda370670 100644 --- a/cmake/NSIS.template.in +++ b/cmake/NSIS.template.in @@ -15,6 +15,17 @@ Var ReinstallMode Var GuardDir Var OldUninstallString Var OldInstallDir +; What ExecWait reported for the previous version's uninstaller (empty when it could not be +; started at all). Read by WaitForOldPayloadToDisappear: an uninstaller that ran in place +; (_?=) and failed or was cancelled will not remove anything more, however long we wait. +Var OldUninstallExit +; Set to 1 by RemoveOldPayloadLeftovers only when every leftover it is responsible for is +; gone; WaitForOldPayloadToDisappear aborts while it is still 0. +Var OldPayloadRemoved +; Scratch state of AppendUninstallerLog. +Var UninstLogHandle +Var UninstLogLine +Var UninstLogChar Var DataBackupDir Var DataBackupDone Var DataBackupForDir @@ -198,6 +209,7 @@ StrCpy $LooseDeckNoticeShown "" ; No old install location resolved yet; Backup/Restore fall back to $INSTDIR ; when this is empty (the /R path where $INSTDIR already is the old dir). StrCpy $OldInstallDir "" +StrCpy $OldUninstallExit "" ; No purge has run and nothing has been measured yet. The section sets both ; after a purge, or measures the directory itself when neither purge applies. StrCpy $StaleRuntimeAfterPurge 0 @@ -404,7 +416,9 @@ StrCpy $OldInstallDir $INSTDIR StrCmp $R0 "" done32 DetailPrint "Removing previous version (32-bit)..." Call BackupUserDataBeforeUninstall +StrCpy $R5 "" ExecWait '$R0 _?=$OldInstallDir' $R5 +StrCpy $OldUninstallExit $R5 !insertmacro CockLog "uninstall (32-bit): exit=$R5" Call WaitForOldPayloadToDisappear Call RestoreUserDataAfterUninstall @@ -419,9 +433,11 @@ ${If} ${RunningX64} StrCpy $OldInstallDir $INSTDIR !insertmacro CockLog "uninstall (64-bit): '$R0 _?=$OldInstallDir'" StrCmp $R0 "" done64 + StrCpy $R5 "" DetailPrint "Removing previous version (64-bit)..." Call BackupUserDataBeforeUninstall ExecWait '$R0 _?=$OldInstallDir' $R5 + StrCpy $OldUninstallExit $R5 !insertmacro CockLog "uninstall (64-bit): exit=$R5" Call WaitForOldPayloadToDisappear Call RestoreUserDataAfterUninstall @@ -439,6 +455,12 @@ FunctionEnd ; ExecWait returns at once while the copy keeps deleting - and it finishes after this ; window has closed, removing the version that was just installed. Waiting for ; cockatrice.exe to disappear covers both cases, and stops with a message instead of +; +; When the payload is still there after the wait, $OldUninstallExit decides: a non-zero +; code means the uninstaller was cancelled or failed, and the install aborts saying so. +; A zero code means it finished but skipped files someone was holding (NSIS Delete does +; that silently), so RemoveOldPayloadLeftovers frees and removes them, and the install +; only aborts - naming the holders - when even that fails. ; installing over a directory that is still being emptied. Function WaitForOldPayloadToDisappear ${If} $OldInstallDir == "" @@ -449,10 +471,37 @@ old_uninstall_wait: IfFileExists "$OldInstallDir\cockatrice.exe" 0 old_uninstall_gone IntOp $R2 $R2 + 1 ${If} $R2 > 60 - !insertmacro CockLog "old uninstall: $OldInstallDir still holds cockatrice.exe after 30 s" - StrCpy $AbortReason \ - "The previous version in $OldInstallDir is still there after its uninstaller ran.$\r$\n$\r$\nIf that uninstaller was cancelled or reported an error, close anything still using those files and run this installer again." - Call AbortInstall + !insertmacro CockLog "old uninstall: $OldInstallDir still holds cockatrice.exe after 30 s (uninstaller exit=$OldUninstallExit)" + ; The uninstaller's own account of what it skipped or why it stopped lives in its + ; separate log; copy it here so the report sent from AbortInstall carries it. + Call AppendUninstallerLog + ${If} $OldUninstallExit != "0" + ; Cancelled, failed, or could not be started at all (empty code): the old version + ; was not meant to be removed, so nothing is deleted on its behalf. + StrCpy $AbortReason \ + "The previous version in $OldInstallDir is still there because its uninstaller was cancelled or failed (exit code '$OldUninstallExit').$\r$\n$\r$\nClose anything still using those files and run this installer again." + ; The backup was taken before the uninstaller ran; an abort must not leave the + ; only copy of the user data in %TEMP%, where the next run clears it. + Call RestoreUserDataAfterUninstall + Call AbortInstall + ${EndIf} + ; The uninstaller finished but skipped files - a running instance or a scanner holding + ; them, which NSIS Delete passes over silently. Free them and remove what is left. + Call RemoveOldPayloadLeftovers + ; The removal reports success only when every payload executable is gone and the + ; DLL/Plugins purge came back clean; cockatrice.exe alone would hide a locked + ; oracle.exe or a stale DLL the new payload is about to be written over. + ${If} $OldPayloadRemoved != "1" + StrCpy $AbortReason \ + "The previous version in $OldInstallDir could not be fully removed after its uninstaller ran.$\r$\n$\r$\nStill in use by: $HolderOutput$\r$\n$\r$\nClose anything still using those files and run this installer again." + Call RestoreUserDataAfterUninstall + Call AbortInstall + ${EndIf} + ; The payload is gone and ExecWait already waited for the uninstaller; keep the + ; short settle the other paths use, then hand back to the caller's restore. + Sleep 1000 + !insertmacro CockLog "old uninstall: continuing after removing the leftovers the uninstaller skipped" + Return ${EndIf} Sleep 500 Goto old_uninstall_wait @@ -467,6 +516,97 @@ ${If} $R2 > 0 ${Else} !insertmacro CockLog "old uninstall: $OldInstallDir was already clear when ExecWait returned" ${EndIf} +Return +FunctionEnd + +; Called when the old uninstaller exited successfully but left its payload behind. +; Frees the files the same way the guard does (close, then offer to end), deletes what +; the uninstaller skipped, and on failure records which files survived and who holds +; them in $HolderOutput, for the abort message. Sets $OldPayloadRemoved to 1 only when +; every payload executable is gone and the DLL/Plugins purge is clean. +Function RemoveOldPayloadLeftovers + !insertmacro CockLog "old uninstall: removing leftovers of $OldInstallDir" + StrCpy $OldPayloadRemoved 0 + StrCpy $HolderOutput "nothing could be named" + StrCpy $GuardDir $OldInstallDir + Call GuardAppsNotRunning + ; The purge helpers work on $INSTDIR; point them at the old directory for the + ; duration (on /R the two are the same anyway). + Push $INSTDIR + StrCpy $INSTDIR $OldInstallDir + StrCpy $R2 0 + rop_loop: + SetFileAttributes "$INSTDIR\cockatrice.exe" NORMAL + SetFileAttributes "$INSTDIR\oracle.exe" NORMAL + SetFileAttributes "$INSTDIR\servatrice.exe" NORMAL + Delete "$INSTDIR\cockatrice.exe" + Delete "$INSTDIR\oracle.exe" + Delete "$INSTDIR\servatrice.exe" + Delete "$INSTDIR\qt.conf" + Call RetryDllPurge + ; $R0 is RetryDllPurge's verdict (1 = a DLL or the Plugins directory survived). + ; Every payload executable has to be gone as well: a locked oracle.exe or + ; servatrice.exe is just as fatal to the install that follows as cockatrice.exe. + IfFileExists "$INSTDIR\cockatrice.exe" rop_retry + IfFileExists "$INSTDIR\oracle.exe" rop_retry + IfFileExists "$INSTDIR\servatrice.exe" rop_retry + ${If} $R0 == 0 + Goto rop_done + ${EndIf} + rop_retry: + IntOp $R2 $R2 + 1 + ${If} $R2 < 3 + !insertmacro CockLog "old uninstall: leftovers of $OldInstallDir survived removal attempt $R2, retrying in 2 s" + Sleep 2000 + Goto rop_loop + ${EndIf} + !insertmacro CockLog "old uninstall: leftovers of $OldInstallDir still present after 3 removal attempts" + !insertmacro CockLogListing "old-leftovers" "*.*" + ; Per-file Windows codes for the DLLs plus the holder probe's line ($HolderOutput). + Call LogStaleRuntimeDiagnostics + Pop $INSTDIR + Return + rop_done: + StrCpy $OldPayloadRemoved 1 + ; Only now that the recovery has succeeded: deleting the old uninstaller on a + ; failed attempt would leave a directory whose UninstallString points at nothing, + ; so no later run could remove it. WriteUninstaller recreates it on success anyway. + SetFileAttributes "$INSTDIR\uninstall.exe" NORMAL + Delete "$INSTDIR\uninstall.exe" + !insertmacro CockLog "old uninstall: leftovers of $OldInstallDir removed" + !insertmacro CockLogListing "old-leftovers-after-removal" "*.*" + Pop $INSTDIR +FunctionEnd + +; Copies the old uninstaller's log ($TEMP\Cockatrice-uninstaller.log) into this +; installer's log, so the report AbortInstall sends shows what the uninstaller did. +; Uninstallers older than this log simply have no file; that is logged too. +Function AppendUninstallerLog + ClearErrors + FileOpen $UninstLogHandle "$TEMP\Cockatrice-uninstaller.log" r + ${If} ${Errors} + ${OrIf} $UninstLogHandle == "" + !insertmacro CockLog "uninstaller log: not found at $TEMP\Cockatrice-uninstaller.log" + Return + ${EndIf} + !insertmacro CockLog "uninstaller log begin ----------------------------------------" + aul_loop: + ClearErrors + FileRead $UninstLogHandle $UninstLogLine + IfErrors aul_done + ; Strip the line break FileRead keeps, so CockLog does not double it. + aul_trim: + StrCpy $R9 $UninstLogLine 1 -1 + ${If} $R9 == "$\n" + ${OrIf} $R9 == "$\r" + StrCpy $UninstLogLine $UninstLogLine -1 + Goto aul_trim + ${EndIf} + !insertmacro CockLog "uninstaller: $UninstLogLine" + Goto aul_loop + aul_done: + FileClose $UninstLogHandle + !insertmacro CockLog "uninstaller log end ------------------------------------------" FunctionEnd ; BackupUserDataBeforeUninstall / RestoreUserDataAfterUninstall @@ -1000,6 +1140,9 @@ Function GuardAppsNotRunning !insertmacro CockLog "guard: cancelled by user" StrCpy $AbortReason \ "The installation was cancelled because Cockatrice, Oracle or Servatrice is still running and holding files of the previous version.$\r$\n$\r$\nClose them and run the installer again." + ; RemoveOldPayloadLeftovers runs with the pre-uninstall backup already taken; + ; restoring here keeps this abort from stranding the only copy in %TEMP%. + Call RestoreUserDataAfterUninstall Call AbortInstall ; Only reached through Yes: AbortInstall does not return. guard_end: @@ -1031,6 +1174,9 @@ Function GuardAppsNotRunning guard_unknown_abort: StrCpy $AbortReason \ "The installation was cancelled because it could not check whether Cockatrice, Oracle or Servatrice is still running.$\r$\n$\r$\nThe check reported: $GuardOutput$\r$\n$\r$\nPlease close them and run the installer again." + ; Same reason as the cancel path above: this abort can be reached from the + ; leftover-removal guard, after the backup was taken. + Call RestoreUserDataAfterUninstall Call AbortInstall FunctionEnd @@ -1818,7 +1964,9 @@ ${If} $PortableMode = 0 !insertmacro CockLog "componentsPagePre: old 32-bit uninstall '$OldUninstallString _?=$OldInstallDir' (oldDir=$OldInstallDir InstDir=$INSTDIR)" ClearErrors Call BackupUserDataBeforeUninstall + StrCpy $R5 "" ExecWait "$OldUninstallString _?=$OldInstallDir" $R5 + StrCpy $OldUninstallExit $R5 !insertmacro CockLog "componentsPagePre: old 32-bit uninstall exit=$R5" Call WaitForOldPayloadToDisappear Call RestoreUserDataAfterUninstall @@ -1854,7 +2002,9 @@ ${If} $PortableMode = 0 !insertmacro CockLog "componentsPagePre: old 64-bit uninstall '$OldUninstallString _?=$OldInstallDir' (oldDir=$OldInstallDir InstDir=$INSTDIR)" ClearErrors Call BackupUserDataBeforeUninstall + StrCpy $R5 "" ExecWait "$OldUninstallString _?=$OldInstallDir" $R5 + StrCpy $OldUninstallExit $R5 !insertmacro CockLog "componentsPagePre: old 64-bit uninstall exit=$R5" Call WaitForOldPayloadToDisappear Call RestoreUserDataAfterUninstall From d89b5023422a4888d463c5c9a94ed2e62fbd4220 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:19:42 +0100 Subject: [PATCH 043/108] [Client] Report password-reset rate limits instead of a generic failure (#7677) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Client] Report password-reset rate limits instead of a generic failure sigForgotPasswordError carried no response code, so rate limiting, a disabled reset feature, an unknown user and a bad token all produced "contact the server operator". Users who were only rate limited retried, which kept them rate limited. * [Client] Move the reset-password warning out of the response switch --------- Co-authored-by: Lukas Brübach --- .../remote_connection_controller.cpp | 38 +++++++++++++++---- .../remote_connection_controller.h | 2 +- .../network/client/remote/remote_client.cpp | 6 +-- .../network/client/remote/remote_client.h | 2 +- 4 files changed, 36 insertions(+), 12 deletions(-) diff --git a/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp b/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp index e23e91b46..4ac29b49b 100644 --- a/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp +++ b/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp @@ -517,14 +517,38 @@ void ConnectionController::onForgotPasswordSuccess() SettingsCache::instance().servers().setFPPlayerName(""); } -void ConnectionController::onForgotPasswordError() +void ConnectionController::onForgotPasswordError(int responseCode) { - QMessageBox::warning( - dialogParent, tr("Reset Password"), - tr("Failed to reset user account password, please contact the server operator to reset your password.")); - SettingsCache::instance().servers().setFPHostName(""); - SettingsCache::instance().servers().setFPPort(""); - SettingsCache::instance().servers().setFPPlayerName(""); + // Rate limiting is retryable, so it must not clear the prefill. + bool keepPrefill = false; + QString message; + switch (static_cast(responseCode)) { + case Response::RespTooManyRequests: + // Retryable: keep the host/port/user prefill so the user can try again. The window + // is server-configured (an hour by default), so do not promise a shorter wait. + keepPrefill = true; + message = tr("Too many password reset attempts from your address. Please wait before " + "requesting a new reset."); + break; + case Response::RespFunctionNotAllowed: + // Also returned for an unknown user, a banned user, and an expired token or + // challenge, so the message must not claim a single cause. + message = tr("The server did not accept the reset request. Check the user name, or contact " + "the server operator to reset your password."); + break; + default: + message = tr("Failed to reset user account password, please contact the server operator to reset your " + "password."); + break; + } + + QMessageBox::warning(dialogParent, tr("Reset Password"), message); + + if (!keepPrefill) { + SettingsCache::instance().servers().setFPHostName(""); + SettingsCache::instance().servers().setFPPort(""); + SettingsCache::instance().servers().setFPPlayerName(""); + } } void ConnectionController::onPromptForgotPasswordReset() diff --git a/cockatrice/src/client/network/connection_controller/remote_connection_controller.h b/cockatrice/src/client/network/connection_controller/remote_connection_controller.h index 150ec9e5b..c8f96a9a9 100644 --- a/cockatrice/src/client/network/connection_controller/remote_connection_controller.h +++ b/cockatrice/src/client/network/connection_controller/remote_connection_controller.h @@ -87,7 +87,7 @@ private slots: // Forgot-password flow void onForgotPasswordSuccess(); - void onForgotPasswordError(); + void onForgotPasswordError(int responseCode); void onPromptForgotPasswordReset(); void onPromptForgotPasswordChallenge(); diff --git a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp index e02f5d3d8..51cb73750 100644 --- a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp +++ b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp @@ -837,7 +837,7 @@ void RemoteClient::requestForgotPasswordResponse(const Response &response) emit sigPromptForForgotPasswordReset(); } } else { - emit sigForgotPasswordError(); + emit sigForgotPasswordError(response.response_code()); } doDisconnectFromServer(); @@ -873,7 +873,7 @@ void RemoteClient::submitForgotPasswordResetResponse(const Response &response) derivedVerifier.clear(); } } else { - emit sigForgotPasswordError(); + emit sigForgotPasswordError(response.response_code()); } doDisconnectFromServer(); @@ -908,7 +908,7 @@ void RemoteClient::submitForgotPasswordChallengeResponse(const Response &respons if (response.response_code() == Response::RespOk) { emit sigPromptForForgotPasswordReset(); } else { - emit sigForgotPasswordError(); + emit sigForgotPasswordError(response.response_code()); } doDisconnectFromServer(); diff --git a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.h b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.h index dd91c4c6d..d0c0a18ce 100644 --- a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.h +++ b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.h @@ -54,7 +54,7 @@ signals: void notifyUserAboutUpdate(); void sigRequestForgotPasswordToServer(const QString &hostname, unsigned int port, const QString &_userName); void sigForgotPasswordSuccess(); - void sigForgotPasswordError(); + void sigForgotPasswordError(int responseCode); void sigPromptForForgotPasswordReset(); void sigSubmitForgotPasswordResetToServer(const QString &hostname, unsigned int port, From 5cf3358c997254eb8cf97fde3f906e5ef9cf24a4 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:48:37 +0100 Subject: [PATCH 044/108] [Client] Stop reporting local login aborts as missing client features (#7678) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Client] Stop reporting local login aborts as missing client features Local aborts (unusable saved verifier, refused authentication downgrade, failed scrypt derivation) reuse RespClientUpdateRequired with an empty feature list, and the handler discarded the reason and advised a client update. Report the abort with the cause instead, and report a strict server's challenge_response_auth answer to a credential-less login as the missing password it is. * [Client] Keep the login abort messages in one named set The local login aborts each carried their wording inline at the emit site, so changing the tone or the remedy meant editing several functions. Collect them as named constants next to RemoteClient instead, translated at the use sites with the RemoteClient context so lupdate still picks them up. * [Client] Reword the login abort explanations to match what happens The first wording sent users to a server operator to 'migrate' an account, which operators cannot do, and told them to 'save' a login the client saves by itself. It also blamed the device for a derivation failure the server's parameters can cause, and the dialog led with a line that said nothing while the reason sat in the informative slot. Say what the client actually does in each case, give the step that works, and show the reason as the dialog's main text. The unreachable fallback sentence about the login not reaching the server is gone. --------- Co-authored-by: Lukas Brübach --- .../remote_connection_controller.cpp | 26 +++++-- .../network/client/remote/remote_client.cpp | 73 +++++++++++++++---- 2 files changed, 78 insertions(+), 21 deletions(-) diff --git a/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp b/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp index 4ac29b49b..020e53877 100644 --- a/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp +++ b/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp @@ -244,17 +244,27 @@ void ConnectionController::onLoginError(int r, { switch (static_cast(r)) { case Response::RespClientUpdateRequired: { - QString formatted = "Missing Features: "; - for (int i = 0; i < missingFeatures.size(); ++i) { - formatted.append(QString("\n %1").arg(QChar(0x2022)) + " " + missingFeatures.value(i)); - } - QMessageBox msgBox(dialogParent); msgBox.setIcon(QMessageBox::Critical); msgBox.setWindowTitle(tr("Failed Login")); - msgBox.setText(tr("Your client seems to be missing features this server requires for connection.") + - "\n\n" + tr("To update your client, go to 'Help -> Check for Client Updates'.")); - msgBox.setDetailedText(formatted); + + if (missingFeatures.isEmpty()) { + // Without a feature list this is either a login the client stopped itself or a + // strict server asking for a password; the reason string already says which and + // what to do. No client update can fix any of those causes, so show it instead of + // the update advice. + msgBox.setText(reasonStr.isEmpty() ? tr("The login did not complete.") : reasonStr); + } else { + QString formatted = tr("Missing Features:"); + for (const QString &feature : missingFeatures) { + formatted.append(QString("\n %1 ").arg(QChar(0x2022)) + feature); + } + + msgBox.setText(tr("Your client seems to be missing features this server requires for connection.") + + "\n\n" + tr("To update your client, go to 'Help -> Check for Client Updates'.")); + msgBox.setDetailedText(formatted); + } + msgBox.exec(); break; } diff --git a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp index 51cb73750..7eab58668 100644 --- a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp +++ b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp @@ -40,6 +40,47 @@ class QObject; static const unsigned int protocolVersion = 14; +// Explanations for logins that do not complete but carry no missing-feature list: the +// ones RemoteClient stops itself, plus a strict server asking for a password. They all +// travel with RespClientUpdateRequired and are shown by the same dialog, so their wording +// is kept together here and adjusted as a set. The context has to stay "RemoteClient" for +// the tr() calls at the use sites to find the translations. +namespace +{ +// The server warns that this account still uses the legacy password hash while this client +// holds a scrypt verifier. Sending the verifier would downgrade the proof, so the client +// stops; the login only succeeds after the saved verifier is gone and the user types the +// password, which also upgrades the account. +constexpr const char *const LOGIN_ABORT_AUTH_DOWNGRADE = QT_TRANSLATE_NOOP( + "RemoteClient", + "The server still uses an old password format for this account, so the saved login on this computer cannot be " + "used. Untick 'Save password' in the Connect window and connect; when it opens again, type your password."); +// The account is still on the legacy hash and only the plaintext password can produce it. +constexpr const char *const LOGIN_ABORT_PASSWORD_REQUIRED = + QT_TRANSLATE_NOOP("RemoteClient", + "This account still uses an old password format. Type your password in the Connect window and " + "log in; the account is upgraded automatically."); +// Rejected by PasswordHasher::costParamsAreSane, either for being weak or for being too +// expensive for this client. Nothing on the client side can fix it. +constexpr const char *const LOGIN_ABORT_UNSAFE_SCRYPT_PARAMS = + QT_TRANSLATE_NOOP("RemoteClient", + "The server asked for password settings that Cockatrice will not accept because they are too " + "weak or too demanding. The login was stopped; only the server's staff can change this."); +// Scrypt derivation failed locally: a device resource problem, or server parameters this +// client cannot satisfy. +constexpr const char *const LOGIN_ABORT_VERIFIER_NOT_DERIVED = + QT_TRANSLATE_NOOP("RemoteClient", + "Cockatrice could not prepare your password for this login. Try again; if it keeps happening, " + "the server's password settings may be too demanding for this version of Cockatrice."); +constexpr const char *const LOGIN_ABORT_STORED_VERIFIER_UNUSABLE = + QT_TRANSLATE_NOOP("RemoteClient", + "The saved login for this account cannot be used. Type your password in the Connect window; if " + "'Save password' is ticked, a new saved login is created after you log in."); +constexpr const char *const LOGIN_ABORT_PASSWORD_MISSING = + QT_TRANSLATE_NOOP("RemoteClient", + "This server requires your password to log in. Type it in the Connect window and try again."); +} // namespace + RemoteClient::RemoteClient(QObject *parent, INetworkSettingsProvider *_networkSettingsProvider) : AbstractClient(parent), networkSettingsProvider(_networkSettingsProvider), timeRunning(0), lastDataReceived(0), messageInProgress(false), handshakeStarted(false), usingWebSocket(false), messageLength(0), hashedPassword(), @@ -335,9 +376,7 @@ void RemoteClient::passwordSaltResponse(const Response &response) // 1000-round legacy hash is a downgrade of the KDF — refuse it rather // than hand an attacker a weakly-keyed HMAC they can attack offline. if (resp.needs_migration() && PasswordHasher::parsePasswordVerifier(storedVerifier).isValid) { - emit loginError(Response::RespClientUpdateRequired, - QStringLiteral("Server asked to downgrade authentication for a migrated account."), 0, - {}); + emit loginError(Response::RespClientUpdateRequired, tr(LOGIN_ABORT_AUTH_DOWNGRADE), 0, {}); return; } QByteArray key; @@ -345,8 +384,7 @@ void RemoteClient::passwordSaltResponse(const Response &response) // The account still uses the legacy format; the legacy full hash // is only derivable from the plaintext password. if (password.isEmpty()) { - emit loginError(Response::RespClientUpdateRequired, - QStringLiteral("This account must be logged in with its password once."), 0, {}); + emit loginError(Response::RespClientUpdateRequired, tr(LOGIN_ABORT_PASSWORD_REQUIRED), 0, {}); return; } key = PasswordHasher::computeHash(password, passwordSalt).toUtf8(); @@ -357,8 +395,7 @@ void RemoteClient::passwordSaltResponse(const Response &response) const int r = resp.has_r() ? resp.r() : SCRYPT_R; const int p = resp.has_p() ? resp.p() : SCRYPT_P; if (!PasswordHasher::costParamsAreSane(n, r, p)) { - emit loginError(Response::RespClientUpdateRequired, - QStringLiteral("The server requested unreasonable scrypt cost parameters."), 0, {}); + emit loginError(Response::RespClientUpdateRequired, tr(LOGIN_ABORT_UNSAFE_SCRYPT_PARAMS), 0, {}); return; } // password_salt may carry a legacy backup salt for pre-challenge clients; @@ -367,8 +404,7 @@ void RemoteClient::passwordSaltResponse(const Response &response) resp.has_scrypt_salt() ? QString::fromStdString(resp.scrypt_salt()) : passwordSalt; key = PasswordHasher::deriveKey(password, QByteArray::fromBase64(scryptSalt.toUtf8()), n, r, p); if (key.isEmpty()) { - emit loginError(Response::RespClientUpdateRequired, QStringLiteral("Unable to derive verifier."), 0, - {}); + emit loginError(Response::RespClientUpdateRequired, tr(LOGIN_ABORT_VERIFIER_NOT_DERIVED), 0, {}); return; } derivedVerifier = @@ -376,8 +412,8 @@ void RemoteClient::passwordSaltResponse(const Response &response) } else if (!storedVerifier.isEmpty()) { const PasswordVerifier verifier = PasswordHasher::parsePasswordVerifier(storedVerifier); if (!verifier.isValid) { - emit loginError(Response::RespClientUpdateRequired, QStringLiteral("Stored verifier is invalid."), - 0, {}); + emit loginError(Response::RespClientUpdateRequired, tr(LOGIN_ABORT_STORED_VERIFIER_UNUSABLE), 0, + {}); return; } key = verifier.verifier; @@ -446,8 +482,19 @@ void RemoteClient::loginResponse(const Response &response) missingFeatures << QString::fromStdString(resp.missing_features(i)); } } - emit loginError(response.response_code(), QString::fromStdString(resp.denied_reason_str()), - static_cast(resp.denied_end_time()), missingFeatures); + + QString reasonStr = QString::fromStdString(resp.denied_reason_str()); + // A strict server reports a login that carried no credential as a missing feature, + // because challenge-response needs one. No client update can supply a password, so + // report it like the other local aborts instead of advising an update. + if (response.response_code() == Response::RespClientUpdateRequired && missingFeatures.size() == 1 && + missingFeatures.first() == "challenge_response_auth" && password.isEmpty() && storedVerifier.isEmpty()) { + missingFeatures.clear(); + reasonStr = tr(LOGIN_ABORT_PASSWORD_MISSING); + } + + emit loginError(response.response_code(), reasonStr, static_cast(resp.denied_end_time()), + missingFeatures); setStatus(StatusDisconnecting); } } From 06da7f815a4f24abef0b2f173971e904f2bef402 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 04:00:42 +0100 Subject: [PATCH 045/108] [Installer] End a launcher that has no window to ask instead of prompting (#7681) * [Installer] End a launcher that has no window to ask instead of prompting The in-app updater starts this installer detached and then closes itself. A client that accepts that close hides its window before it exits, and if it then hangs in shutdown there is nothing left to ask: the holder probe deliberately never posts WM_CLOSE to an instance it cannot see a window for, so the "ask it to close again" answer only repeated itself until the user picked Cancel and the installation aborted (Sentry COCKATRICE-DESKTOP-J). The launcher wait now asks the launcher itself before asking the user. A new probe mode, --close-launcher, answers whether that one process has a window a close could reach at all; it is limited to a pid that still runs from the directory being replaced, so a reused process id cannot be hit. When there is no window, the installer gives the shutdown it already started one full grace and then ends it through the handle it waited on, which is what the dialog's Yes answer did. The prompt remains for the case it exists for: a window is up, the instance may be waiting on its own question, and No now means only "keep waiting". The client is left alone: an already-installed version runs the installer during its own update, so a change there cannot reach the run that aborts. * [libcockatrice_rng] Include immintrin.h for AVX2 builds in SFMT --------- Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> --- cmake/NSIS.template.in | 110 +++++++++++++++--- .../libcockatrice/rng/sfmt/SFMT.h | 8 ++ .../installer-holder-probe/close_holders.cpp | 30 ++--- tools/installer-holder-probe/close_holders.h | 18 ++- tools/installer-holder-probe/main.cpp | 108 +++++++++++++++-- 5 files changed, 233 insertions(+), 41 deletions(-) diff --git a/cmake/NSIS.template.in b/cmake/NSIS.template.in index cda370670..bc121ff25 100644 --- a/cmake/NSIS.template.in +++ b/cmake/NSIS.template.in @@ -74,6 +74,11 @@ Var WaitSlice ; be ended from the answer the dialog offers. Cleared when only the synchronize right could ; be had (see WaitForLauncherToExit). Var WaitCanEnd +; How far the launcher wait got with a launcher that has no window to ask: 0 before the +; first round, 1 after the grace it was given, 2 after it was ended. A launcher whose +; window is gone has answered its own close and is stuck in shutdown, so the dialog cannot +; reach it and the wait may end it instead of asking the user (see WaitForLauncherToExit). +Var WaitWindowless Var InstallLogPath Var LogHandle Var LogMessage @@ -1241,6 +1246,33 @@ Function AbortInstall Abort FunctionEnd +; usage: set $GuardDir and $WaitPid, then call this. Asks the one process the installer +; waits on to close and sets $R3 to what it could do: 1 = a window exists and was asked to +; close (or is waiting on a question of its own), 4 = it has no window a user could answer, +; anything else = the probe could not answer. Only 4 lets the wait act on its own; every +; other code keeps the user in the decision, because the installer holds a live handle and +; "the probe did not find it" is not the same answer as "it is gone". +; +; The guard's CloseHolders answers the same question for every holder and leaves a +; windowless instance to the user, which is right there: the prompt names it. The launcher +; is the one process nobody can be asked about - the in-app updater runs the installer +; silent and detached, and a client that has already hidden its window has answered its +; own prompts - so this answer has to say whether there was a window to ask at all. +Function CloseLauncher + Call ExtractHolderProbe + System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")' + System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_LAUNCHER_PID", "$WaitPid")' + ; The probe waits 30 s itself when it posts a close. The timeout here only bounds a + ; probe that never returns; the Restart Manager call the other modes make is not + ; reached in this one. + nsExec::ExecToStack /TIMEOUT=40000 '"$PLUGINSDIR\installer-holder-probe.exe" --close-launcher' + Pop $R3 + Pop $R4 + Push "close-launcher: probe exit=$R3 [$R4]" + Call CockLogWrite + DetailPrint "Closing Cockatrice: $R4" +FunctionEnd + ; Waits for the application that launched this installer to exit, using the /PID= ; it passed. Sets $LauncherExited to 1 when that process is provably gone. ; @@ -1255,8 +1287,16 @@ FunctionEnd ; Sets $LauncherExited to 0 when no usable /PID= was given, which is the older ; client that does not pass one; the caller then has no authority available and ; falls back to the holder probe. +; +; A launcher that survives the first grace is asked to close by the probe, and the +; probe also answers whether there was a window a close could reach. A client hides +; its window the moment its own close is accepted, so a launcher without one has +; already answered its own prompts and is stuck in shutdown: no dialog can reach it +; there, and the wait ends it after one more grace instead of asking the user a +; question whose "keep waiting" answer can never change anything. Function WaitForLauncherToExit StrCpy $LauncherExited 0 + StrCpy $WaitWindowless 0 !insertmacro CockLog "wait-launcher: pid='$WaitPid'" ${If} $WaitPid == "" !insertmacro CockLog "wait-launcher: no pid given, falling back to name check" @@ -1324,14 +1364,58 @@ Function WaitForLauncherToExit ; below has to act on that process and not on whatever $WaitPid names by then. !insertmacro CockLog "wait-launcher: did not exit within $WaitSlice ms (wait=$R1, canEnd=$WaitCanEnd)" DetailPrint "The application that started this installer did not exit in time." - ; Same three answers as every other prompt that waits on a running program, and - ; "carry on anyway" is not one of them: a live process has its image and its Qt - ; DLLs mapped, so writing the payload over them is the broken install this wait - ; exists to prevent. Yes ends the launcher, No asks it to close again and keeps - ; waiting, Cancel cancels - the launcher is the client the user started this update - ; from, and ending it is the same offer the guard makes a moment later. + ; Ask the launcher itself before asking the user. A client that has already hidden + ; its window has answered its own prompts and is stuck in shutdown: there is nothing + ; left a dialog could reach, and the "ask it to close again" answer the dialog used + ; to offer could only repeat itself until the user cancelled. + StrCpy $GuardDir $INSTDIR + Call CloseLauncher + ; $R3 = what the probe could do (see CloseLauncher), $R4 = its line, already logged. + ; A process that answered the close during the probe's own wait is gone by now. + System::Call 'kernel32::WaitForSingleObject(p R0, i 0) i.R1' + ${If} $R1 == 0 + System::Call 'kernel32::CloseHandle(p R0)' + StrCpy $R0 0 + Goto wait_launcher_open + ${EndIf} + ${If} $R3 == "4" + ; Nothing a user could answer: the window is gone, so the launcher is past its + ; own prompts and stuck in shutdown. It cannot be asked, and it is not going to + ; ask the user anything either, so the dialog below would be unanswerable. + ${If} $WaitCanEnd == 1 + ${If} $WaitWindowless == 0 + ; Hiding the window is what the client does when its close is accepted, + ; so what remains is the shutdown it already started: one full grace for + ; that, without a question nobody could act on. + StrCpy $WaitWindowless 1 + StrCpy $WaitSlice 60000 + !insertmacro CockLog "wait-launcher: no window to ask, giving it one more minute" + System::Call 'kernel32::CloseHandle(p R0)' + StrCpy $R0 0 + Goto wait_launcher_open + ${EndIf} + ${If} $WaitWindowless == 1 + ; It did not use that minute either. Ending it is what the dialog's Yes + ; answer would have done, and there is no question left to put to the + ; user: the instance lost its window before this round started. + StrCpy $WaitWindowless 2 + !insertmacro CockLog "wait-launcher: still no window to ask; ending the launcher" + DetailPrint "The application that started this installer could not be asked to close; ending it." + System::Call 'kernel32::TerminateProcess(p R0, i 1) i.R2' + !insertmacro CockLog "wait-launcher: ending the launcher returned $R2" + System::Call 'kernel32::CloseHandle(p R0)' + StrCpy $R0 0 + Goto wait_launcher_open + ${EndIf} + ${EndIf} + ${EndIf} + ; A window exists, or the launcher cannot be ended from here: the three answers + ; below. "Carry on anyway" is not one of them - a live process has its image and its + ; Qt DLLs mapped, so writing the payload over them is the broken install this wait + ; exists to prevent. Yes ends the launcher, Cancel cancels, and No keeps waiting: + ; the close and the bring-forward just happened above. MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON2 \ - "Cockatrice has not finished closing, so the installation is paused rather than replace files it is still using.$\r$\n$\r$\nIt may be waiting for you to answer its own question about an unsaved deck, an open game or a card database update.$\r$\n$\r$\nChoose Yes to end it now - an unsaved deck, a running game or an unwritten setting is lost.$\r$\n$\r$\nChoose No to bring it to the front, ask it to close again and keep waiting, or Cancel to cancel the installation." \ + "Cockatrice has not finished closing, so the installation is paused rather than replace files it is still using.$\r$\n$\r$\nIt may be waiting for you to answer its own question about an unsaved deck, an open game or a card database update.$\r$\n$\r$\nChoose Yes to end it now - an unsaved deck, a running game or an unwritten setting is lost.$\r$\n$\r$\nChoose No to keep waiting, or Cancel to cancel the installation." \ IDYES wait_launcher_end IDNO wait_launcher_retry !insertmacro CockLog "wait-launcher: cancelled by user" StrCpy $AbortReason \ @@ -1357,14 +1441,12 @@ Function WaitForLauncherToExit StrCpy $R0 0 ; The question has been asked and answered once; from here the wait is the full ; grace again, so a slow flush or a confirmation being answered does not turn - ; into a stream of prompts. + ; into a stream of prompts. The close itself and the bring-forward already + ; happened just before the dialog, so this only waits. The windowless grace starts + ; over as well: "keep waiting" must not be answered by ending the process a minute + ; later without asking again. + StrCpy $WaitWindowless 0 StrCpy $WaitSlice 60000 - ; The same request the window's close button makes, and the same close the guard - ; would send a moment later. It brings the client forward as well, which is the - ; part that helps when the confirmation is behind something else. Nothing is - ; force-stopped here: the answer that does that is Yes. - StrCpy $GuardDir $INSTDIR - Call CloseHolders Goto wait_launcher_open ${EndIf} System::Call 'kernel32::CloseHandle(p R0)' diff --git a/libcockatrice_rng/libcockatrice/rng/sfmt/SFMT.h b/libcockatrice_rng/libcockatrice/rng/sfmt/SFMT.h index 34d9e746f..df114cf9b 100644 --- a/libcockatrice_rng/libcockatrice/rng/sfmt/SFMT.h +++ b/libcockatrice_rng/libcockatrice/rng/sfmt/SFMT.h @@ -113,6 +113,14 @@ union W128_T { /** 128-bit data type */ typedef union W128_T w128_t; +#if defined(__AVX2__) +/* The AVX2 state below names __m256i (and __m512i under AVX-512VL). The SIMD header + above is only pulled in for HAVE_SSE2, so a build that has __AVX2__ from the compiler + (e.g. -march=native) but not HAVE_SSE2 from the build system would otherwise not have + those types declared. */ + #include +#endif + /** * SFMT internal state */ diff --git a/tools/installer-holder-probe/close_holders.cpp b/tools/installer-holder-probe/close_holders.cpp index 791347408..d1831e4e9 100644 --- a/tools/installer-holder-probe/close_holders.cpp +++ b/tools/installer-holder-probe/close_holders.cpp @@ -15,8 +15,6 @@ namespace { -constexpr wchar_t COCKATRICE_IMAGE[] = L"cockatrice.exe"; - /// How long one poll of the asked processes takes. One WaitForSingleObject per process /// and no enumeration, so polling costs nothing next to the seconds this is called on. constexpr unsigned long WAIT_POLL_MILLISECONDS = 200; @@ -26,11 +24,6 @@ constexpr unsigned long WAIT_POLL_MILLISECONDS = 200; /// the line stays far below it. constexpr std::size_t REPORT_CHAR_MAX = 900; -bool isCockatriceImage(const std::wstring &imageName) -{ - return CompareStringOrdinal(imageName.c_str(), -1, COCKATRICE_IMAGE, -1, TRUE) == CSTR_EQUAL; -} - /// The holder as the log has to name it to be actionable. Deliberately not /// formatHolder(): that one is worded for the dialog, where ", running" says something /// the line here continues after. @@ -112,7 +105,7 @@ struct CloseTarget { HWND main = nullptr; ///< first visible window without an owner: the application's own HWND toRaise = nullptr; ///< topmost visible window: a dialog when one is up - bool modalOpen = false; ///< the window a close would reach is disabled, so a dialog of its own is up + bool modalOpen = false; ///< the only window a user could answer is the instance's own question }; /// Only the windows the user could answer are candidates - see isAnswerableWindow. An @@ -136,9 +129,11 @@ CloseTarget chooseCloseTarget(const std::vector &windows) } target.toRaise = topmost != nullptr ? topmost : target.main; // A modal dialog disables the windows behind it, so a disabled main window is the - // instance asking a question of its own. - target.modalOpen = - target.toRaise != nullptr && IsWindowEnabled(target.main != nullptr ? target.main : target.toRaise) == FALSE; + // instance asking a question of its own. A visible window with no unowned main window + // next to it is one too: what is on screen is then the question itself - the window + // behind it is hidden, not gone - and there is no window a close could be posted to + // without hiding the question with it. + target.modalOpen = target.toRaise != nullptr && (target.main == nullptr || IsWindowEnabled(target.main) == FALSE); return target; } @@ -213,6 +208,12 @@ bool waitForExit(const std::vector &processes, unsigned long waitMillise } } // namespace +bool isCockatriceImage(const std::wstring &imageName) +{ + constexpr wchar_t COCKATRICE_IMAGE[] = L"cockatrice.exe"; + return CompareStringOrdinal(imageName.c_str(), -1, COCKATRICE_IMAGE, -1, TRUE) == CSTR_EQUAL; +} + CloseHoldersResult closeCockatriceHolders(const std::vector &holders, unsigned long waitMilliseconds) { CloseHoldersResult result; @@ -233,18 +234,20 @@ CloseHoldersResult closeCockatriceHolders(const std::vector &holders, un search.processId = holder.processId; EnumWindows(collectProcessWindow, reinterpret_cast(&search)); if (search.windows.empty()) { + ++result.withoutWindow; lines.push_back(processLabel(holder) + L" has no window to close, left to you"); continue; } const CloseTarget target = chooseCloseTarget(search.windows); - if (target.main == nullptr) { + if (target.toRaise == nullptr) { // Nothing visible that a user could answer: the instance is hidden - in the // notification area, say - or has nothing but Qt's own bookkeeping windows on // screen. Neither is a window a close could reach, and showing one from here does // not help: Windows would display it while the application still believes it is // hidden, so it stays blank and answers nothing. The instance is named and left to // the user, who can open it from the notification area. + ++result.withoutWindow; lines.push_back(processLabel(holder) + L" has no window to ask, left to you"); continue; } @@ -255,7 +258,8 @@ CloseHoldersResult closeCockatriceHolders(const std::vector &holders, un // an earlier attempt useless: the client accepts it, hides this window and the dialog // with it, and stays alive inside the nested loop the dialog opened, where nothing can // reach it any more. The question is the request now, so it is brought forward and - // left alone. + // left alone - including a question whose owner window is hidden, which is the only + // thing left on screen and has no unowned window a close could be posted to. if (target.modalOpen) { lines.push_back(line + L", and is waiting for an answer to a question of its own"); continue; diff --git a/tools/installer-holder-probe/close_holders.h b/tools/installer-holder-probe/close_holders.h index 0e2b93b26..643624779 100644 --- a/tools/installer-holder-probe/close_holders.h +++ b/tools/installer-holder-probe/close_holders.h @@ -11,13 +11,27 @@ * * @c report is one line naming every holder, including the ones this deliberately * leaves alone, because the installer repeats it in its log and in the second prompt. + * @c withoutWindow counts the holders that had no visible window a user could answer: they + * were named and left alone, and the launcher mode reads that count as "nothing to ask" + * rather than as a window the user still has to answer. */ struct CloseHoldersResult { - unsigned int askedToClose = 0; ///< holders a WM_CLOSE was posted to - std::wstring report; ///< one line, narrowed to ASCII when written + unsigned int askedToClose = 0; ///< holders a WM_CLOSE was posted to + unsigned int withoutWindow = 0; ///< holders with no visible window a user could answer + std::wstring report; ///< one line, narrowed to ASCII when written }; +/** + * @brief Whether @p imageName is the client the update belongs to, matched without case. + * + * The close mode asks only this image to close, and the launcher mode is only allowed to + * report it as safe to end, so both read the answer from here. + * + * @param imageName the file name of a process image, empty when it could not be read + */ +bool isCockatriceImage(const std::wstring &imageName); + /** * @brief Brings every running Cockatrice holding an installer file forward and asks it * to close. diff --git a/tools/installer-holder-probe/main.cpp b/tools/installer-holder-probe/main.cpp index 0cbecb9f2..10a6c22e4 100644 --- a/tools/installer-holder-probe/main.cpp +++ b/tools/installer-holder-probe/main.cpp @@ -3,6 +3,7 @@ #include "restart_manager_holders.h" #include +#include #include #include #include @@ -14,6 +15,10 @@ namespace /// trailing backslash then never has to survive NSIS quoting or a command line. constexpr wchar_t INSTALL_DIR_VARIABLE[] = L"COCKATRICE_GUARD_DIR"; +/// The installer sets this to the process it waits on (/PID=), so the launcher mode reads +/// the same process id the wait does instead of one that has to survive NSIS quoting. +constexpr wchar_t LAUNCHER_PID_VARIABLE[] = L"COCKATRICE_LAUNCHER_PID"; + /// How long the close mode waits for the instance it asked to exit before it reports the /// process as still running. The installer measures the purge right after this call, so /// the wait belongs here: it is the only place that holds a handle on the process whose @@ -30,16 +35,19 @@ constexpr unsigned long END_WAIT_MILLISECONDS = 5000; /// In the close mode 1 means a close was actually sent and 0 that every holder was left /// to the user, which is what the report line next to the code spells out. 3 is only for /// a lookup that failed and named nobody: one that failed but still named a process -/// running from the directory is answered with that process. +/// running from the directory is answered with that process. 4 is only the launcher mode: +/// the process the installer waits on has no window a user could answer, which is the one +/// answer the installer acts on by itself instead of asking the user. enum ExitCode { EXIT_NO_HOLDERS = 0, EXIT_HOLDERS_FOUND = 1, EXIT_NOT_CONFIGURED = 2, EXIT_PROBE_FAILED = 3, + EXIT_NO_WINDOW = 4, }; -/// The probe has three modes, selected by the first argument: +/// The probe has four modes, selected by the first argument: /// /// (none) name what holds the payload, one line, for the guard prompt and the /// abort dialog/log @@ -48,18 +56,22 @@ enum ExitCode /// --end-holders end the running Cockatrice, Oracle and Servatrice and wait for them to /// be gone, for the last resort the guard prompt offers when a close did /// not free the files +/// --close-launcher ask the one process the installer waits on (/PID=) to close and say +/// whether there was a window to ask at all, because an instance with +/// no window has answered its own close and is stuck in shutdown /// -/// All three answer from two lookups that cover each other's blind spot: the Restart Manager -/// names whoever has a payload file open, and every process running from the directory is -/// named as well, because a running instance holds its own image and the modules it -/// loaded as mappings rather than open files and the Restart Manager does not always +/// The guard's three modes answer from two lookups that cover each other's blind spot: the +/// Restart Manager names whoever has a payload file open, and every process running from the +/// directory is named as well, because a running instance holds its own image and the modules +/// it loaded as mappings rather than open files and the Restart Manager does not always /// report those. Neither lookup alone is the guard's authority: an instance the file /// handles do not name is exactly the case that left a half-replaced install behind. enum class Mode { - Name, ///< name the holders - Close, ///< ask the holders to close - End, ///< end the holders + Name, ///< name the holders + Close, ///< ask the holders to close + End, ///< end the holders + CloseLauncher, ///< ask the one process the installer waits on to close }; Mode requestedMode(int argumentCount, char **arguments) @@ -72,6 +84,9 @@ Mode requestedMode(int argumentCount, char **arguments) if (mode == "--end-holders") { return Mode::End; } + if (mode == "--close-launcher") { + return Mode::CloseLauncher; + } } return Mode::Name; } @@ -92,11 +107,11 @@ void writeLine(const std::wstring &text) WriteFile(GetStdHandle(STD_OUTPUT_HANDLE), narrow.data(), static_cast(narrow.size()), &written, nullptr); } -std::wstring installDirFromEnvironment() +std::wstring environmentVariable(const wchar_t *name) { for (DWORD size = MAX_PATH; size <= 32768U; size *= 2) { std::vector buffer(size); - const DWORD length = GetEnvironmentVariableW(INSTALL_DIR_VARIABLE, buffer.data(), size); + const DWORD length = GetEnvironmentVariableW(name, buffer.data(), size); if (length == 0) { return {}; } @@ -106,6 +121,68 @@ std::wstring installDirFromEnvironment() } return {}; } + +std::wstring installDirFromEnvironment() +{ + return environmentVariable(INSTALL_DIR_VARIABLE); +} + +/// The process id the installer waits on, or false when it was not passed or is not a +/// number. A pid of 0 is refused because the installer never passes one. +bool launcherProcessIdFromEnvironment(unsigned long &processId) +{ + const std::wstring text = environmentVariable(LAUNCHER_PID_VARIABLE); + if (text.empty()) { + return false; + } + wchar_t *end = nullptr; + const unsigned long parsed = std::wcstoul(text.c_str(), &end, 10); + if (parsed == 0 || end == nullptr || *end != L'\0') { + return false; + } + processId = parsed; + return true; +} + +/// Asks the one process the installer waits on to close and answers what it could do. +/// +/// The guard closes a set and reports every holder; here there is exactly one process, and +/// the installer needs an answer it can act on rather than a sentence: a launcher with no +/// window to ask is past its own prompts and stuck in shutdown, so no answer the installer +/// offers would ever reach it. Everything else - a window that was asked, or one that is +/// waiting on a question of its own - is still the user's decision. +int closeLauncher(const std::wstring &installDir) +{ + unsigned long processId = 0; + if (!launcherProcessIdFromEnvironment(processId)) { + writeLine(L"the probe was not given the launcher process id"); + return EXIT_NOT_CONFIGURED; + } + + // Only an instance that still runs from the directory being replaced is asked: a bare + // process id names anything once it is reused, while a process running from the + // directory is the one the installer started this update from. + for (const Holder &holder : findProcessesRunningFrom(installDir)) { + if (holder.processId != processId) { + continue; + } + if (!isCockatriceImage(holder.imageName)) { + writeLine(L"the launcher is not cockatrice.exe, left to you"); + return EXIT_PROBE_FAILED; + } + const CloseHoldersResult result = closeCockatriceHolders({holder}, CLOSE_WAIT_MILLISECONDS); + writeLine(result.report); + if (result.askedToClose > 0) { + return EXIT_HOLDERS_FOUND; + } + return result.withoutWindow > 0 ? EXIT_NO_WINDOW : EXIT_HOLDERS_FOUND; + } + // Not an answer: the lookup may have failed or refused the image, and the installer + // holds a live handle on that process, so "not found" must not read as "gone". The + // installer shows its dialog for this code instead of acting on it. + writeLine(L"the launcher is not running from the directory being replaced, left to you"); + return EXIT_PROBE_FAILED; +} } // namespace int main(int argumentCount, char **arguments) @@ -116,6 +193,14 @@ int main(int argumentCount, char **arguments) return EXIT_NOT_CONFIGURED; } + const Mode mode = requestedMode(argumentCount, arguments); + // The launcher mode answers about exactly one process, so the holder set below is what it + // does not need - and the Restart Manager call behind it is the slowest thing this tool + // does, which must not delay a wait that started with a ten-second grace. + if (mode == Mode::CloseLauncher) { + return closeLauncher(installDir); + } + // Processes running from the directory first: they are the instance the user is asked // to close, and the Restart Manager may not name any of them (see // findProcessesRunningFrom). The file holders follow, deduplicated by process, so an @@ -147,7 +232,6 @@ int main(int argumentCount, char **arguments) return EXIT_PROBE_FAILED; } - const Mode mode = requestedMode(argumentCount, arguments); if (holders.empty()) { // No output in the naming mode: the installer words the "nothing is holding them" // case, and it owns the message either way. The acting modes say it themselves, From 9e50c5ad6e14194586849378d3fa0eb35f2ff03b Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:10:18 +0100 Subject: [PATCH 046/108] [Installer] Recover payload files when previous uninstaller is missing or fails to launch (#7682) Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> --- cmake/NSIS.template.in | 119 +++++++++++++++++++++++++++++++++-------- 1 file changed, 98 insertions(+), 21 deletions(-) diff --git a/cmake/NSIS.template.in b/cmake/NSIS.template.in index bc121ff25..ceba04877 100644 --- a/cmake/NSIS.template.in +++ b/cmake/NSIS.template.in @@ -15,10 +15,14 @@ Var ReinstallMode Var GuardDir Var OldUninstallString Var OldInstallDir -; What ExecWait reported for the previous version's uninstaller (empty when it could not be -; started at all). Read by WaitForOldPayloadToDisappear: an uninstaller that ran in place +; What ExecWait reported for the previous version's uninstaller, or "missing" / +; "launch-failed" when RunOldUninstaller could not start it. Read by WaitForOldPayloadToDisappear: an uninstaller that ran in place ; (_?=) and failed or was cancelled will not remove anything more, however long we wait. Var OldUninstallExit +; Executable path parsed out of $OldUninstallString by ResolveOldUninstallExe. Used to +; tell an uninstaller that is missing or cannot be started ("missing" / "launch-failed" +; in $OldUninstallExit) from one that ran and failed. +Var OldUninstallExe ; Set to 1 by RemoveOldPayloadLeftovers only when every leftover it is responsible for is ; gone; WaitForOldPayloadToDisappear aborts while it is still 0. Var OldPayloadRemoved @@ -397,6 +401,61 @@ ${If} $ReinstallMode = 1 ${EndIf} FunctionEnd +; Sets $OldUninstallExe to the executable named in $OldUninstallString (everything up to +; and including the first ".exe", without a leading quote), or "" when there is none. +Function ResolveOldUninstallExe + Push $R7 + Push $R8 + StrLen $R8 $OldUninstallString + StrCpy $R7 0 + roue_loop: + ${If} $R7 >= $R8 + StrCpy $OldUninstallExe "" + Goto roue_out + ${EndIf} + StrCpy $OldUninstallExe $OldUninstallString 4 $R7 + ${If} $OldUninstallExe == ".exe" + IntOp $R7 $R7 + 4 + StrCpy $OldUninstallExe $OldUninstallString $R7 + StrCpy $R8 $OldUninstallExe 1 + ${If} $R8 == '"' + StrCpy $OldUninstallExe $OldUninstallExe "" 1 + ${EndIf} + Goto roue_out + ${EndIf} + IntOp $R7 $R7 + 1 + Goto roue_loop + roue_out: + Pop $R8 + Pop $R7 +FunctionEnd + +; Runs "$OldUninstallString _?=$OldInstallDir" in place and sets $OldUninstallExit: +; the uninstaller's own exit code when it ran, "missing" when the executable the +; registry names does not exist, "launch-failed" when ExecWait could not start it. +; The last two used to leave the code empty, which WaitForOldPayloadToDisappear read +; as a cancelled uninstall and aborted on, although nobody had cancelled anything. +; Uses $R5 for the exit code, as the callers did before. +Function RunOldUninstaller + Call ResolveOldUninstallExe + StrCpy $R5 "" + ${If} $OldUninstallExe == "" + ${OrIfNot} ${FileExists} "$OldUninstallExe" + StrCpy $OldUninstallExit "missing" + !insertmacro CockLog "old uninstall: uninstaller '$OldUninstallExe' does not exist (command '$OldUninstallString')" + Return + ${EndIf} + ClearErrors + ExecWait '$OldUninstallString _?=$OldInstallDir' $R5 + ${If} ${Errors} + ${OrIf} $R5 == "" + StrCpy $OldUninstallExit "launch-failed" + !insertmacro CockLog "old uninstall: could not start '$OldUninstallString _?=$OldInstallDir'" + Return + ${EndIf} + StrCpy $OldUninstallExit $R5 +FunctionEnd + Function AutoUninstallIfNeeded SetShellVarContext all @@ -421,10 +480,9 @@ StrCpy $OldInstallDir $INSTDIR StrCmp $R0 "" done32 DetailPrint "Removing previous version (32-bit)..." Call BackupUserDataBeforeUninstall -StrCpy $R5 "" -ExecWait '$R0 _?=$OldInstallDir' $R5 -StrCpy $OldUninstallExit $R5 -!insertmacro CockLog "uninstall (32-bit): exit=$R5" +StrCpy $OldUninstallString $R0 +Call RunOldUninstaller +!insertmacro CockLog "uninstall (32-bit): exit=$OldUninstallExit" Call WaitForOldPayloadToDisappear Call RestoreUserDataAfterUninstall @@ -438,12 +496,11 @@ ${If} ${RunningX64} StrCpy $OldInstallDir $INSTDIR !insertmacro CockLog "uninstall (64-bit): '$R0 _?=$OldInstallDir'" StrCmp $R0 "" done64 - StrCpy $R5 "" DetailPrint "Removing previous version (64-bit)..." Call BackupUserDataBeforeUninstall - ExecWait '$R0 _?=$OldInstallDir' $R5 - StrCpy $OldUninstallExit $R5 - !insertmacro CockLog "uninstall (64-bit): exit=$R5" + StrCpy $OldUninstallString $R0 + Call RunOldUninstaller + !insertmacro CockLog "uninstall (64-bit): exit=$OldUninstallExit" Call WaitForOldPayloadToDisappear Call RestoreUserDataAfterUninstall @@ -471,6 +528,28 @@ Function WaitForOldPayloadToDisappear ${If} $OldInstallDir == "" Return ${EndIf} +; The registered uninstaller could not be run at all (missing, or ExecWait could not +; start it): nothing is going to empty the directory, so waiting is pointless and +; aborting as "cancelled" was wrong. Remove the previous payload the same way the +; skipped-files recovery below does, and abort - naming why - only if that fails. +${If} $OldUninstallExit == "missing" +${OrIf} $OldUninstallExit == "launch-failed" + ${IfNot} ${FileExists} "$OldInstallDir\*.*" + !insertmacro CockLog "old uninstall: uninstaller $OldUninstallExit and $OldInstallDir is already gone" + Return + ${EndIf} + !insertmacro CockLog "old uninstall: uninstaller $OldUninstallExit ('$OldUninstallString'); removing $OldInstallDir payload directly" + Call AppendUninstallerLog + Call RemoveOldPayloadLeftovers + ${If} $OldPayloadRemoved != "1" + StrCpy $AbortReason \ + "The previous version in $OldInstallDir could not be removed: its uninstaller ($OldUninstallExe) could not be run ($OldUninstallExit), and removing the files directly failed.$\r$\n$\r$\nStill in use by: $HolderOutput$\r$\n$\r$\nClose anything still using those files and run this installer again." + Call RestoreUserDataAfterUninstall + Call AbortInstall + ${EndIf} + !insertmacro CockLog "old uninstall: continuing after removing the payload of an uninstaller that could not run" + Return +${EndIf} StrCpy $R2 0 old_uninstall_wait: IfFileExists "$OldInstallDir\cockatrice.exe" 0 old_uninstall_gone @@ -481,8 +560,9 @@ ${If} $R2 > 60 ; separate log; copy it here so the report sent from AbortInstall carries it. Call AppendUninstallerLog ${If} $OldUninstallExit != "0" - ; Cancelled, failed, or could not be started at all (empty code): the old version - ; was not meant to be removed, so nothing is deleted on its behalf. + ; Cancelled or failed with a non-zero exit code (an uninstaller that could not be + ; started is handled above): the old version was not meant to be removed, so + ; nothing is deleted on its behalf. StrCpy $AbortReason \ "The previous version in $OldInstallDir is still there because its uninstaller was cancelled or failed (exit code '$OldUninstallExit').$\r$\n$\r$\nClose anything still using those files and run this installer again." ; The backup was taken before the uninstaller ran; an abort must not leave the @@ -524,7 +604,8 @@ ${EndIf} Return FunctionEnd -; Called when the old uninstaller exited successfully but left its payload behind. +; Called when the old uninstaller exited successfully but left its payload behind, or +; could not be run at all (missing or not startable) and the payload is still there. ; Frees the files the same way the guard does (close, then offer to end), deletes what ; the uninstaller skipped, and on failure records which files survived and who holds ; them in $HolderOutput, for the abort message. Sets $OldPayloadRemoved to 1 only when @@ -2046,10 +2127,8 @@ ${If} $PortableMode = 0 !insertmacro CockLog "componentsPagePre: old 32-bit uninstall '$OldUninstallString _?=$OldInstallDir' (oldDir=$OldInstallDir InstDir=$INSTDIR)" ClearErrors Call BackupUserDataBeforeUninstall - StrCpy $R5 "" - ExecWait "$OldUninstallString _?=$OldInstallDir" $R5 - StrCpy $OldUninstallExit $R5 - !insertmacro CockLog "componentsPagePre: old 32-bit uninstall exit=$R5" + Call RunOldUninstaller + !insertmacro CockLog "componentsPagePre: old 32-bit uninstall exit=$OldUninstallExit" Call WaitForOldPayloadToDisappear Call RestoreUserDataAfterUninstall @@ -2084,10 +2163,8 @@ ${If} $PortableMode = 0 !insertmacro CockLog "componentsPagePre: old 64-bit uninstall '$OldUninstallString _?=$OldInstallDir' (oldDir=$OldInstallDir InstDir=$INSTDIR)" ClearErrors Call BackupUserDataBeforeUninstall - StrCpy $R5 "" - ExecWait "$OldUninstallString _?=$OldInstallDir" $R5 - StrCpy $OldUninstallExit $R5 - !insertmacro CockLog "componentsPagePre: old 64-bit uninstall exit=$R5" + Call RunOldUninstaller + !insertmacro CockLog "componentsPagePre: old 64-bit uninstall exit=$OldUninstallExit" Call WaitForOldPayloadToDisappear Call RestoreUserDataAfterUninstall From ded83aaab55c205b86e7c06b44559b6480263ec6 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:01:16 +0100 Subject: [PATCH 047/108] [Installer] Ask a hidden instance to close, then offer to end it once (#7683) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Installer] Request quit for hidden Cockatrice instances and record holders in abort telemetry * [Installer] Fix compiler warnings in holder probe and installer quit handler * [VisualDeckStorage] Order the includes the way clang-format wants them * [Installer] Ask a hidden instance to close, then offer to end it once The previous attempt had the client answer a registered window message, which cannot work for the update it was reported on: the instance being replaced is a client from before that message exists, so it never answers, and the user is left with "Cockatrice is still running" exactly as reported. Every step that can free the files has to be the installer's, so the client side is reverted. The probe now posts WM_CLOSE to the instance's own window whether that window is on screen or only in the notification area: the request does not need a visible window, an instance in the tray is the one this update has no other way to reach, and the confirmation Cockatrice answers with is a window Qt shows itself, so it is painted - unlike one restored from outside. A visible window is brought forward first, and Qt's bookkeeping windows stay excluded, because a close to the notification-area one terminates the instance without its confirmation. A question of the instance's own no longer suppresses the close. Withholding it protected nobody: the installer asked the user to close a program it had never asked, and a current client refuses the close while its confirmation is pending anyway, leaving the question where it is. The launcher mode keeps the visible-only rule, because a launcher without a window has already answered its own close and the wait ends it after a grace instead of asking a user who is not there. The guard asked the same question until the user gave up: after the automatic close failed it offered to end the programs, then re-ran the probe and showed the same dialog word for word when the answer was No, which is how an abort was reported three times over. It now asks once - Yes ends the Cockatrice, Oracle and Servatrice programs the payload installs and says what that costs, No cancels - and what survives an end is named in the abort instead of being put to the user as the same question again. The uninstaller's guard and the recovery path after a failed purge follow the same shape. Kept: the abort telemetry that records the holders, the close result and the end result as event data. --------- Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> Co-authored-by: Lukas Brübach --- cmake/NSIS.template.in | 189 +++++++++--------- cmake/SendInstallerLog.ps1.in | 26 +++ .../deck_preview_deck_tags_display_widget.cpp | 2 +- .../installer-holder-probe/close_holders.cpp | 110 +++++----- tools/installer-holder-probe/close_holders.h | 43 ++-- tools/installer-holder-probe/holder_line.h | 6 +- tools/installer-holder-probe/main.cpp | 12 +- 7 files changed, 223 insertions(+), 165 deletions(-) diff --git a/cmake/NSIS.template.in b/cmake/NSIS.template.in index ceba04877..8d7a82f81 100644 --- a/cmake/NSIS.template.in +++ b/cmake/NSIS.template.in @@ -61,12 +61,8 @@ Var GuardOutput ; close, 1 after it. The prompt that follows offers to end the instance rather than asking ; a second time (see GuardAppsNotRunning). Var GuardCloseTried -; Set when the user answered Yes to ending the programs and the files were still in use -; afterwards, so the prompt that follows says the end did not help instead of reappearing -; word for word (see CockEndPrompt). -Var GuardEndFailed -; The question CockEndPrompt assembles: one sentence in it is conditional, and the result -; has to stay inside the characters one NSIS string holds. +; The question CockEndPrompt assembles: fixed sentences around the probe's line, and the +; result has to stay inside the characters one NSIS string holds. Var GuardPrompt Var WaitPid Var LauncherExited @@ -1043,8 +1039,9 @@ FunctionEnd ; the recovery prompt after a failed purge. !define END_COST "An unsaved deck, a running game or an unwritten setting is lost, Servatrice drops its players, and an Oracle that is mid-write can damage the card database." -; The other half of that answer - what is left to do when nothing is ended - is shared by -; every prompt that names the holders, for the same reason. +; What the user does instead when they say no: the prompt's other answer stops the run, so they +; are told how to get past the same wall next time. Also used by the abort dialogs, which is +; where the answer to "why did it stop" ends up. !define AFTER_CLOSING_GUIDANCE "Answer the confirmation Cockatrice may be showing - open it from the notification area if it is hidden there - and use Task Manager for a program that would not end." ; usage: set the variable named by the second argument to the probe's listing, then call @@ -1056,23 +1053,22 @@ FunctionEnd ; else the probe named has to be closed by hand, so a listing that includes a scanner or a ; service hosted elsewhere does not come with an answer that cannot do what it says. ; +; Two answers, one of each: the automatic close already ran when this is asked, so Yes is the +; only thing left that can free the files and No stops the run. There is deliberately no +; "try again" - the same question, word for word, with nothing changed in between was what +; made an abort out of a stall. +; ; The assembled string has to fit the characters one NSIS string holds, which is what ; bounds HOLDER_LINE_CHAR_MAX in tools\installer-holder-probe\holder_line.h: with the -; longest listing that can arrive and the sentence only a failed end adds, it stays inside -; that budget instead of being cut off at the tail, where the Cancel line is. +; longest listing that can arrive it stays inside that budget instead of being cut off at +; the tail, where the cancel line is. ; ; @param header the sentence that introduces the listing, with its trailing colon ; @param listing the variable holding the probe's line, $GuardOutput or $HolderOutput -; @param cancelWord what Cancel gives up, "installation" or "uninstallation" +; @param cancelWord what No gives up, "installation" or "uninstallation" !macro CockEndPrompt header listing cancelWord StrCpy $GuardPrompt "${header}$\r$\n$\r$\n${listing}" - ; Said only after the previous answer was Yes: without it the same dialog reappears word - ; for word, which is what "nothing happened" looked like - the end can fail on a process - ; the installer may not open, and it never touches a program that is not one of ours. - ${If} $GuardEndFailed = 1 - StrCpy $GuardPrompt "$GuardPrompt$\r$\n$\r$\nEnding them did not free the files." - ${EndIf} - StrCpy $GuardPrompt "$GuardPrompt$\r$\n$\r$\nChoose Yes to end the Cockatrice, Oracle and Servatrice programs listed above. ${END_COST}$\r$\n$\r$\nChoose No to try again after closing anything else listed above by hand. ${AFTER_CLOSING_GUIDANCE}$\r$\n$\r$\nChoose Cancel to cancel the ${cancelWord}." + StrCpy $GuardPrompt "$GuardPrompt$\r$\n$\r$\nChoose Yes to end the Cockatrice, Oracle and Servatrice programs listed above. ${END_COST}$\r$\n$\r$\nChoose No to cancel the ${cancelWord}. ${AFTER_CLOSING_GUIDANCE}" !macroend ; Emitted once for the installer and once for the uninstaller: the uninstaller @@ -1115,10 +1111,12 @@ FunctionEnd ; cockatrice.exe is asked: Oracle writes the card database in place and Servatrice has no ; window to ask, so both are left alone here and only ended by the explicit answer. ; -; Each instance is brought to the foreground before WM_CLOSE is posted to its main window. -; WM_CLOSE is answered, not obeyed - Cockatrice asks before it quits while a card database -; update is running, a game is open or a deck is unsaved - and that question is only useful -; if it is in front of the installer instead of behind it. +; Each instance's own window is asked to close with WM_CLOSE - whether it is on screen or +; only in the notification area, since the request does not need a visible window and the +; notification-area instance is the one this update has no other way to reach. A visible +; window is brought forward first. WM_CLOSE is answered, not obeyed - Cockatrice asks before +; it quits while a card database update is running, a game is open or a deck is unsaved - and +; that question is only useful if it is in front of the installer instead of behind it. ; ; The probe waits up to 30 s for the processes it asked to exit, so the check that follows ; measures a settled directory rather than sleeping for a fixed time. Its line goes into the @@ -1171,14 +1169,16 @@ FunctionEnd ; usage: set $GuardDir to the directory to guard, then call this. ; -; Asks Cockatrice to close once, then prompts until the payload is free. The automatic -; close is the same request clicking the window's close button makes, so the common case - -; the client was still shutting down - never turns into a question. The prompt is what -; remains when the close cannot reach everything: Oracle writes the card database in -; place, Servatrice has no window, and Cockatrice may still be waiting for an answer in -; its own confirmation. It offers to end the programs the payload installs - and only those, -; which the text says - with what that costs (Yes), a plain re-check for after the user -; closed things by hand (No), and Cancel - ending is the last resort, see EndHolders. +; Asks Cockatrice to close once, then asks one question with one answer that can free the +; payload. The automatic close is the same request clicking the window's close button makes, +; so the common case - the client was still shutting down - never turns into a question. The +; prompt is what remains when the close cannot reach everything: Oracle writes the card +; database in place, Servatrice has no window, and a client that is sitting on its own +; confirmation needs an answer nobody can give it from here. It offers to end the programs +; the payload installs - and only those, which the text says - with what that costs (Yes), +; and its other answer stops the install (No) instead of repeating the same dialog. What +; survives an end is reported in the abort, never asked about again - ending is the last +; resort, see EndHolders. ; ; An error is always shown, in every mode. A /S install without /R has nobody at ; the keyboard, but this installer does not promise unattended runs: when an @@ -1188,7 +1188,6 @@ FunctionEnd Function GuardAppsNotRunning !insertmacro CockLog "guard: checking $GuardDir (launcherExited=$LauncherExited)" StrCpy $GuardCloseTried 0 - StrCpy $GuardEndFailed 0 guard_check: Call RunHolderProbe ${If} $GuardExit == "0" @@ -1211,21 +1210,15 @@ Function GuardAppsNotRunning Goto guard_check ${EndIf} DetailPrint "The install directory is in use by: $GuardOutput" - ; The automatic close already ran once. Asking Cockatrice to close again is unlikely - ; to help: an instance that did not answer the first one is usually sitting on a - ; confirmation of its own, and the close probe leaves such an instance alone so that a - ; second WM_CLOSE cannot hide the window the question is in - which is how a pre-3.1.2 - ; update used to leave a headless process behind. An instance with no window has - ; nothing to ask at all. Ending is what is left that can free the files, so the prompt - ; offers it here, with what it costs in the text: an unsaved deck, a game, a settings - ; write, and an Oracle that may be mid-way through the card database. The answers that - ; end nothing stay reachable from the same dialog: the instance's own confirmation, - ; and closing anything by hand. + ; The close ran and did not free the files, so ending them is the only thing left that + ; can - and that is the one question, asked once. Its other answer stops the run + ; instead of asking again: a second identical dialog, with nothing changed in between, + ; is what made users cancel an update the installer could have finished by itself. !insertmacro CockEndPrompt "Cockatrice, Oracle or Servatrice is still using files of the installation:" $GuardOutput "installation" - MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON2 "$GuardPrompt" IDYES guard_end IDNO guard_check + MessageBox MB_YESNO|MB_ICONEXCLAMATION|MB_DEFBUTTON2 "$GuardPrompt" IDYES guard_end !insertmacro CockLog "guard: cancelled by user" StrCpy $AbortReason \ - "The installation was cancelled because Cockatrice, Oracle or Servatrice is still running and holding files of the previous version.$\r$\n$\r$\nClose them and run the installer again." + "The installation was cancelled because Cockatrice, Oracle or Servatrice is still running and holding files of the previous version.$\r$\n$\r$\n${AFTER_CLOSING_GUIDANCE}$\r$\n$\r$\nClose them and run the installer again." ; RemoveOldPayloadLeftovers runs with the pre-uninstall backup already taken; ; restoring here keeps this abort from stranding the only copy in %TEMP%. Call RestoreUserDataAfterUninstall @@ -1234,12 +1227,25 @@ Function GuardAppsNotRunning guard_end: !insertmacro CockLog "guard: ending the payload programs on request, then checking again" Call EndHolders - ; Everything the payload installs is ended, but a program that could not be opened - ; (another account, a protected process) survives, and a program that is not one of - ; ours is never touched at all, so what is left may still have to be closed by hand. - ; Go back to the same prompt, which says the end did not free the files. - StrCpy $GuardEndFailed 1 - Goto guard_check + ; Measure once and report, rather than asking the same question again. Everything the + ; payload installs was ended, so what survived is a program this installer may not + ; open or does not own - another dialog offering to end it again would only repeat + ; itself. + Call RunHolderProbe + ${If} $GuardExit == "0" + !insertmacro CockLog "guard: nothing is holding the payload" + Return + ${EndIf} + !insertmacro CockLog "guard: still in use after ending them ($GuardExit): $GuardOutput" + ${If} $GuardExit == "1" + StrCpy $AbortReason \ + "The installation was cancelled because these programs still hold files of the previous version and could not be ended:$\r$\n$\r$\n$GuardOutput$\r$\n$\r$\n${AFTER_CLOSING_GUIDANCE}$\r$\n$\r$\nClose them and run the installer again." + ${Else} + StrCpy $AbortReason \ + "The installation was cancelled because, after ending Cockatrice, Oracle and Servatrice, the installer could not check whether they still hold files of the previous version.$\r$\n$\r$\nThe check reported: $GuardOutput$\r$\n$\r$\nClose them and run the installer again." + ${EndIf} + Call RestoreUserDataAfterUninstall + Call AbortInstall ${EndIf} ; Not an answer at all: the probe was not configured, the Restart Manager ; call failed and nothing runs from the directory, or it timed out. Carrying @@ -1921,36 +1927,20 @@ FunctionEnd ; a decision, it asks for one. No offer when the probe named nobody: there is nothing to ; end, and the abort's per-file diagnostics already record what Windows said. ; -; Every round offers all three answers: Yes ends the programs the payload installs - and -; says so, because anything else the probe named is left to the user - and purges again, No -; purges again without ending anything (for when the user closed things by hand), and -; Cancel gives up. Each round re-reads the holders, so the prompt names what survived the -; last attempt rather than repeating the first listing. +; One question after the automatic close, and never a loop: Yes ends the programs the +; payload installs - and says so, because anything else the probe named is left to the user - +; and purges again; the other answer gives up, and the caller's abort names what survived. +; The listing is re-read before the question, so it says what is left now rather than +; repeating the first one. Function RecoverFromHolders ${If} $HolderFound != 1 !insertmacro CockLog "recover-holders: no holder was named, nothing to offer" Return ${EndIf} - StrCpy $GuardEndFailed 0 ; The automatic attempt, the same one the guard makes before it asks anything. !insertmacro CockLog "recover-holders: closing cockatrice, then trying the removal again" StrCpy $GuardDir $INSTDIR Call CloseHolders - Goto rfh_retry - rfh_prompt: - ; The same question the guard asks, so the two cannot drift apart in what they promise. - !insertmacro CockEndPrompt "Files of the previous version of Cockatrice are still in use by:" $HolderOutput "installation" - MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON2 "$GuardPrompt" IDYES rfh_end IDNO rfh_retry - !insertmacro CockLog "recover-holders: cancelled by user" - Return - ; Only reached through Yes: Return does not come back here. - rfh_end: - !insertmacro CockLog "recover-holders: ending the payload programs on request, then trying the removal again" - Call EndHolders - ; The next prompt says the end did not free the files, instead of naming the same - ; survivors word for word. - StrCpy $GuardEndFailed 1 - rfh_retry: Call RetryDllPurge StrCpy $StaleRuntimeAfterPurge $R0 !insertmacro CockLogListing "after-retry-purge" "*.dll" @@ -1960,13 +1950,33 @@ Function RecoverFromHolders DetailPrint "Files of the previous version were removed; continuing the installation." Return ${EndIf} - ; Still stuck: refresh the names, so the next prompt and the log describe what - ; survived the last attempt and not what was there before it. + ; Still stuck: refresh the names, so the prompt and the log describe what survived the + ; last attempt and not what was there before it. Call LogStaleRuntimeDiagnostics ${If} $HolderFound != 1 Return ${EndIf} - Goto rfh_prompt + ; One question, the same one the guard asks and with the same two answers. No retry loop: + ; the purge has already been tried after the close, so the only thing that can still free + ; the files is ending them, and the caller's abort names whatever survives that. + !insertmacro CockEndPrompt "Files of the previous version of Cockatrice are still in use by:" $HolderOutput "installation" + MessageBox MB_YESNO|MB_ICONEXCLAMATION|MB_DEFBUTTON2 "$GuardPrompt" IDYES rfh_end + !insertmacro CockLog "recover-holders: cancelled by user" + Return + ; Only reached through Yes: Return does not come back here. + rfh_end: + !insertmacro CockLog "recover-holders: ending the payload programs on request, then trying the removal again" + Call EndHolders + Call RetryDllPurge + StrCpy $StaleRuntimeAfterPurge $R0 + !insertmacro CockLog "recover-holders: stale-runtime after end=$StaleRuntimeAfterPurge" + ${If} $StaleRuntimeAfterPurge = 0 + DetailPrint "Files of the previous version were removed; continuing the installation." + Return + ${EndIf} + ; Refresh the names one last time so the abort that follows describes what survived the + ; end, and then let it stop the run: the same dialog again would only repeat itself. + Call LogStaleRuntimeDiagnostics FunctionEnd ; Uninstaller copy of the guard (the uninstaller gets its own function set compiled in, it @@ -1980,7 +1990,6 @@ FunctionEnd Function un.GuardAppsNotRunning StrCpy $GuardCloseTried 0 - StrCpy $GuardEndFailed 0 un_guard_check: !insertmacro CockLogUn "uninst-guard: checking $GuardDir" Call un.RunHolderProbe @@ -1999,15 +2008,12 @@ Function un.GuardAppsNotRunning Goto un_guard_check ${EndIf} DetailPrint "The install directory is in use by: $GuardOutput" - ; Same approach as in the installer's guard: the automatic close already ran once, and - ; asking again is unlikely to help an instance that is sitting on its own confirmation - ; or has no window a close could reach. Ending is what is left that can free the files, - ; so the prompt offers it here, with what it costs in the text, while keeping the - ; answers that end nothing - the instance's own question, and closing anything by hand - ; - reachable from the same dialog. CockEndPrompt words it, so this copy and the + ; Same question as in the installer's guard, and the same two answers: Yes ends the + ; programs the payload installs, anything else stops the uninstallation. Asking again + ; after a No only repeated the dialog. CockEndPrompt words it, so this copy and the ; installer's promise the same thing. !insertmacro CockEndPrompt "Cockatrice, Oracle or Servatrice is still using files of the installation:" $GuardOutput "uninstallation" - MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON2 "$GuardPrompt" IDYES un_guard_end IDNO un_guard_check + MessageBox MB_YESNO|MB_ICONEXCLAMATION|MB_DEFBUTTON2 "$GuardPrompt" IDYES un_guard_end !insertmacro CockLogUn "uninst-guard: cancelled by user" SetErrorLevel 1 Abort @@ -2015,12 +2021,17 @@ Function un.GuardAppsNotRunning un_guard_end: !insertmacro CockLogUn "uninst-guard: ending the payload programs on request, then checking again" Call un.EndHolders - ; Everything the payload installs is ended, but a program that could not be opened - ; (another account, a protected process) survives, and a program that is not one of - ; ours is never touched at all, so what is left may still have to be closed by hand. - ; Go back to the same prompt, which says the end did not free the files. - StrCpy $GuardEndFailed 1 - Goto un_guard_check + ; One measurement and a stop, not another question: what survived a terminate is a + ; program this uninstaller may not open or does not own, and ending it again cannot + ; change that. + Call un.RunHolderProbe + ${If} $GuardExit == "0" + !insertmacro CockLogUn "uninst-guard: nothing is holding the payload" + Return + ${EndIf} + !insertmacro CockLogUn "uninst-guard: still in use after ending them ($GuardExit): $GuardOutput" + SetErrorLevel 1 + Abort ${EndIf} ; Same reasoning as in the installer: without an answer from the probe we cannot tell ; whether a running instance still holds file locks, and refusing outright would leave @@ -2333,8 +2344,8 @@ ${If} $StaleRuntimeAfterPurge = 1 ${If} $HolderFound = 1 ; On /R the previous version is already uninstalled at this point, so an abort ; leaves the user with no working Cockatrice. It closes once, then offers to end - ; what is left or to try the removal again; this updates $StaleRuntimeAfterPurge - ; with the last retry's verdict. + ; what is left; this updates $StaleRuntimeAfterPurge with the verdict after that + ; close, or after the end when the user asked for one. Call RecoverFromHolders ${Else} ; Nothing is holding them. The three attempts in RetryDllPurge were two seconds diff --git a/cmake/SendInstallerLog.ps1.in b/cmake/SendInstallerLog.ps1.in index dfb8c3d63..585c726dd 100644 --- a/cmake/SendInstallerLog.ps1.in +++ b/cmake/SendInstallerLog.ps1.in @@ -57,6 +57,29 @@ try { } } + # --- What was holding the files, read from the log as well ------------------- + # The guard and the close attempt each log one line naming every holder and what + # happened to it. Carrying the last of each as event data keeps an abort caused by + # a running instance actionable without opening the attachment. Kept out of the + # message so the issue still groups on the reason alone. + $Holders = '' + $CloseResult = '' + $EndResult = '' + if (Test-Path -LiteralPath $LogPath) { + $holderLine = Select-String -LiteralPath $LogPath -Pattern 'guard: still in use by (.*)$' | Select-Object -Last 1 + if ($holderLine) { + $Holders = $holderLine.Matches[0].Groups[1].Value + } + $closeLine = Select-String -LiteralPath $LogPath -Pattern '(close-holders: .*)$' | Select-Object -Last 1 + if ($closeLine) { + $CloseResult = $closeLine.Matches[0].Groups[1].Value + } + $endLine = Select-String -LiteralPath $LogPath -Pattern '(end-holders: .*)$' | Select-Object -Last 1 + if ($endLine) { + $EndResult = $endLine.Matches[0].Groups[1].Value + } + } + # --- Event --------------------------------------------------------------- $eventId = [guid]::NewGuid().ToString('N') $now = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') @@ -75,6 +98,9 @@ try { extra = @{ reason = $Reason os = [System.Environment]::OSVersion.VersionString + holders = $Holders + close_result = $CloseResult + end_result = $EndResult } } $eventJson = $sentryEvent | ConvertTo-Json -Compress -Depth 6 diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.cpp index 1a06c0b18..11c8df54a 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.cpp @@ -12,9 +12,9 @@ #include #include -#include #include #include +#include #include #include #include diff --git a/tools/installer-holder-probe/close_holders.cpp b/tools/installer-holder-probe/close_holders.cpp index d1831e4e9..53a1fed8e 100644 --- a/tools/installer-holder-probe/close_holders.cpp +++ b/tools/installer-holder-probe/close_holders.cpp @@ -103,44 +103,50 @@ bool isAnswerableWindow(HWND window) /// Picks the window to post the close to and the one the user should end up looking at. struct CloseTarget { - HWND main = nullptr; ///< first visible window without an owner: the application's own + HWND main = nullptr; ///< first answerable window without an owner: the application's own HWND toRaise = nullptr; ///< topmost visible window: a dialog when one is up - bool modalOpen = false; ///< the only window a user could answer is the instance's own question + bool hidden = false; ///< the window being closed is not on screen (notification area) }; -/// Only the windows the user could answer are candidates - see isAnswerableWindow. An -/// instance left with nothing but Qt's own bookkeeping windows visible has no window to ask: -/// the caller reports that instead of posting a close the application would answer with a -/// terminate that skips its own confirmation. -CloseTarget chooseCloseTarget(const std::vector &windows) +/// Only the windows the user could be asked to answer are candidates - see isAnswerableWindow. +/// +/// A hidden window is a candidate like a visible one: WM_CLOSE does not need a window on +/// screen, so an instance that is only in the notification area answers it the way it answers +/// its own close button, and the confirmation it raises then is a window Qt shows itself - +/// painted, unlike one restored from outside. Nothing is shown from here. Qt's own bookkeeping +/// windows stay excluded, because a close posted to the notification-area one terminates the +/// instance without the confirmation and that is worse than no close at all. +/// +/// @param windows every top-level window the process owns, in Z-order +/// @param askHidden when false, only a window that is on screen is a target: the launcher +/// mode reads "no window" as "past its own prompts and stuck", which stops being true +/// the moment a hidden window is asked to close +CloseTarget chooseCloseTarget(const std::vector &windows, bool askHidden) { CloseTarget target; - HWND topmost = nullptr; for (const HWND window : windows) { - if (IsWindowVisible(window) == FALSE || !isAnswerableWindow(window)) { + if (!isAnswerableWindow(window)) { continue; } - if (topmost == nullptr) { - topmost = window; + const bool visible = IsWindowVisible(window) != FALSE; + if (visible && target.toRaise == nullptr) { + target.toRaise = window; } - if (target.main == nullptr && GetWindow(window, GW_OWNER) == nullptr) { + if (target.main == nullptr && GetWindow(window, GW_OWNER) == nullptr && (visible || askHidden)) { target.main = window; + target.hidden = !visible; } } - target.toRaise = topmost != nullptr ? topmost : target.main; - // A modal dialog disables the windows behind it, so a disabled main window is the - // instance asking a question of its own. A visible window with no unowned main window - // next to it is one too: what is on screen is then the question itself - the window - // behind it is hidden, not gone - and there is no window a close could be posted to - // without hiding the question with it. - target.modalOpen = target.toRaise != nullptr && (target.main == nullptr || IsWindowEnabled(target.main) == FALSE); return target; } /// What is left of the window the close was posted to, so a log line tells an instance that /// answered by hiding from one that never answered at all. A process that hid its window and -/// stayed alive is the one that needs the next close to bring it back into view. -std::wstring describeWindowState(HWND window) +/// stayed alive is the one the guard's end answer is for. +/// +/// @param window the window the close was posted to, or null when there was none +/// @param wasHidden whether that window was already off screen before the close +std::wstring describeWindowState(HWND window, bool wasHidden) { if (window == nullptr || IsWindow(window) == FALSE) { return L", its window is gone"; @@ -151,7 +157,12 @@ std::wstring describeWindowState(HWND window) DWORD_PTR ignored = 0; const bool responding = SendMessageTimeoutW(window, WM_NULL, 0, 0, SMTO_ABORTIFHUNG | SMTO_BLOCK, 1000, &ignored) != 0; - std::wstring state = IsWindowVisible(window) != FALSE ? L", window still open" : L", window hidden again"; + std::wstring state; + if (IsWindowVisible(window) != FALSE) { + state = L", window still open"; + } else { + state = wasHidden ? L", window still hidden" : L", window hidden again"; + } if (!responding) { state += L" and not responding"; } @@ -214,13 +225,15 @@ bool isCockatriceImage(const std::wstring &imageName) return CompareStringOrdinal(imageName.c_str(), -1, COCKATRICE_IMAGE, -1, TRUE) == CSTR_EQUAL; } -CloseHoldersResult closeCockatriceHolders(const std::vector &holders, unsigned long waitMilliseconds) +CloseHoldersResult +closeCockatriceHolders(const std::vector &holders, unsigned long waitMilliseconds, bool askHiddenWindows) { CloseHoldersResult result; std::vector lines; std::vector asked; std::vector askedLines; std::vector askedWindows; + std::vector askedWasHidden; for (const Holder &holder : holders) { if (holder.isService || !isCockatriceImage(holder.imageName)) { @@ -233,38 +246,36 @@ CloseHoldersResult closeCockatriceHolders(const std::vector &holders, un WindowSearch search; search.processId = holder.processId; EnumWindows(collectProcessWindow, reinterpret_cast(&search)); - if (search.windows.empty()) { + CloseTarget target = chooseCloseTarget(search.windows, askHiddenWindows); + if (target.main == nullptr) { + // Nothing of the application's own to post a close to: every window left is one of + // Qt's bookkeeping windows, or the only thing on screen is a question whose owner + // window is gone. Neither is a close the application would answer - a close to the + // notification-area window terminates without the confirmation - so the instance is + // named and the guard's end answer is what can free its files. ++result.withoutWindow; - lines.push_back(processLabel(holder) + L" has no window to close, left to you"); + lines.push_back(processLabel(holder) + L" has no window of its own to close"); continue; } - const CloseTarget target = chooseCloseTarget(search.windows); - if (target.toRaise == nullptr) { - // Nothing visible that a user could answer: the instance is hidden - in the - // notification area, say - or has nothing but Qt's own bookkeeping windows on - // screen. Neither is a window a close could reach, and showing one from here does - // not help: Windows would display it while the application still believes it is - // hidden, so it stays blank and answers nothing. The instance is named and left to - // the user, who can open it from the notification area. - ++result.withoutWindow; - lines.push_back(processLabel(holder) + L" has no window to ask, left to you"); - continue; - } std::wstring line = processLabel(holder); - line += bringForward(target.toRaise) ? L" brought forward" : L" could not be brought forward"; - - // Posting the close to an instance that is asking a question of its own is what made - // an earlier attempt useless: the client accepts it, hides this window and the dialog - // with it, and stays alive inside the nested loop the dialog opened, where nothing can - // reach it any more. The question is the request now, so it is brought forward and - // left alone - including a question whose owner window is hidden, which is the only - // thing left on screen and has no unowned window a close could be posted to. - if (target.modalOpen) { - lines.push_back(line + L", and is waiting for an answer to a question of its own"); - continue; + if (target.hidden) { + line += L" is hidden in the notification area"; + } else { + if (target.toRaise == nullptr) { + target.toRaise = target.main; + } + line += bringForward(target.toRaise) ? L" brought forward" : L" could not be brought forward"; } + // Posted to the instance's own window even when a question of its own is up. The close + // is what that window's close button does, and the application is the one that answers + // it: a current client refuses the close while its confirmation is pending, leaving the + // question in front, and only a client from before that guard accepts it and hides the + // window with the question. Withholding the request therefore protected nobody - it + // left the installer asking the user to close a program it had never asked - while the + // guard's end answer is what frees the files when the close cannot. + // // Kept open across the wait so a pid reused after the process exits cannot be // mistaken for the instance that was asked to close. const HANDLE process = OpenProcess(SYNCHRONIZE, FALSE, holder.processId); @@ -287,6 +298,7 @@ CloseHoldersResult closeCockatriceHolders(const std::vector &holders, un askedLines.push_back(lines.size()); asked.push_back(process); askedWindows.push_back(target.main); + askedWasHidden.push_back(target.hidden); } lines.push_back(std::move(line)); } @@ -300,7 +312,7 @@ CloseHoldersResult closeCockatriceHolders(const std::vector &holders, un lines[askedLines[index]] += L", exited after " + std::to_wstring(elapsed) + L" ms"; } else { lines[askedLines[index]] += L", still running after " + std::to_wstring(elapsed) + L" ms" + - describeWindowState(askedWindows[index]); + describeWindowState(askedWindows[index], askedWasHidden[index]); } } } diff --git a/tools/installer-holder-probe/close_holders.h b/tools/installer-holder-probe/close_holders.h index 643624779..91e6b316b 100644 --- a/tools/installer-holder-probe/close_holders.h +++ b/tools/installer-holder-probe/close_holders.h @@ -10,15 +10,15 @@ * @brief What asking the holders to close did, in the form the installer logs. * * @c report is one line naming every holder, including the ones this deliberately - * leaves alone, because the installer repeats it in its log and in the second prompt. - * @c withoutWindow counts the holders that had no visible window a user could answer: they - * were named and left alone, and the launcher mode reads that count as "nothing to ask" - * rather than as a window the user still has to answer. + * leaves alone, because the installer repeats it in its log. + * @c withoutWindow counts the holders that had no window of their own to ask: they were + * named and left alone, and the launcher mode reads that count as "nothing to ask" rather + * than as a window the user still has to answer. */ struct CloseHoldersResult { unsigned int askedToClose = 0; ///< holders a WM_CLOSE was posted to - unsigned int withoutWindow = 0; ///< holders with no visible window a user could answer + unsigned int withoutWindow = 0; ///< holders with no window of their own to ask std::wstring report; ///< one line, narrowed to ASCII when written }; @@ -42,27 +42,34 @@ bool isCockatriceImage(const std::wstring &imageName); * asked to close. Oracle and Servatrice are left to the user - Oracle writes the card * database in place - and so is every service. * - * One thing happens per instance: its main window is brought to the foreground, because - * Cockatrice may answer the close with a confirmation of its own that would otherwise open - * behind the installer's prompt. Then WM_CLOSE is posted to the main window, the same - * request as clicking its close button - unless the instance is asking a question of its - * own, which disables that window: posting the close then only hides the question, so it is - * brought forward and left to the user. An instance with no window a user could answer - - * one hidden in the notification area, say - is named and left to the user as well; showing - * its windows from here does not help, because the application would still believe they are - * hidden and never paint them. Stopping the process is never attempted. + * One thing happens per instance: its own window is asked to close with WM_CLOSE, the same + * request as clicking that window's close button - whether it is on screen or only in the + * notification area, since the request does not need the window to be visible and the + * confirmation Cockatrice answers it with is a window Qt shows itself, painted, unlike one + * restored from outside. A window that is on screen is brought forward first, because that + * confirmation would otherwise open behind the installer's prompt; nothing is shown from + * here. Qt's own bookkeeping windows are never the target - posting a close to the + * notification-area one terminates the instance without its confirmation - so a process + * without a window of its own is named and left to the guard, which offers to end it. + * Stopping the process is never attempted here. * * Once the requests are out, this waits up to @c waitMilliseconds for the processes to * exit, so the caller can measure the purge immediately afterwards instead of sleeping * for a fixed time or re-asking a question this call already answered. A process that is - * still running is described further: whether its window is gone, still open, hidden - * again, and whether it answers at all, so the log can tell an instance that refused the - * close from one that never received it. + * still running is described further: whether its window is gone, still open, still + * hidden or hidden again, and whether it answers at all, so the log can tell an instance + * that refused the close from one that never received it. * * @param holders the Restart Manager answer, one entry per application or service * @param waitMilliseconds how long to wait for the asked instances to exit, 0 to not wait + * @param askHiddenWindows whether a window that is not on screen is asked to close too. + * The guard wants that: an instance in the notification area is the one this update + * has no other way to reach. The launcher mode does not - a launcher without a + * visible window has already answered its own close and is stuck in shutdown, and + * the installer ends it instead of putting a question to a user who is not there. */ -CloseHoldersResult closeCockatriceHolders(const std::vector &holders, unsigned long waitMilliseconds); +CloseHoldersResult +closeCockatriceHolders(const std::vector &holders, unsigned long waitMilliseconds, bool askHiddenWindows); /** * @brief What ending the holders did, in the form the installer logs. diff --git a/tools/installer-holder-probe/holder_line.h b/tools/installer-holder-probe/holder_line.h index 163112ff6..675d79fc6 100644 --- a/tools/installer-holder-probe/holder_line.h +++ b/tools/installer-holder-probe/holder_line.h @@ -29,9 +29,9 @@ inline constexpr std::size_t HOLDER_LINE_MAX = 5; * The line is not the whole string: every prompt puts it after a fixed paragraph and before * the answers, and one NSIS string holds 1024 characters. Those paragraphs grew when the * guard started spelling out what ending the programs costs and what is left to do by hand, - * so this budget is what keeps the longest message - the one that also says the last end - * freed nothing, plus the "; and N more" the summary appends after the budget - complete, - * including the Cancel line at its end. Windows caps one application or service name well + * so this budget is what keeps the longest message - the one with the longest listing, plus + * the "; and N more" the summary appends after the budget - complete, including the sentence + * that offers the other answer at its end. Windows caps one application or service name well * below this, so it bounds the list without ever cutting a single holder short. */ inline constexpr std::size_t HOLDER_LINE_CHAR_MAX = 320; diff --git a/tools/installer-holder-probe/main.cpp b/tools/installer-holder-probe/main.cpp index 10a6c22e4..d88760b35 100644 --- a/tools/installer-holder-probe/main.cpp +++ b/tools/installer-holder-probe/main.cpp @@ -148,9 +148,9 @@ bool launcherProcessIdFromEnvironment(unsigned long &processId) /// /// The guard closes a set and reports every holder; here there is exactly one process, and /// the installer needs an answer it can act on rather than a sentence: a launcher with no -/// window to ask is past its own prompts and stuck in shutdown, so no answer the installer -/// offers would ever reach it. Everything else - a window that was asked, or one that is -/// waiting on a question of its own - is still the user's decision. +/// visible window has already answered its own close and is stuck in shutdown, so no answer +/// the installer offers would ever reach it. A window that was asked - including one the +/// instance is showing a question in - is still the user's decision. int closeLauncher(const std::wstring &installDir) { unsigned long processId = 0; @@ -170,7 +170,7 @@ int closeLauncher(const std::wstring &installDir) writeLine(L"the launcher is not cockatrice.exe, left to you"); return EXIT_PROBE_FAILED; } - const CloseHoldersResult result = closeCockatriceHolders({holder}, CLOSE_WAIT_MILLISECONDS); + const CloseHoldersResult result = closeCockatriceHolders({holder}, CLOSE_WAIT_MILLISECONDS, false); writeLine(result.report); if (result.askedToClose > 0) { return EXIT_HOLDERS_FOUND; @@ -251,7 +251,9 @@ int main(int argumentCount, char **arguments) return result.ended > 0 ? EXIT_HOLDERS_FOUND : EXIT_NO_HOLDERS; } - const CloseHoldersResult result = closeCockatriceHolders(holders, CLOSE_WAIT_MILLISECONDS); + // Hidden windows are asked to close here: an instance in the notification area is the one + // this update has no other way to reach, and the guard's end answer is behind that attempt. + const CloseHoldersResult result = closeCockatriceHolders(holders, CLOSE_WAIT_MILLISECONDS, true); writeLine(result.report); return result.askedToClose > 0 ? EXIT_HOLDERS_FOUND : EXIT_NO_HOLDERS; } From 0369aaffa973b5820d3b6270a8eceadaf94bc9c5 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:16:50 +0100 Subject: [PATCH 048/108] [Installer] Name only the payload programs installed in the directory (#7684) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The running-instance lookup named every process whose image path began with the install directory, subfolders included. Installing into a folder that holds another application in a subfolder therefore reported that application as holding the previous version, and nothing the guard offers could clear it: the close asks cockatrice.exe only, and the end answer refuses anything that is not a payload program. Both answers were no-ops, so the update could only abort and tell the user to close a program that was never touching the files. Name only the programs the payload installs, and only when the image lies in the install directory itself. A program below the directory that really does hold a payload file is still named by the Restart Manager lookup, which is the lookup for that question. The Windows probe check now covers a payload-named program in a subfolder and a program the payload does not install; both have to come back as nothing. Co-authored-by: Lukas Brübach --- .ci/compile.sh | 48 ++++++++++++++-- cmake/NSIS.template.in | 43 +++++++------- tests/CMakeLists.txt | 14 ++++- tests/payload_paths_test.cpp | 57 +++++++++++++++++++ tools/installer-holder-probe/CMakeLists.txt | 4 +- .../installer-holder-probe/close_holders.cpp | 1 + tools/installer-holder-probe/main.cpp | 22 +++---- .../installer-holder-probe/payload_paths.cpp | 52 +++++++++++++++++ tools/installer-holder-probe/payload_paths.h | 39 +++++++++++++ .../restart_manager_holders.cpp | 53 +++++++---------- .../restart_manager_holders.h | 35 +++++------- 11 files changed, 279 insertions(+), 89 deletions(-) create mode 100644 tests/payload_paths_test.cpp create mode 100644 tools/installer-holder-probe/payload_paths.cpp create mode 100644 tools/installer-holder-probe/payload_paths.h diff --git a/.ci/compile.sh b/.ci/compile.sh index 1a30a0fd3..cea837528 100755 --- a/.ci/compile.sh +++ b/.ci/compile.sh @@ -567,8 +567,9 @@ Start-Sleep -Seconds 120 # instance holds its payload as mapped images, not as handles, and that is the state # the guard used to miss - it passed, the purge failed, and the install ended in a # warning with no offer to close and try again. The image is a copy of a native - # Windows program, so it starts without its usual directory next to it. - $copy = Join-Path $dir "fake-cockatrice.exe" + # Windows program, so it starts without its usual directory next to it, and it carries + # one of the payload's own names because that is what the lookup names. + $copy = Join-Path $dir "cockatrice.exe" Copy-Item -LiteralPath (Join-Path $env:SystemRoot "System32\cmd.exe") -Destination $copy $running = Start-Process -FilePath $copy -ArgumentList "/c", "ping -n 20 127.0.0.1 >nul" -PassThru try { @@ -580,10 +581,49 @@ Start-Sleep -Seconds 120 try { $running.WaitForExit(5000) | Out-Null } catch { } } if ($byImage.Code -ne 1) { throw "expected exit 1 for a process running from the directory, got $($byImage.Code) : $($byImage.Output)" } - if ($byImage.Output -notmatch "fake-cockatrice\.exe \(pid\s+$($running.Id)") { + if ($byImage.Output -notmatch "cockatrice\.exe \(pid\s+$($running.Id)") { throw "the lookup did not name the process running from the directory: $($byImage.Output)" } - Write-Host "holder lookup named pid $($holder.Id) for a file another process holds, named nothing for a file only the asking process holds, named nothing for an empty directory, and named pid $($running.Id) for a process running from the directory" + + # A program in a folder below the directory is not an instance of the installation, even + # when it carries a payload program's name: the install directory can be a folder the + # user keeps other applications in, each in its own subfolder, and naming one of those + # asked for a close the probe never sends and an end it refuses to perform, so the guard + # could only abort the update. A program that really does hold a payload file is still + # named, by the Restart Manager lookup exercised above. + $nested = Join-Path $dir "other" + New-Item -ItemType Directory -Force $nested | Out-Null + $nestedImage = Join-Path $nested "cockatrice.exe" + Copy-Item -LiteralPath (Join-Path $env:SystemRoot "System32\cmd.exe") -Destination $nestedImage + $nestedRunning = Start-Process -FilePath $nestedImage -ArgumentList "/c", "ping -n 20 127.0.0.1 >nul" -PassThru + try { + Start-Sleep -Milliseconds 1500 + $inSubfolder = Invoke-Probe $dir + } finally { + try { $nestedRunning.Kill() } catch { } + try { $nestedRunning.WaitForExit(5000) | Out-Null } catch { } + } + if ($inSubfolder.Code -ne 0) { + throw "expected exit 0 for a program in a folder below the directory, got $($inSubfolder.Code) : $($inSubfolder.Output)" + } + + # A program the user keeps in the directory itself is not a payload program either: the + # guard ends only what the payload installs, and naming anything else asks for an answer + # that cannot free a single file. + $foreign = Join-Path $dir "tool.exe" + Copy-Item -LiteralPath (Join-Path $env:SystemRoot "System32\cmd.exe") -Destination $foreign + $foreignRunning = Start-Process -FilePath $foreign -ArgumentList "/c", "ping -n 20 127.0.0.1 >nul" -PassThru + try { + Start-Sleep -Milliseconds 1500 + $ignored = Invoke-Probe $dir + } finally { + try { $foreignRunning.Kill() } catch { } + try { $foreignRunning.WaitForExit(5000) | Out-Null } catch { } + } + if ($ignored.Code -ne 0) { + throw "expected exit 0 for a program the payload does not install, got $($ignored.Code) : $($ignored.Output)" + } + Write-Host "holder lookup named pid $($holder.Id) for a file another process holds, named nothing for a file only the asking process holds, named nothing for an empty directory, named pid $($running.Id) for a payload program running from the directory, and named nothing for a payload program in a folder below it or for a program the payload does not install" } finally { Remove-Item -Recurse -Force $dir -ErrorAction SilentlyContinue } diff --git a/cmake/NSIS.template.in b/cmake/NSIS.template.in index 8d7a82f81..d7a6e82c6 100644 --- a/cmake/NSIS.template.in +++ b/cmake/NSIS.template.in @@ -998,7 +998,8 @@ FunctionEnd ; repository and embedded in the installer and the uninstaller alike. It names ; every process and service that has one of the payload files open or mapped ; (the Restart Manager, RmRegisterResources/RmGetList) and, separately, every -; process whose image lives in the directory being replaced. +; payload program whose image lies in the directory being replaced rather than +; in a folder below it. ; ; The second lookup is not redundant. Restart Manager answers "who has this ; file open", and a running instance does not have to have one open: its own @@ -1084,10 +1085,10 @@ FunctionEnd ; usage: set $GuardDir to the directory to check, then call this. Sets ; $GuardExit to the probe's exit code and $GuardOutput to its one-line answer. -; 0 = nothing holds the payload and nothing runs from the directory, 1 = the +; 0 = nothing holds the payload and no payload program runs from the directory, 1 = the ; line names what does, anything else = the probe could not answer (2 = no -; directory was given, 3 = the Restart Manager call failed and no process runs -; from the directory either, "timeout" = it did not return in time). +; directory was given, 3 = the Restart Manager call failed and no payload program +; runs from the directory either, "timeout" = it did not return in time). Function ${prefix}RunHolderProbe Call ${prefix}ExtractHolderProbe ; The directory travels in an environment variable, never on the command @@ -1106,7 +1107,7 @@ FunctionEnd ; Cockatrice to close, and waits for it to exit. ; ; The set is what the probe names: the processes holding a file of the version being -; replaced, plus the processes running from the directory. tools\installer-holder-probe +; replaced, plus the payload programs running from the directory itself. The probe ; closes that set by pid, which is why nothing here has to match an executable path. Only ; cockatrice.exe is asked: Oracle writes the card database in place and Servatrice has no ; window to ask, so both are left alone here and only ended by the explicit answer. @@ -1248,14 +1249,15 @@ Function GuardAppsNotRunning Call AbortInstall ${EndIf} ; Not an answer at all: the probe was not configured, the Restart Manager - ; call failed and nothing runs from the directory, or it timed out. Carrying - ; on from that is a decision, so it is asked for rather than assumed - the - ; stale-runtime check after the purge still stands between a failed guard - ; and a mismatched install, but it only fires after files have been - ; replaced, and on an upgrade the launcher asked for nobody is watching the - ; installer's exit code. Retry asks the probe again (a Restart Manager that - ; was busy answers the second time), Ignore installs anyway, Abort cancels - ; instead of writing a payload next to files the guard could not rule out. + ; call failed and no payload program runs from the directory, or it timed + ; out. Carrying on from that is a decision, so it is asked for rather than + ; assumed - the stale-runtime check after the purge still stands between a + ; failed guard and a mismatched install, but it only fires after files have + ; been replaced, and on an upgrade the launcher asked for nobody is watching + ; the installer's exit code. Retry asks the probe again (a Restart Manager + ; that was busy answers the second time), Ignore installs anyway, Abort + ; cancels instead of writing a payload next to files the guard could not + ; rule out. !insertmacro CockLog "guard: the holder probe could not answer ($GuardExit): $GuardOutput" DetailPrint "Could not check whether Cockatrice is still running: $GuardOutput" MessageBox MB_ABORTRETRYIGNORE|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ @@ -1862,12 +1864,13 @@ FunctionEnd ; anything is replaced; here it answers the other question, about files a purge could not ; remove. It asks the Restart Manager (RmRegisterResources/RmGetList), which names a ; process holding a plain handle on a file - an antivirus scanner - and names services as -; well as user programs, and it adds every process running from the directory, because a -; running instance holds its payload as mapped images rather than as open handles and the -; Restart Manager does not reliably report those. Its remaining blind spot is a handle -; taken in kernel mode, where there is no process to name; then the per-file codes logged -; above are the whole story, and the probe says so by naming nothing rather than by -; failing. +; well as user programs, and it adds every payload program running from the directory +; itself - not one in a folder below it, which is another application the user installed +; there - because a running instance holds its payload as mapped images rather than as open +; handles and the Restart Manager does not reliably report those. Its remaining blind spot +; is a handle taken in kernel mode, where there is no process to name; then the per-file +; codes logged above are the whole story, and the probe says so by naming nothing rather +; than by failing. ; ; $INSTDIR travels through the same COCKATRICE_GUARD_DIR environment variable the guard ; uses, so a path with spaces or apostrophes never has to be embedded in a command line. @@ -1889,7 +1892,7 @@ Function LogProcessesUsingInstallDir ${If} $HolderExit == "1" StrCpy $HolderFound 1 ${ElseIf} $HolderExit == "0" - StrCpy $HolderOutput "nothing is holding them and no program is running from the directory" + StrCpy $HolderOutput "nothing is holding them and no payload program is running from the directory" ${Else} StrCpy $HolderOutput "the holder probe could not run: $HolderOutput" ${EndIf} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 62b735a50..1750a448e 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -178,8 +178,9 @@ cockatrice_add_test(NAME user_data_backup_test SOURCES user_data_backup_test.cpp cockatrice_add_test(NAME password_hash_test SOURCES password_hash_test.cpp LIBS libcockatrice_utility) cockatrice_add_test(NAME report_categories_test SOURCES report_categories_test.cpp LIBS libcockatrice_utility) cockatrice_add_test(NAME warning_categories_test SOURCES warning_categories_test.cpp LIBS libcockatrice_utility) -# The installer's holder probe is Windows-only, but the wording it produces is plain C++ -# and is what a stuck user has to act on, so it is tested everywhere. +# The installer's holder probe is Windows-only, but the wording it produces and the +# question which programs are the payload's own are plain C++ and are what a stuck user +# has to act on, so they are tested everywhere. cockatrice_add_test( NAME holder_line_test @@ -189,6 +190,15 @@ cockatrice_add_test( INCLUDE_DIRS ${CMAKE_SOURCE_DIR}/tools/installer-holder-probe ) +cockatrice_add_test( + NAME + payload_paths_test + SOURCES + ${CMAKE_SOURCE_DIR}/tools/installer-holder-probe/payload_paths.cpp + payload_paths_test.cpp + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/tools/installer-holder-probe +) cockatrice_add_test( NAME playmat_resolver_test diff --git a/tests/payload_paths_test.cpp b/tests/payload_paths_test.cpp new file mode 100644 index 000000000..c4576d751 --- /dev/null +++ b/tests/payload_paths_test.cpp @@ -0,0 +1,57 @@ +#include "payload_paths.h" + +#include +#include + +namespace +{ +/// The directory part of a path spelled with the same case as the install directory, which +/// is the comparison the probe makes once both sides are folded - see isSameDirectory in +/// restart_manager_holders.cpp. Kept as a plain string here so the shape of a path can be +/// tested without Windows. +bool runsFrom(const std::wstring &imagePath, const std::wstring &installDir) +{ + return parentDirectoryOf(imagePath) == installDir; +} +} // namespace + +TEST(PayloadPaths, SplitsAnImagePathIntoItsDirectoryAndName) +{ + EXPECT_EQ(fileNameOf(L"D:\\Games\\Cockatrice\\cockatrice.exe"), std::wstring(L"cockatrice.exe")); + EXPECT_EQ(parentDirectoryOf(L"D:\\Games\\Cockatrice\\cockatrice.exe"), std::wstring(L"D:\\Games\\Cockatrice")); + EXPECT_EQ(fileNameOf(L"cockatrice.exe"), std::wstring(L"cockatrice.exe")); + EXPECT_EQ(parentDirectoryOf(L"cockatrice.exe"), std::wstring()); + EXPECT_EQ(parentDirectoryOf(L"D:/Games/Cockatrice/cockatrice.exe"), std::wstring(L"D:/Games/Cockatrice")); +} + +TEST(PayloadPaths, OnlyAnImageInTheInstallDirectoryRunsFromIt) +{ + const std::wstring installDir = L"D:\\Games\\Cockatrice"; + EXPECT_TRUE(runsFrom(L"D:\\Games\\Cockatrice\\cockatrice.exe", installDir)); + // A folder the user keeps other applications in is not the installation, and neither is + // a sibling whose name merely begins with the same letters. + EXPECT_FALSE(runsFrom(L"D:\\Games\\Cockatrice\\old\\cockatrice.exe", installDir)); + EXPECT_FALSE(runsFrom(L"D:\\Games\\Cockatrice-old\\cockatrice.exe", installDir)); + EXPECT_FALSE(runsFrom(L"D:\\Games\\cockatrice.exe", installDir)); +} + +TEST(PayloadPaths, KnowsTheProgramsThePayloadInstalls) +{ + EXPECT_TRUE(isPayloadProgram(L"cockatrice.exe")); + EXPECT_TRUE(isPayloadProgram(L"oracle.exe")); + EXPECT_TRUE(isPayloadProgram(L"servatrice.exe")); + EXPECT_TRUE(isPayloadProgram(L"COCKATRICE.EXE")); + EXPECT_TRUE(isPayloadProgram(L"Servatrice.Exe")); +} + +TEST(PayloadPaths, LeavesEveryOtherProgramAlone) +{ + // A program the user keeps in the install directory, the uninstaller, a service host and + // a name that only looks like a payload program are all not instances of the payload. + EXPECT_FALSE(isPayloadProgram(L"uninstall.exe")); + EXPECT_FALSE(isPayloadProgram(L"tool.exe")); + EXPECT_FALSE(isPayloadProgram(L"svchost.exe")); + EXPECT_FALSE(isPayloadProgram(L"cockatrice2.exe")); + EXPECT_FALSE(isPayloadProgram(L"mycockatrice.exe")); + EXPECT_FALSE(isPayloadProgram(L"")); +} diff --git a/tools/installer-holder-probe/CMakeLists.txt b/tools/installer-holder-probe/CMakeLists.txt index e4def8c39..e8ea6df16 100644 --- a/tools/installer-holder-probe/CMakeLists.txt +++ b/tools/installer-holder-probe/CMakeLists.txt @@ -5,7 +5,9 @@ # installer and the uninstaller and is extracted to $PLUGINSDIR there, never installed, so # it links nothing from this project: no Qt, and a static C runtime because the installed # runtime is exactly what the purge may have failed to replace. -add_executable(installer-holder-probe close_holders.cpp holder_line.cpp main.cpp restart_manager_holders.cpp) +add_executable( + installer-holder-probe close_holders.cpp holder_line.cpp main.cpp payload_paths.cpp restart_manager_holders.cpp +) target_link_libraries(installer-holder-probe PRIVATE rstrtmgr) diff --git a/tools/installer-holder-probe/close_holders.cpp b/tools/installer-holder-probe/close_holders.cpp index 53a1fed8e..7c599bdec 100644 --- a/tools/installer-holder-probe/close_holders.cpp +++ b/tools/installer-holder-probe/close_holders.cpp @@ -1,5 +1,6 @@ #include "close_holders.h" +#include "payload_paths.h" #include "restart_manager_holders.h" // clang-format off diff --git a/tools/installer-holder-probe/main.cpp b/tools/installer-holder-probe/main.cpp index d88760b35..f9ca84ac8 100644 --- a/tools/installer-holder-probe/main.cpp +++ b/tools/installer-holder-probe/main.cpp @@ -34,7 +34,7 @@ constexpr unsigned long END_WAIT_MILLISECONDS = 5000; /// answer of "nothing holds the files" stays distinguishable from a probe that failed. /// In the close mode 1 means a close was actually sent and 0 that every holder was left /// to the user, which is what the report line next to the code spells out. 3 is only for -/// a lookup that failed and named nobody: one that failed but still named a process +/// a lookup that failed and named nobody: one that failed but still named a payload program /// running from the directory is answered with that process. 4 is only the launcher mode: /// the process the installer waits on has no window a user could answer, which is the one /// answer the installer acts on by itself instead of asking the user. @@ -61,11 +61,13 @@ enum ExitCode /// no window has answered its own close and is stuck in shutdown /// /// The guard's three modes answer from two lookups that cover each other's blind spot: the -/// Restart Manager names whoever has a payload file open, and every process running from the -/// directory is named as well, because a running instance holds its own image and the modules -/// it loaded as mappings rather than open files and the Restart Manager does not always -/// report those. Neither lookup alone is the guard's authority: an instance the file -/// handles do not name is exactly the case that left a half-replaced install behind. +/// Restart Manager names whoever has a payload file open, and every payload program running +/// from the directory itself is named as well, because a running instance holds its own image +/// and the modules it loaded as mappings rather than open files and the Restart Manager does +/// not always report those. Neither lookup alone is the guard's authority: an instance the +/// file handles do not name is exactly the case that left a half-replaced install behind. +/// A program in a folder below the directory is not named by the second lookup: it is not an +/// instance of this payload, and the guard could neither close nor end it. enum class Mode { Name, ///< name the holders @@ -201,8 +203,8 @@ int main(int argumentCount, char **arguments) return closeLauncher(installDir); } - // Processes running from the directory first: they are the instance the user is asked - // to close, and the Restart Manager may not name any of them (see + // The payload programs running from the directory first: they are the instance the user + // is asked to close, and the Restart Manager may not name any of them (see // findProcessesRunningFrom). The file holders follow, deduplicated by process, so an // instance that both runs from the directory and holds a payload file is named once. // Neither lookup may name the probe or the process that started it, whichever one found @@ -225,8 +227,8 @@ int main(int argumentCount, char **arguments) } // A failed lookup only makes the probe unable to answer when it also named nobody: with - // a process running from the directory the installer still knows what to close, and - // that is worth more to the guard than the code of the query that missed it. + // a payload program running from the directory the installer still knows what to close, + // and that is worth more to the guard than the code of the query that missed it. if (error != ERROR_SUCCESS && holders.empty()) { writeLine(L"the Restart Manager lookup failed with win32 error " + std::to_wstring(error)); return EXIT_PROBE_FAILED; diff --git a/tools/installer-holder-probe/payload_paths.cpp b/tools/installer-holder-probe/payload_paths.cpp new file mode 100644 index 000000000..8fac40040 --- /dev/null +++ b/tools/installer-holder-probe/payload_paths.cpp @@ -0,0 +1,52 @@ +#include "payload_paths.h" + +#include +#include +#include + +namespace +{ +/// The payload programs, by image name. A process running one of these is what the guard +/// exists to name, so askingProcessIds() stops its walk up the parent chain at one. +constexpr const wchar_t *PAYLOAD_PROGRAMS[] = {L"cockatrice.exe", L"oracle.exe", L"servatrice.exe"}; + +/// Compares two file names the way the file system does, without a locale. +/// +/// Only the names above are ever compared against, and all of them are ASCII, so folding +/// the ASCII letters is the whole answer. The Windows ordinal comparison is not used here +/// on purpose: this file is compiled into a test that runs everywhere, and the question +/// "is this image a program the payload installs?" must be answerable without Windows. +bool equalsIgnoringAsciiCase(const std::wstring &left, const std::wstring &right) +{ + if (left.size() != right.size()) { + return false; + } + return std::equal(left.begin(), left.end(), right.begin(), [](wchar_t a, wchar_t b) { + const wchar_t foldedA = a >= L'A' && a <= L'Z' ? static_cast(a - L'A' + L'a') : a; + const wchar_t foldedB = b >= L'A' && b <= L'Z' ? static_cast(b - L'A' + L'a') : b; + return foldedA == foldedB; + }); +} +} // namespace + +std::wstring fileNameOf(const std::wstring &path) +{ + const std::size_t separator = path.find_last_of(L"\\/"); + return separator == std::wstring::npos ? path : path.substr(separator + 1); +} + +std::wstring parentDirectoryOf(const std::wstring &path) +{ + const std::size_t separator = path.find_last_of(L"\\/"); + return separator == std::wstring::npos ? std::wstring() : path.substr(0, separator); +} + +bool isPayloadProgram(const std::wstring &imageName) +{ + for (const wchar_t *program : PAYLOAD_PROGRAMS) { + if (equalsIgnoringAsciiCase(imageName, program)) { + return true; + } + } + return false; +} diff --git a/tools/installer-holder-probe/payload_paths.h b/tools/installer-holder-probe/payload_paths.h new file mode 100644 index 000000000..247fc64ed --- /dev/null +++ b/tools/installer-holder-probe/payload_paths.h @@ -0,0 +1,39 @@ +#ifndef PAYLOAD_PATHS_H +#define PAYLOAD_PATHS_H + +#include + +/** + * @brief The last name in @p path: what follows its last separator, or @p path itself when + * it has none. + * + * Empty for an empty path and for one that ends in a separator, which has no name of its + * own - a caller that hands in a directory there gets no name rather than the directory's + * own last component. + */ +std::wstring fileNameOf(const std::wstring &path); + +/** + * @brief The directory @p path lies in, without a trailing separator. + * + * Empty when @p path has no directory part. Used to tell where a process was started from: + * an image running directly in the install directory has that directory as its parent, + * while one started from a folder below it has that folder, and the two are not the same + * answer to "does this process belong to the installation being replaced?". + */ +std::wstring parentDirectoryOf(const std::wstring &path); + +/** + * @brief Whether @p imageName is one of the programs the payload installs. + * + * The single place that answers which images are Cockatrice's own, so the guard acts on the + * same set it names, and so a program a user happens to keep in the install directory is + * never treated as an instance of the application. Matched on the image name alone, without + * case: the names are the payload's own, and the comparison is deliberately free of Windows + * API so it can be exercised off Windows. + * + * @param imageName the file name of a process image, empty when it could not be read + */ +bool isPayloadProgram(const std::wstring &imageName); + +#endif diff --git a/tools/installer-holder-probe/restart_manager_holders.cpp b/tools/installer-holder-probe/restart_manager_holders.cpp index e8c982f26..e3fb3e3c8 100644 --- a/tools/installer-holder-probe/restart_manager_holders.cpp +++ b/tools/installer-holder-probe/restart_manager_holders.cpp @@ -30,10 +30,6 @@ constexpr int MAX_ASKER_LEVELS = 8; /// The payload files the installer replaces by name; the DLLs are matched by mask below. constexpr const wchar_t *PAYLOAD_FILES[] = {L"cockatrice.exe", L"oracle.exe", L"servatrice.exe", L"qt.conf"}; -/// The payload programs, by image name. A process running one of these is what the guard -/// exists to name, so askingProcessIds() stops its walk up the parent chain at one. -constexpr const wchar_t *PAYLOAD_PROGRAMS[] = {L"cockatrice.exe", L"oracle.exe", L"servatrice.exe"}; - void collectFilesOf(const std::wstring &directory, int depth, int maxDepth, std::vector &files) { if (files.size() >= MAX_PROBED_FILES) { @@ -84,15 +80,9 @@ std::wstring imagePathOf(unsigned long processId) return imagePath; } -std::wstring fileNameOf(const std::wstring &path) -{ - const std::size_t separator = path.find_last_of(L"\\/"); - return separator == std::wstring::npos ? path : path.substr(separator + 1); -} - -/// @p directory with exactly one trailing separator, so that a sibling whose name merely -/// begins with the same letters ("Cockatrice-old" next to "Cockatrice") cannot pass for a -/// subdirectory of it. +/// @p directory with exactly one trailing separator, so that two spellings of the same +/// directory compare equal and a sibling whose name merely begins with the same letters +/// ("Cockatrice-old" next to "Cockatrice") cannot pass for it. std::wstring directoryPrefixOf(const std::wstring &directory) { std::wstring prefix = directory; @@ -103,15 +93,17 @@ std::wstring directoryPrefixOf(const std::wstring &directory) return prefix; } -/// Whether @p path lies inside @p prefix, which is a directory with a trailing separator. -/// Ordinal and case-insensitive, as Windows compares paths. -bool isUnderDirectory(const std::wstring &path, const std::wstring &prefix) +/// Whether @p left and @p right name the same directory, compared the way the file system +/// does: a difference in case is not a difference in directory, and a trailing separator +/// changes nothing. +bool isSameDirectory(const std::wstring &left, const std::wstring &right) { - if (path.size() < prefix.size()) { + if (left.empty() || right.empty()) { return false; } - const int length = static_cast(prefix.size()); - return CompareStringOrdinal(path.c_str(), length, prefix.c_str(), length, TRUE) == CSTR_EQUAL; + const std::wstring leftPrefix = directoryPrefixOf(left); + const std::wstring rightPrefix = directoryPrefixOf(right); + return CompareStringOrdinal(leftPrefix.c_str(), -1, rightPrefix.c_str(), -1, TRUE) == CSTR_EQUAL; } /// Copies a fixed-size, NUL-terminated WCHAR array the API filled in. @@ -134,16 +126,6 @@ Holder holderOf(const RM_PROCESS_INFO &info) } } // namespace -bool isPayloadProgram(const std::wstring &imageName) -{ - for (const wchar_t *program : PAYLOAD_PROGRAMS) { - if (CompareStringOrdinal(imageName.c_str(), -1, program, -1, TRUE) == CSTR_EQUAL) { - return true; - } - } - return false; -} - std::vector collectPayloadFiles(const std::wstring &installDir) { std::vector files; @@ -279,7 +261,6 @@ std::vector findProcessesRunningFrom(const std::wstring &installDir) return holders; } - const std::wstring prefix = directoryPrefixOf(installDir); // Never the probe, and never the installer or uninstaller that started it: either can // run from the directory being replaced, and naming one asks the user to close the // program that is asking (see askingProcessIds). @@ -300,12 +281,20 @@ std::vector findProcessesRunningFrom(const std::wstring &installDir) // refuses to be read is not named here and is still named by Restart // Manager when it holds a handle. The idle process has no image at all. const std::wstring imagePath = imagePathOf(entry.th32ProcessID); - if (!isUnderDirectory(imagePath, prefix)) { + const std::wstring imageName = fileNameOf(imagePath); + // Only the programs the payload installs, and only those started from the + // directory itself: a folder a user installs into can hold other applications, + // each in its own subfolder, and one of those is not an instance of this + // payload. Naming it would ask for a close the probe cannot send and an end it + // would refuse to perform, so the guard could only ever abort. A program below + // the directory that really does hold a payload file is named by + // findProcessesUsingFiles, which is the lookup for that question. + if (!isPayloadProgram(imageName) || !isSameDirectory(parentDirectoryOf(imagePath), installDir)) { continue; } Holder holder; holder.processId = entry.th32ProcessID; - holder.imageName = fileNameOf(imagePath); + holder.imageName = imageName; holders.push_back(std::move(holder)); } while (Process32NextW(snapshot, &entry) != FALSE); } diff --git a/tools/installer-holder-probe/restart_manager_holders.h b/tools/installer-holder-probe/restart_manager_holders.h index c14499941..e966b70b8 100644 --- a/tools/installer-holder-probe/restart_manager_holders.h +++ b/tools/installer-holder-probe/restart_manager_holders.h @@ -2,6 +2,7 @@ #define RESTART_MANAGER_HOLDERS_H #include "holder_line.h" +#include "payload_paths.h" #include #include @@ -21,18 +22,6 @@ */ std::vector collectPayloadFiles(const std::wstring &installDir); -/** - * @brief Whether @p imageName is one of the programs the payload installs. - * - * The single place that answers which images are Cockatrice's own, so the guard ends the - * same set it names. Matched case-insensitively on the image name alone: only holders are - * ever tested against it, and a holder is a process that has a payload file open or runs - * from the install directory. - * - * @param imageName the file name of a process image, empty when it could not be read. - */ -bool isPayloadProgram(const std::wstring &imageName); - /** * @brief Names the applications and services that have one of @p files open or mapped. * @@ -47,8 +36,7 @@ bool isPayloadProgram(const std::wstring &imageName); unsigned long findProcessesUsingFiles(const std::vector &files, std::vector &holders); /** - * @brief Lists every process whose image lives in @p installDir, whether or not it has a - * payload file open. + * @brief Lists every payload program whose image lies in @p installDir itself. * * Restart Manager answers the question "who has this file open", and a running instance * does not necessarily have one open: its own image and the modules it loaded are mapped @@ -57,16 +45,23 @@ unsigned long findProcessesUsingFiles(const std::vector &files, st * an instance running from the directory, and "nothing is holding them any more" as the * answer - so an answer built on file handles alone cannot be the guard's authority. * + * Only the programs the payload installs are named, and only when the image lies directly + * in @p installDir. A folder a user installs into can hold other applications, each in its + * own subfolder, and those are not instances of this one: they hold none of the payload, + * neither closing nor ending them would free a file the purge removes, and naming them + * leaves the guard with an answer that can never be carried out. A program below the + * directory that does hold a payload file is still named, by findProcessesUsingFiles. + * * Reading the image path needs PROCESS_QUERY_LIMITED_INFORMATION and * QueryFullProcessImageNameW, neither of which depends on the bitness of either process, - * so a 64-bit instance is named from this 32-bit stub. The comparison is on the - * directory, not on an image name, so a process that merely shares a name (an Oracle - * database server next to a Cockatrice install) is never named. + * so a 64-bit instance is named from this 32-bit stub. The comparison is on the directory, + * not on an image name alone, so a program that merely shares a name with a payload program + * is never named. * * @param installDir the directory being replaced, as a full path. - * @return one entry per process running from it, in snapshot order, without the processes - * named by askingProcessIds(); empty when the directory is empty or no process - * runs from it. + * @return one entry per payload program running from it, in snapshot order, without the + * processes named by askingProcessIds(); empty when the directory is empty or no + * payload program runs from it. */ std::vector findProcessesRunningFrom(const std::wstring &installDir); From 720adf5410faf25431df77220a85bc9d8998594b Mon Sep 17 00:00:00 2001 From: tooomm Date: Sat, 31 Oct 2026 09:28:52 +0100 Subject: [PATCH 049/108] [Settings] Rename UI language selector & move translator hint (#7656) * Update general_settings_page.cpp * lint --- .../settings_page/general_settings_page.cpp | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp index 77abc699c..7b89b744d 100644 --- a/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp @@ -100,12 +100,12 @@ GeneralSettingsPage::GeneralSettingsPage() auto *languageGrid = new QGridLayout; languageGrid->addWidget(&languageLabel, 0, 0); languageGrid->addWidget(&languageBox, 0, 1); - languageGrid->addWidget(&cardLanguageLabel, 1, 0); - languageGrid->addWidget(&cardLanguageBox, 1, 1); - languageGrid->addWidget(&cardLanguageNoteLabel, 2, 1); - languageGrid->addWidget(&cardSearchLanguageLabel, 3, 0); - languageGrid->addWidget(&cardSearchLanguageBox, 3, 1); - languageGrid->addWidget(&advertiseTranslationPageLabel, 4, 1, Qt::AlignRight); + languageGrid->addWidget(&advertiseTranslationPageLabel, 1, 1, Qt::AlignRight); + languageGrid->addWidget(&cardLanguageLabel, 2, 0); + languageGrid->addWidget(&cardLanguageBox, 2, 1); + languageGrid->addWidget(&cardLanguageNoteLabel, 3, 1); + languageGrid->addWidget(&cardSearchLanguageLabel, 4, 0); + languageGrid->addWidget(&cardSearchLanguageBox, 4, 1); cardLanguageNoteLabel.setWordWrap(true); cardLanguageNoteLabel.setAlignment(Qt::AlignLeft | Qt::AlignVCenter); @@ -568,7 +568,7 @@ void GeneralSettingsPage::retranslateUi() const auto &settings = SettingsCache::instance(); languageGroupBox->setTitle(tr("Language settings")); - languageLabel.setText(tr("Language:")); + languageLabel.setText(tr("Interface language:")); cardLanguageBox.setItemText(0, tr("English")); cardLanguageLabel.setText(tr("Card text & images language:")); cardLanguageNoteLabel.setText( From bbc7e462ff0e9fd5391a0626e6f46fc79223aad5 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:32:59 +0100 Subject: [PATCH 050/108] [Installer] Graceful close and a way out of a stuck update (#7687) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Installer] Stop calling the payload free while an executable is locked The guard answered "nothing is holding the payload" from two lookups that can both miss a running instance: Restart Manager does not reliably report a program's own image or the modules it mapped, and the image path of a process running under another account cannot be read from the probe at all. A user log showed exactly that: the first guard passed while cockatrice.exe was running from the directory, the old uninstaller silently skipped the file it could not delete, and the update aborted half an uninstall later with "cancelled by user" after a close attempt the running window ignored. Ask the file itself. A running image is opened without write sharing, so the write open replacing it needs is refused with ERROR_SHARING_VIOLATION for as long as the program runs, while a file that is merely being read still accepts it. When neither lookup named anybody and a payload executable refuses that open, the probe answers that the file is in use and that the program holding it could not be identified - the guard's "could not confirm" dialog, whose Retry works once the user has closed it - instead of passing the payload as free. Only the payload executables are tested, not every file the purge removes: a locked executable proves a program is running and a mapped DLL belongs to a running executable, while a locked DLL or qt.conf can be the asking process itself, which is the loop the guard already had. The open is retried once after 300 ms, and only for the files that refused, so a scanner holding a file for a moment cannot stop an install for nothing. The Windows probe check now covers that answer: a payload executable held by the asking process, so that it cannot be named, has to come back as in use rather than as free. Co-authored-by: Lukas Brübach * [Installer] Close running clients over a local socket The installer could only ask a running client to close with WM_CLOSE, which an instance hidden in the notification area or sitting on its own confirmation cannot answer usefully. Give every client a per-process local socket and have the holder probe ask it there first, falling back to WM_CLOSE for clients from before the socket. * [Installer] Offer to check again or update by hand when files stay locked Replace the two-answer guard prompt with force close, close by hand and check again, or cancel. Retry the automatic close while the set of holders changes, and point a cancelled run at the latest release so a user who does not want anything killed has a way forward that is not cancelling the update again. --------- Co-authored-by: Lukas Brübach --- .ci/compile.sh | 25 +- cmake/NSIS.template.in | 231 ++++++++++++------ cockatrice/CMakeLists.txt | 2 + cockatrice/src/installer_close_server.cpp | 81 ++++++ cockatrice/src/installer_close_server.h | 50 ++++ cockatrice/src/interface/window_main.cpp | 24 ++ cockatrice/src/interface/window_main.h | 11 + cockatrice/src/main.cpp | 10 + libcockatrice_utility/CMakeLists.txt | 1 + .../utility/installer_close_protocol.h | 18 ++ tests/CMakeLists.txt | 16 ++ tests/installer_close_server_test.cpp | 101 ++++++++ tools/installer-holder-probe/CMakeLists.txt | 14 +- .../installer-holder-probe/close_holders.cpp | 63 +++-- tools/installer-holder-probe/close_holders.h | 25 +- .../installer_close_name.cpp | 12 + .../installer_close_name.h | 24 ++ .../installer_close_request.cpp | 96 ++++++++ .../installer_close_request.h | 21 ++ tools/installer-holder-probe/main.cpp | 73 ++++-- .../installer-holder-probe/payload_locks.cpp | 67 +++++ tools/installer-holder-probe/payload_locks.h | 36 +++ .../installer-holder-probe/payload_paths.cpp | 4 - tools/installer-holder-probe/payload_paths.h | 12 + .../restart_manager_holders.cpp | 10 +- 25 files changed, 897 insertions(+), 130 deletions(-) create mode 100644 cockatrice/src/installer_close_server.cpp create mode 100644 cockatrice/src/installer_close_server.h create mode 100644 libcockatrice_utility/libcockatrice/utility/installer_close_protocol.h create mode 100644 tests/installer_close_server_test.cpp create mode 100644 tools/installer-holder-probe/installer_close_name.cpp create mode 100644 tools/installer-holder-probe/installer_close_name.h create mode 100644 tools/installer-holder-probe/installer_close_request.cpp create mode 100644 tools/installer-holder-probe/installer_close_request.h create mode 100644 tools/installer-holder-probe/payload_locks.cpp create mode 100644 tools/installer-holder-probe/payload_locks.h diff --git a/.ci/compile.sh b/.ci/compile.sh index cea837528..bf5a169af 100755 --- a/.ci/compile.sh +++ b/.ci/compile.sh @@ -559,6 +559,29 @@ Start-Sleep -Seconds 120 } if ($self.Code -ne 0) { throw "expected exit 0 when the asking process is the only holder, got $($self.Code) : $($self.Output)" } if ($self.Output -match "pid\s+$PID\b") { throw "the lookup named the process that started it: $($self.Output)" } + + # A payload executable that refuses to be replaced is running, whether or not this probe + # may name it: the Restart Manager does not report every mapping, and the image path of a + # process under another account cannot be read from here, so both process lookups can + # come back empty while the program is still there. Held here by the asking process, so + # that it cannot be named - the answer has to be "could not name it", never "nothing is + # holding it", which is what sent an update into an uninstall that could only fail + # halfway. Exit 3 is the code the guard answers by asking the user to close it and retry. + $image = Join-Path $dir "oracle.exe" + [IO.File]::WriteAllText($image, "x") + $handle = [IO.File]::Open($image, "Open", "ReadWrite", "None") + try { + $unnameable = Invoke-Probe $dir + } finally { + $handle.Dispose() + } + if ($unnameable.Code -ne 3) { + throw "expected exit 3 for a payload executable that is in use but cannot be named, got $($unnameable.Code) : $($unnameable.Output)" + } + if ($unnameable.Output -notmatch "oracle\.exe is in use") { + throw "the answer did not name the executable it could not replace: $($unnameable.Output)" + } + $empty = Join-Path $dir "empty" New-Item -ItemType Directory -Force $empty | Out-Null $nothing = Invoke-Probe $empty @@ -623,7 +646,7 @@ Start-Sleep -Seconds 120 if ($ignored.Code -ne 0) { throw "expected exit 0 for a program the payload does not install, got $($ignored.Code) : $($ignored.Output)" } - Write-Host "holder lookup named pid $($holder.Id) for a file another process holds, named nothing for a file only the asking process holds, named nothing for an empty directory, named pid $($running.Id) for a payload program running from the directory, and named nothing for a payload program in a folder below it or for a program the payload does not install" + Write-Host "holder lookup named pid $($holder.Id) for a file another process holds, named nothing for a file only the asking process holds, reported a payload executable it could not name as in use instead of free, named nothing for an empty directory, named pid $($running.Id) for a payload program running from the directory, and named nothing for a payload program in a folder below it or for a program the payload does not install" } finally { Remove-Item -Recurse -Force $dir -ErrorAction SilentlyContinue } diff --git a/cmake/NSIS.template.in b/cmake/NSIS.template.in index d7a6e82c6..923aded9d 100644 --- a/cmake/NSIS.template.in +++ b/cmake/NSIS.template.in @@ -57,12 +57,16 @@ Var HolderFound ; whether the payload is free, while the abort dialog reports what survived. Var GuardExit Var GuardOutput -; Whether the guard has already asked Cockatrice to close by itself: 0 before the automatic -; close, 1 after it. The prompt that follows offers to end the instance rather than asking -; a second time (see GuardAppsNotRunning). -Var GuardCloseTried -; The question CockEndPrompt assembles: fixed sentences around the probe's line, and the -; result has to stay inside the characters one NSIS string holds. +; How many times the guard has asked the payload programs to close by itself. Bounded, and +; only repeated while the listing changed between rounds (see GuardAppsNotRunning). +Var GuardCloseRounds +; The listing the last automatic close was sent against, so a second round is spent only +; when something actually exited and the rest is worth asking again. Empty before round one. +Var GuardLastHolders +; How many times the user asked to check again after closing programs by hand (logged only). +Var GuardRecheckCount +; The question CockEndPrompt/CockEndRecheckPrompt assemble: fixed sentences around the +; probe's line, and the result has to stay inside the characters one NSIS string holds. Var GuardPrompt Var WaitPid Var LauncherExited @@ -1029,11 +1033,14 @@ FunctionEnd ; Nothing is force-stopped without the user asking for it: Cockatrice may be holding ; an unsaved deck or showing a "really leave this game?" confirmation, Oracle writes ; the card database in place and can be killed mid-write, and Servatrice may be serving -; a game. A close is a request (WM_CLOSE), made automatically once because that is what -; the user started the installer to do, and only Cockatrice can answer one - the other -; two have no window. When the close is not answered the user is told which program is -; in the way and is offered to end all three themselves - the last resort, with what it -; costs spelled out in the prompt. +; a game. A close is a request, made automatically because that is what the user started +; the installer to do: the probe first asks every instance over its local socket, which +; reaches a hidden window and an instance sitting on its own confirmation, and falls back +; to WM_CLOSE for an older client. The automatic close is retried while the set of holders +; changes, so one instance exiting does not leave the next one unasked. When a close is +; still not answered the user is told which program is in the way and is offered to force +; close all three, check again after closing them by hand, or abort - the last resort, with +; what it costs spelled out in the prompt. ; One sentence for every prompt whose Yes ends the whole set, so that answer reads the same ; wherever the user meets it: this guard before anything is replaced, the uninstaller's, and @@ -1045,9 +1052,15 @@ FunctionEnd ; where the answer to "why did it stop" ends up. !define AFTER_CLOSING_GUIDANCE "Answer the confirmation Cockatrice may be showing - open it from the notification area if it is hidden there - and use Task Manager for a program that would not end." +; The escape hatch for a user who would rather not force-close anything: the same release, +; downloaded and run by hand once Cockatrice is closed. Every abort dialog shows this and +; offers to open the page, so an update that cannot free the files still leaves the user a +; way forward that does not end in Task Manager. +!define LATEST_RELEASE_URL "https://github.com/Cockatrice/Cockatrice/releases/latest" +!define MANUAL_UPDATE_GUIDANCE "To update safely instead, close Cockatrice and download the latest installer from:$\r$\n${LATEST_RELEASE_URL}$\r$\nThen run it manually." + ; usage: set the variable named by the second argument to the probe's listing, then call -; this. Sets $GuardPrompt to the question the guard, the uninstaller's guard and the -; recovery path all ask. +; this. Sets $GuardPrompt to the question the guard and the recovery path ask. ; ; One macro rather than three copies, because the wording is a promise: Yes ends the ; programs the payload installs - which is exactly the set EndHolders acts on - and anything @@ -1055,9 +1068,9 @@ FunctionEnd ; service hosted elsewhere does not come with an answer that cannot do what it says. ; ; Two answers, one of each: the automatic close already ran when this is asked, so Yes is the -; only thing left that can free the files and No stops the run. There is deliberately no -; "try again" - the same question, word for word, with nothing changed in between was what -; made an abort out of a stall. +; only thing left that can free the files and No stops the run. The guard itself uses +; CockEndRecheckPrompt instead, whose No checks again; this one stays for the recovery path, +; where a retry cannot change anything because the purge was already retried. ; ; The assembled string has to fit the characters one NSIS string holds, which is what ; bounds HOLDER_LINE_CHAR_MAX in tools\installer-holder-probe\holder_line.h: with the @@ -1069,7 +1082,29 @@ FunctionEnd ; @param cancelWord what No gives up, "installation" or "uninstallation" !macro CockEndPrompt header listing cancelWord StrCpy $GuardPrompt "${header}$\r$\n$\r$\n${listing}" - StrCpy $GuardPrompt "$GuardPrompt$\r$\n$\r$\nChoose Yes to end the Cockatrice, Oracle and Servatrice programs listed above. ${END_COST}$\r$\n$\r$\nChoose No to cancel the ${cancelWord}. ${AFTER_CLOSING_GUIDANCE}" + StrCpy $GuardPrompt "$GuardPrompt$\r$\n$\r$\nChoose Yes to force close the Cockatrice, Oracle and Servatrice programs listed above. ${END_COST}$\r$\n$\r$\nChoose No to cancel the ${cancelWord}. ${AFTER_CLOSING_GUIDANCE}" +!macroend + +; usage: like CockEndPrompt, for a MB_YESNOCANCEL question whose No checks again. +; +; The automatic close has already run, so the three answers are what is left: force close +; the programs the payload installs (Yes), close them by hand and check again (No), or stop +; the run (Cancel). No used to be the only alternative to ending them, which meant a user who +; would rather close the instance themselves had to abandon the update and start the +; installer again. The listing the question is asked with is refreshed on every pass, so the +; question changes as programs go away instead of repeating itself word for word. +; +; The manual-update guidance lives in the abort dialog, which is where a Cancel ends up, not +; in this prompt. With the longest listing HOLDER_LINE_CHAR_MAX allows, the assembled string +; stays under 900 characters, inside the 1024 one NSIS string holds, so the Cancel line is +; never the part that gets cut off. +; +; @param header the sentence that introduces the listing, with its trailing colon +; @param listing the variable holding the probe's line, $GuardOutput +; @param cancelWord what Cancel gives up, "installation" or "uninstallation" +!macro CockEndRecheckPrompt header listing cancelWord + StrCpy $GuardPrompt "${header}$\r$\n$\r$\n${listing}" + StrCpy $GuardPrompt "$GuardPrompt$\r$\n$\r$\nChoose Yes to force close the Cockatrice, Oracle and Servatrice programs listed above. ${END_COST}$\r$\n$\r$\nChoose No to close them yourself, then check again - answer the confirmation Cockatrice may be showing, and open it from the notification area if it is hidden there.$\r$\n$\r$\nChoose Cancel to abort the ${cancelWord}." !macroend ; Emitted once for the installer and once for the uninstaller: the uninstaller @@ -1087,8 +1122,9 @@ FunctionEnd ; $GuardExit to the probe's exit code and $GuardOutput to its one-line answer. ; 0 = nothing holds the payload and no payload program runs from the directory, 1 = the ; line names what does, anything else = the probe could not answer (2 = no -; directory was given, 3 = the Restart Manager call failed and no payload program -; runs from the directory either, "timeout" = it did not return in time). +; directory was given, 3 = no payload program was named but one refuses to be replaced, or +; the Restart Manager call failed and no payload program runs from the directory either, +; "timeout" = it did not return in time). Function ${prefix}RunHolderProbe Call ${prefix}ExtractHolderProbe ; The directory travels in an environment variable, never on the command @@ -1112,12 +1148,13 @@ FunctionEnd ; cockatrice.exe is asked: Oracle writes the card database in place and Servatrice has no ; window to ask, so both are left alone here and only ended by the explicit answer. ; -; Each instance's own window is asked to close with WM_CLOSE - whether it is on screen or -; only in the notification area, since the request does not need a visible window and the -; notification-area instance is the one this update has no other way to reach. A visible -; window is brought forward first. WM_CLOSE is answered, not obeyed - Cockatrice asks before -; it quits while a card database update is running, a game is open or a deck is unsaved - and -; that question is only useful if it is in front of the installer instead of behind it. +; Each instance is asked to close over the local socket it listens on, which is the same +; request as clicking its close button and reaches one whose window is hidden in the +; notification area or is sitting on its own confirmation. A client from before that socket +; is asked with WM_CLOSE on its own window instead, and a visible window is brought forward +; first. Either way the close is answered, not obeyed - Cockatrice asks before it quits while +; a card database update is running, a game is open or a deck is unsaved - and that question +; is only useful if it is in front of the installer instead of behind it. ; ; The probe waits up to 30 s for the processes it asked to exit, so the check that follows ; measures a settled directory rather than sleeping for a fixed time. Its line goes into the @@ -1188,7 +1225,9 @@ FunctionEnd ; that has nothing to report stays silent. Function GuardAppsNotRunning !insertmacro CockLog "guard: checking $GuardDir (launcherExited=$LauncherExited)" - StrCpy $GuardCloseTried 0 + StrCpy $GuardCloseRounds 0 + StrCpy $GuardRecheckCount 0 + StrCpy $GuardLastHolders "" guard_check: Call RunHolderProbe ${If} $GuardExit == "0" @@ -1198,26 +1237,40 @@ Function GuardAppsNotRunning ${EndIf} ${If} $GuardExit == "1" !insertmacro CockLog "guard: still in use by $GuardOutput" - ; Close it for the user first. The probe knows exactly which cockatrice.exe holds - ; these files, and asking it to close is the same request as clicking the window's - ; close button - the application still answers its own "really quit?" prompt, so - ; nothing is lost. One attempt per guard call keeps the common case (the client was - ; still shutting down) from turning into a question, without hiding the case where - ; the close did not help: the pass after it names what is still holding the files. - ${If} $GuardCloseTried = 0 - StrCpy $GuardCloseTried 1 - !insertmacro CockLog "guard: asking cockatrice to close, then checking again" + ; Close it for the user first. The probe asks every instance over its local socket + ; and falls back to WM_CLOSE for an older client, so this is the same request as + ; clicking the window's close button: the application still answers its own "really + ; quit?" prompt, so nothing is lost. A second round is spent only when the listing + ; changed, which means something exited and the rest is worth asking again; repeating + ; the same request against the same processes cannot change anything. + ${If} $GuardCloseRounds < 2 + ${AndIf} $GuardOutput != $GuardLastHolders + StrCpy $GuardLastHolders $GuardOutput + IntOp $GuardCloseRounds $GuardCloseRounds + 1 + !insertmacro CockLog "guard: asking the payload programs to close (round $GuardCloseRounds)" Call CloseHolders Goto guard_check ${EndIf} DetailPrint "The install directory is in use by: $GuardOutput" - ; The close ran and did not free the files, so ending them is the only thing left that - ; can - and that is the one question, asked once. Its other answer stops the run - ; instead of asking again: a second identical dialog, with nothing changed in between, - ; is what made users cancel an update the installer could have finished by itself. - !insertmacro CockEndPrompt "Cockatrice, Oracle or Servatrice is still using files of the installation:" $GuardOutput "installation" - MessageBox MB_YESNO|MB_ICONEXCLAMATION|MB_DEFBUTTON2 "$GuardPrompt" IDYES guard_end - !insertmacro CockLog "guard: cancelled by user" + ; The close ran and did not free the files. Force-closing them is what the installer + ; can do (Yes); closing them by hand is what the user can do (No checks again, with + ; the fresh listing); Cancel stops the run and the abort dialog offers the same + ; update from the official download page. Without the middle answer a user who did + ; not want anything killed had no way forward but to abandon the update. + guard_ask: + !insertmacro CockEndRecheckPrompt "Cockatrice, Oracle or Servatrice is still using files of the installation:" $GuardOutput "installation" + MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON2 "$GuardPrompt" IDYES guard_end IDNO guard_recheck + Goto guard_cancel + guard_recheck: + IntOp $GuardRecheckCount $GuardRecheckCount + 1 + !insertmacro CockLog "guard: user closed programs by hand, checking again (recheck $GuardRecheckCount)" + ; A fresh set gets a fresh automatic close, exactly as if the guard had just started: + ; what the user closed by hand may have been one instance of several. + StrCpy $GuardCloseRounds 0 + StrCpy $GuardLastHolders "" + Goto guard_check + guard_cancel: + !insertmacro CockLog "guard: cancelled by user after $GuardRecheckCount recheck(s)" StrCpy $AbortReason \ "The installation was cancelled because Cockatrice, Oracle or Servatrice is still running and holding files of the previous version.$\r$\n$\r$\n${AFTER_CLOSING_GUIDANCE}$\r$\n$\r$\nClose them and run the installer again." ; RemoveOldPayloadLeftovers runs with the pre-uninstall backup already taken; @@ -1249,25 +1302,27 @@ Function GuardAppsNotRunning Call AbortInstall ${EndIf} ; Not an answer at all: the probe was not configured, the Restart Manager - ; call failed and no payload program runs from the directory, or it timed - ; out. Carrying on from that is a decision, so it is asked for rather than - ; assumed - the stale-runtime check after the purge still stands between a - ; failed guard and a mismatched install, but it only fires after files have - ; been replaced, and on an upgrade the launcher asked for nobody is watching - ; the installer's exit code. Retry asks the probe again (a Restart Manager - ; that was busy answers the second time), Ignore installs anyway, Abort - ; cancels instead of writing a payload next to files the guard could not - ; rule out. + ; call failed and no payload program runs from the directory, a payload + ; program refuses to be replaced but no lookup could name it (it may run + ; under another account), or the probe timed out. Carrying on from that is a + ; decision, so it is asked for rather than assumed - the stale-runtime check + ; after the purge still stands between a failed guard and a mismatched + ; install, but it only fires after files have been replaced, and on an + ; upgrade the launcher asked for nobody is watching the installer's exit + ; code. Retry asks the probe again (a Restart Manager that was busy answers + ; the second time, and a program the user has closed frees its file), Ignore + ; installs anyway, Abort cancels instead of writing a payload next to files + ; the guard could not rule out. !insertmacro CockLog "guard: the holder probe could not answer ($GuardExit): $GuardOutput" - DetailPrint "Could not check whether Cockatrice is still running: $GuardOutput" + DetailPrint "Could not confirm whether Cockatrice is still running: $GuardOutput" MessageBox MB_ABORTRETRYIGNORE|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ - "This installer could not check whether Cockatrice, Oracle or Servatrice is still running.$\r$\n$\r$\nThe check reported: $GuardOutput$\r$\n$\r$\nClose them, then choose Retry to check again. Choose Ignore to install anyway, or Abort to cancel the installation." \ + "This installer could not confirm whether Cockatrice, Oracle or Servatrice is still running.$\r$\n$\r$\nThe check reported: $GuardOutput$\r$\n$\r$\nClose them, then choose Retry to check again. Choose Ignore to install anyway, or Abort to cancel the installation." \ IDRETRY guard_check IDABORT guard_unknown_abort !insertmacro CockLog "guard: continuing without an answer from the probe" Return guard_unknown_abort: StrCpy $AbortReason \ - "The installation was cancelled because it could not check whether Cockatrice, Oracle or Servatrice is still running.$\r$\n$\r$\nThe check reported: $GuardOutput$\r$\n$\r$\nPlease close them and run the installer again." + "The installation was cancelled because it could not confirm whether Cockatrice, Oracle or Servatrice is still running.$\r$\n$\r$\nThe check reported: $GuardOutput$\r$\n$\r$\nPlease close them and run the installer again." ; Same reason as the cancel path above: this abort can be reached from the ; leftover-removal guard, after the backup was taken. Call RestoreUserDataAfterUninstall @@ -1292,7 +1347,18 @@ FunctionEnd Function AbortInstall !insertmacro CockLog "ABORT: $AbortReason" !insertmacro CockLog "full installer log: $InstallLogPath" - MessageBox MB_OK|MB_ICONEXCLAMATION "$AbortReason$\r$\n$\r$\nA detailed installer log was written to:$\r$\n$InstallLogPath" + !insertmacro CockLog "manual update: ${LATEST_RELEASE_URL}" + ; The reason names what stopped the run; the manual-update paragraph is the way forward + ; that does not need this installer to free anything. Shown in every mode, exactly like + ; the reason, because a /R upgrade has nobody watching its exit code. + MessageBox MB_OK|MB_ICONEXCLAMATION "$AbortReason$\r$\n$\r$\n${MANUAL_UPDATE_GUIDANCE}$\r$\n$\r$\nA detailed installer log was written to:$\r$\n$InstallLogPath" + ; Offer the page as well: copying a URL out of a message box is a step users skip. + ; explorer.exe hands the URL to the already-running shell, so the browser opens at the + ; user's integrity level rather than as the elevated installer. + MessageBox MB_YESNO|MB_ICONQUESTION|MB_DEFBUTTON2 "Open the download page now?$\r$\n$\r$\n${LATEST_RELEASE_URL}" IDNO abort_no_link + !insertmacro CockLog "manual update: opening the download page" + Exec '"$WINDIR\explorer.exe" "${LATEST_RELEASE_URL}"' + abort_no_link: ; Offer to send the installer log to Sentry so aborted installs can be diagnosed. ; Only a package built with a baked DSN offers to send, so the prompt can only appear ; when a send can actually go out. A preprocessor guard rather than a runtime test: a @@ -1868,9 +1934,9 @@ FunctionEnd ; itself - not one in a folder below it, which is another application the user installed ; there - because a running instance holds its payload as mapped images rather than as open ; handles and the Restart Manager does not reliably report those. Its remaining blind spot -; is a handle taken in kernel mode, where there is no process to name; then the per-file -; codes logged above are the whole story, and the probe says so by naming nothing rather -; than by failing. +; is a handle taken in kernel mode, where there is no process to name; a payload executable +; among the files then still refuses to be replaced, and the probe answers with that file +; instead of naming nothing. ; ; $INSTDIR travels through the same COCKATRICE_GUARD_DIR environment variable the guard ; uses, so a path with spaces or apostrophes never has to be embedded in a command line. @@ -1894,7 +1960,7 @@ Function LogProcessesUsingInstallDir ${ElseIf} $HolderExit == "0" StrCpy $HolderOutput "nothing is holding them and no payload program is running from the directory" ${Else} - StrCpy $HolderOutput "the holder probe could not run: $HolderOutput" + StrCpy $HolderOutput "the holder probe could not name what holds them: $HolderOutput" ${EndIf} !insertmacro CockLog "holders: $HolderOutput" FunctionEnd @@ -1992,7 +2058,9 @@ Function un.EnsureAppsNotRunning FunctionEnd Function un.GuardAppsNotRunning - StrCpy $GuardCloseTried 0 + StrCpy $GuardCloseRounds 0 + StrCpy $GuardRecheckCount 0 + StrCpy $GuardLastHolders "" un_guard_check: !insertmacro CockLogUn "uninst-guard: checking $GuardDir" Call un.RunHolderProbe @@ -2002,24 +2070,36 @@ Function un.GuardAppsNotRunning ${EndIf} ${If} $GuardExit == "1" !insertmacro CockLogUn "uninst-guard: still in use by $GuardOutput" - ; Same convenience as in the installer: ask Cockatrice to close once, then report - ; what the close could not reach. - ${If} $GuardCloseTried = 0 - StrCpy $GuardCloseTried 1 - !insertmacro CockLogUn "uninst-guard: asking cockatrice to close, then checking again" + ; Same convenience as in the installer: ask every instance to close, repeat only + ; while the set changed, then report what the close could not reach. + ${If} $GuardCloseRounds < 2 + ${AndIf} $GuardOutput != $GuardLastHolders + StrCpy $GuardLastHolders $GuardOutput + IntOp $GuardCloseRounds $GuardCloseRounds + 1 + !insertmacro CockLogUn "uninst-guard: asking the payload programs to close (round $GuardCloseRounds)" Call un.CloseHolders Goto un_guard_check ${EndIf} DetailPrint "The install directory is in use by: $GuardOutput" - ; Same question as in the installer's guard, and the same two answers: Yes ends the - ; programs the payload installs, anything else stops the uninstallation. Asking again - ; after a No only repeated the dialog. CockEndPrompt words it, so this copy and the - ; installer's promise the same thing. - !insertmacro CockEndPrompt "Cockatrice, Oracle or Servatrice is still using files of the installation:" $GuardOutput "uninstallation" - MessageBox MB_YESNO|MB_ICONEXCLAMATION|MB_DEFBUTTON2 "$GuardPrompt" IDYES un_guard_end - !insertmacro CockLogUn "uninst-guard: cancelled by user" + ; Same question and the same three answers as the installer's guard: force close, + ; close by hand and check again, or abort. CockEndRecheckPrompt words it, so the two + ; copies promise the same thing. + !insertmacro CockEndRecheckPrompt "Cockatrice, Oracle or Servatrice is still using files of the installation:" $GuardOutput "uninstallation" + MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON2 "$GuardPrompt" IDYES un_guard_end IDNO un_guard_recheck + !insertmacro CockLogUn "uninst-guard: cancelled by user after $GuardRecheckCount recheck(s)" + ; The installer's Cancel lands in AbortInstall, which explains what to do next. The + ; uninstaller has no such dialog, so say the same thing here: a bare Abort would + ; close the window and leave the user with no reason and no next step. + MessageBox MB_OK|MB_ICONEXCLAMATION \ + "The uninstallation was cancelled because Cockatrice, Oracle or Servatrice is still running and holding files of the installation.$\r$\n$\r$\n${AFTER_CLOSING_GUIDANCE}$\r$\n$\r$\nClose them and run the uninstaller again." SetErrorLevel 1 Abort + un_guard_recheck: + IntOp $GuardRecheckCount $GuardRecheckCount + 1 + !insertmacro CockLogUn "uninst-guard: user closed programs by hand, checking again (recheck $GuardRecheckCount)" + StrCpy $GuardCloseRounds 0 + StrCpy $GuardLastHolders "" + Goto un_guard_check ; Only reached through Yes: Abort does not return. un_guard_end: !insertmacro CockLogUn "uninst-guard: ending the payload programs on request, then checking again" @@ -2039,11 +2119,12 @@ Function un.GuardAppsNotRunning ; Same reasoning as in the installer: without an answer from the probe we cannot tell ; whether a running instance still holds file locks, and refusing outright would leave ; the user with an uninstaller that never completes. Same three answers too, so an - ; uninstall that hits a Restart Manager that was busy can simply be retried. + ; uninstall that hits a Restart Manager that was busy, or a payload program the user has + ; not closed yet, can simply be retried. !insertmacro CockLogUn "uninst-guard: the holder probe could not answer ($GuardExit): $GuardOutput" - DetailPrint "Could not check whether Cockatrice is still running: $GuardOutput" + DetailPrint "Could not confirm whether Cockatrice is still running: $GuardOutput" MessageBox MB_ABORTRETRYIGNORE|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ - "This uninstaller could not check whether Cockatrice, Oracle or Servatrice is still running.$\r$\n$\r$\nThe check reported: $GuardOutput$\r$\n$\r$\nClose them, then choose Retry to check again. Choose Ignore to uninstall anyway, or Abort to cancel the uninstallation." \ + "This uninstaller could not confirm whether Cockatrice, Oracle or Servatrice is still running.$\r$\n$\r$\nThe check reported: $GuardOutput$\r$\n$\r$\nClose them, then choose Retry to check again. Choose Ignore to uninstall anyway, or Abort to cancel the uninstallation." \ IDRETRY un_guard_check IDABORT un_guard_unknown_abort !insertmacro CockLogUn "uninst-guard: continuing without an answer from the probe" Return diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index bec858dcb..4bd1bde56 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -345,6 +345,7 @@ set(cockatrice_SOURCES src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.cpp src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp src/interface/window_main.cpp + src/installer_close_server.cpp src/main.cpp src/single_instance_manager.cpp src/interface/widgets/tabs/abstract_tab_deck_editor.cpp @@ -450,6 +451,7 @@ set(cockatrice_SOURCES src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.h src/interface/widgets/utility/compact_push_button.cpp src/interface/widgets/utility/compact_push_button.h + src/installer_close_server.h src/single_instance_manager.h src/client/url_scheme_event_filter.h src/interface/intents/intent_connect_to_server.cpp diff --git a/cockatrice/src/installer_close_server.cpp b/cockatrice/src/installer_close_server.cpp new file mode 100644 index 000000000..26f0b2c87 --- /dev/null +++ b/cockatrice/src/installer_close_server.cpp @@ -0,0 +1,81 @@ +#include "installer_close_server.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ +/// The one line the probe writes, from the header both ends include. A QByteArray view of +/// the shared literal, so the client cannot drift from the probe's copy. +const QByteArray CLOSE_COMMAND = QByteArray::fromRawData(INSTALLER_CLOSE_COMMAND, sizeof(INSTALLER_CLOSE_COMMAND) - 1); + +/// How long a connection that never sends the command may stay open. The probe writes and +/// closes at once, so this only reaps a stray client; it must not run while a close request +/// is being handled, which is why it is a timer on the socket and not a disconnected slot. +constexpr int STRAY_CONNECTION_MILLISECONDS = 5000; +} // namespace + +InstallerCloseServer::InstallerCloseServer(QObject *parent) : QObject(parent) +{ +} + +QString InstallerCloseServer::socketNameForUser(qint64 processId, const QString &userName) +{ + return QStringLiteral("CockatriceInstallerClose-%1-%2").arg(processId).arg(userName); +} + +QString InstallerCloseServer::socketName(qint64 processId) +{ + // The same scoping helper the single-instance socket uses, so the probe only has to + // reproduce one rule (USER, then USERNAME, then the home directory name). + return scopedLocalServerName(QStringLiteral("CockatriceInstallerClose-%1").arg(processId)); +} + +bool InstallerCloseServer::listen() +{ + if (server != nullptr) { + return server->isListening(); + } + name = socketName(QCoreApplication::applicationPid()); + server = new QLocalServer(this); + // A crashed instance can leave a Unix socket file behind; on Windows this is a no-op. + QLocalServer::removeServer(name); + if (!server->listen(name)) { + delete server; + server = nullptr; + return false; + } + + connect(server, &QLocalServer::newConnection, this, [this]() { + while (QLocalSocket *socket = server->nextPendingConnection()) { + // No disconnected -> deleteLater() slot: the close request can spin a nested + // event loop, the probe disconnects while it runs, and a deferred delete would + // then free the socket from inside its own readyRead emission. The reaper covers + // a client that never sends the line; the handler below deletes after the emit. + auto *reaper = new QTimer(socket); + reaper->setSingleShot(true); + connect(reaper, &QTimer::timeout, socket, &QObject::deleteLater); + reaper->start(STRAY_CONNECTION_MILLISECONDS); + connect(socket, &QLocalSocket::readyRead, this, [this, socket, reaper]() { + if (!socket->canReadLine()) { + return; + } + if (socket->readLine() != CLOSE_COMMAND) { + return; + } + // Stop the reaper before the emit: the receiver may close the window and run + // a nested event loop for as long as the user takes, and the reaper must not + // free the socket from inside that loop while this handler still holds it. + reaper->stop(); + emit closeRequested(); + socket->disconnectFromServer(); + socket->deleteLater(); + }); + } + }); + return true; +} diff --git a/cockatrice/src/installer_close_server.h b/cockatrice/src/installer_close_server.h new file mode 100644 index 000000000..847c35523 --- /dev/null +++ b/cockatrice/src/installer_close_server.h @@ -0,0 +1,50 @@ +#ifndef COCKATRICE_INSTALLER_CLOSE_SERVER_H +#define COCKATRICE_INSTALLER_CLOSE_SERVER_H + +#include +#include + +class QLocalServer; + +/** + * @brief Listens for the installer's request to close this instance gracefully. + * + * The Windows installer cannot reach an instance whose window is hidden in the notification + * area with WM_CLOSE, and it must not kill a process that may hold an unsaved deck. It + * connects to this local socket instead and writes one line; the instance then closes the + * same way its own close button does - saving settings and answering its own confirmation, + * never bypassing it. + * + * The socket name carries the process id, so every instance has its own and an installer + * that found several can ask each one. It is built by installerCloseSocketName() in + * tools/installer-holder-probe/installer_close_name.h; the two must stay identical. + */ +class InstallerCloseServer : public QObject +{ + Q_OBJECT +public: + explicit InstallerCloseServer(QObject *parent = nullptr); + + /** + * @brief Starts listening. A failure is not fatal: the installer falls back to + * WM_CLOSE, which is what older clients answer. + */ + bool listen(); + + /// The socket name this process listens on. The installer's probe builds the same + /// string from the process id and the account name. + static QString socketName(qint64 processId); + + /// The name for an explicit account, exposed so the probe's name helper and this one + /// can be tested against each other. + static QString socketNameForUser(qint64 processId, const QString &userName); + +signals: + void closeRequested(); + +private: + QLocalServer *server = nullptr; + QString name; +}; + +#endif // COCKATRICE_INSTALLER_CLOSE_SERVER_H diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index a44176f38..f8ab5d3f6 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -986,6 +986,30 @@ bool MainWindow::closeForUpdate() return close(); } +void MainWindow::requestInstallerClose() +{ + // The same shutdown the in-app updater asks for: close the window, flush settings and let + // the tabs answer their own prompts. Never a forced quit - ending a process that has not + // agreed is the installer's separate force-close answer, not this one. + if (closeForUpdate()) { + QTimer::singleShot(0, qApp, [] { QCoreApplication::exit(0); }); + return; + } + // A confirmation is already up, or a shutdown is in progress. Bring it forward: an + // installer prompt behind a hidden confirmation is the deadlock this request exists to + // break, and the user has to be able to answer it. showWindowIfHidden() also clears + // Qt::WindowMinimized, which raise() alone does not: a minimized window can keep the + // confirmation behind the installer. + showWindowIfHidden(); + if (QWidget *modal = QApplication::activeModalWidget()) { + modal->raise(); + modal->activateWindow(); + } + raise(); + activateWindow(); + QApplication::alert(this, 0); +} + void MainWindow::changeEvent(QEvent *event) { if (event->type() == QEvent::LanguageChange) { diff --git a/cockatrice/src/interface/window_main.h b/cockatrice/src/interface/window_main.h index 98ef6a9c1..9e342e4b0 100644 --- a/cockatrice/src/interface/window_main.h +++ b/cockatrice/src/interface/window_main.h @@ -255,6 +255,17 @@ public: */ bool closeForUpdate(); + /** + * @brief Closes this instance because the installer asked it to over its local socket. + * + * The request reaches an instance hidden in the notification area or sitting on its own + * confirmation, which is exactly where a WM_CLOSE from the installer does not. The close + * is still the ordinary one: a clean client exits, one with unsaved work raises the same + * confirmation the close button does, and a confirmation already up is brought to the + * front instead of being left behind the installer. + */ + void requestInstallerClose(); + protected: void closeEvent(QCloseEvent *event) override; void changeEvent(QEvent *event) override; diff --git a/cockatrice/src/main.cpp b/cockatrice/src/main.cpp index 7b8648d69..829a8ac42 100644 --- a/cockatrice/src/main.cpp +++ b/cockatrice/src/main.cpp @@ -26,6 +26,7 @@ #include "client/sound_engine.h" #include "client/url_scheme_event_filter.h" #include "database/interface/settings_card_preference_provider.h" +#include "installer_close_server.h" #include "interface/intents/intent.h" #include "interface/intents/intent_open_local_deck.h" #include "interface/logger.h" @@ -431,6 +432,15 @@ int main(int argc, char *argv[]) MainWindow ui; + // The installer asks a running instance to close over this socket, which reaches a + // hidden window and one sitting on its own confirmation. Failure is not fatal: the + // installer falls back to WM_CLOSE for clients that have no socket. + InstallerCloseServer installerClose; + if (installerClose.listen()) { + QObject::connect(&installerClose, &InstallerCloseServer::closeRequested, &ui, + &MainWindow::requestInstallerClose); + } + // A URL launch must own the connection: the intent chain triggered by the // URL connects to the server named in the URL, so the window's own startup // auto-connect must not race against it (two connectToServer calls tear diff --git a/libcockatrice_utility/CMakeLists.txt b/libcockatrice_utility/CMakeLists.txt index 748f6b90f..55f1daf24 100644 --- a/libcockatrice_utility/CMakeLists.txt +++ b/libcockatrice_utility/CMakeLists.txt @@ -23,6 +23,7 @@ set(UTILITY_HEADERS libcockatrice/utility/cryptoutil.h libcockatrice/utility/debouncer.h libcockatrice/utility/expression.h + libcockatrice/utility/installer_close_protocol.h libcockatrice/utility/levenshtein.h libcockatrice/utility/local_server_name.h libcockatrice/utility/macros.h diff --git a/libcockatrice_utility/libcockatrice/utility/installer_close_protocol.h b/libcockatrice_utility/libcockatrice/utility/installer_close_protocol.h new file mode 100644 index 000000000..1db467b2f --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/installer_close_protocol.h @@ -0,0 +1,18 @@ +#ifndef COCKATRICE_INSTALLER_CLOSE_PROTOCOL_H +#define COCKATRICE_INSTALLER_CLOSE_PROTOCOL_H + +/** + * @brief The one line the installer's holder probe writes to a running instance. + * + * Both ends include this header: the probe writes the line + * (tools/installer-holder-probe/installer_close_request.cpp) and the client's + * InstallerCloseServer reads it. Keeping the bytes in one place means the two ends cannot + * drift apart, and the test pins the exact line so a change to it is a deliberate protocol + * change rather than a typo in one copy. + * + * Plain C++ on purpose: the probe links no Qt and no part of this project, so this header + * must stay free of both. + */ +inline constexpr char INSTALLER_CLOSE_COMMAND[] = "COCKATRICE_INSTALLER_CLOSE\n"; + +#endif // COCKATRICE_INSTALLER_CLOSE_PROTOCOL_H diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 1750a448e..19f9268ee 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -445,6 +445,22 @@ if(WITH_CLIENT) ${CMAKE_SOURCE_DIR}/cockatrice/src ${CMAKE_SOURCE_DIR}/libcockatrice_utility ) + # The installer's probe and the client build the socket name independently, and the + # command they exchange is spelled in both places. Compiling both ends into one test is + # what keeps them from drifting apart unnoticed: a mismatch would leave the installer + # unable to close an instance whose window it cannot reach. + cockatrice_add_test( + NAME + installer_close_server_test + SOURCES + ${CMAKE_SOURCE_DIR}/cockatrice/src/installer_close_server.cpp + ${CMAKE_SOURCE_DIR}/tools/installer-holder-probe/installer_close_name.cpp + installer_close_server_test.cpp + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/cockatrice/src + ${CMAKE_SOURCE_DIR}/libcockatrice_utility + ${CMAKE_SOURCE_DIR}/tools/installer-holder-probe + ) # CrashReporter is client code; DEFINITIONS keeps the test hermetic by compiling the # no-op path (no sentry-native headers needed) regardless of WITH_CRASH_REPORTING. cockatrice_add_test( diff --git a/tests/installer_close_server_test.cpp b/tests/installer_close_server_test.cpp new file mode 100644 index 000000000..158d0ce47 --- /dev/null +++ b/tests/installer_close_server_test.cpp @@ -0,0 +1,101 @@ +#include "installer_close_name.h" +#include "installer_close_server.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr int HANDSHAKE_TIMEOUT_MS = 5000; + +/// The bytes both ends exchange, from the header they both include. The separate +/// WireCommandIsThePinnedBytes test is what catches an accidental change to the literal. +const QByteArray CLOSE_COMMAND = QByteArray::fromRawData(INSTALLER_CLOSE_COMMAND, sizeof(INSTALLER_CLOSE_COMMAND) - 1); + +/// Scopes the socket to this test process, so a Cockatrice the developer happens to be +/// running cannot claim the name and the test cannot close it. Kept short on purpose: +/// QLocalServer on Unix puts the socket file under QDir::tempPath(), and macOS caps that +/// path at 104 bytes (sun_path). A longer name passes on Linux (/tmp) and fails to listen +/// on the macOS CI runners, whose temp directory is already ~48 characters. +QByteArray testUserName() +{ + return QByteArrayLiteral("t") + QByteArray::number(QCoreApplication::applicationPid()); +} +} // namespace + +// The probe builds the name from the process id and the account name it reads from the +// environment; the client builds it with scopedLocalServerName(). They have to produce the +// same string or the installer can never reach the instance it is trying to close. +TEST(InstallerCloseServerTest, SocketNameMatchesTheProbeForTheSameProcessAndUser) +{ + constexpr unsigned long PROCESS_ID = 4242; + const QString userName = QStringLiteral("installer-test"); + const QString expected = QStringLiteral("CockatriceInstallerClose-4242-installer-test"); + + EXPECT_EQ(expected, InstallerCloseServer::socketNameForUser(PROCESS_ID, userName)); + EXPECT_EQ(expected.toStdWString(), installerCloseSocketName(PROCESS_ID, userName.toStdWString())); +} + +TEST(InstallerCloseServerTest, SocketNameScopesTheProcessIdByTheCurrentUser) +{ + qputenv("USER", testUserName()); + + EXPECT_EQ(QStringLiteral("CockatriceInstallerClose-4242-") + QString::fromLatin1(testUserName()), + InstallerCloseServer::socketName(4242)); +} + +// The line is a wire format shared with the Windows-only probe, which cannot be compiled +// into this test. Pinning the exact bytes here makes a change to the shared header a +// deliberate protocol change rather than a typo that only shows up in the installer. +TEST(InstallerCloseServerTest, WireCommandIsThePinnedBytes) +{ + EXPECT_EQ(QByteArrayLiteral("COCKATRICE_INSTALLER_CLOSE\n"), CLOSE_COMMAND); + EXPECT_EQ(27, CLOSE_COMMAND.size()); +} + +// The installer only writes the command; the listening instance is what turns it into a +// close. Anything but that exact line must not be treated as a request. +TEST(InstallerCloseServerTest, CloseRequestIsEmittedForTheProbeCommand) +{ + qputenv("USER", testUserName()); + + InstallerCloseServer server; + ASSERT_TRUE(server.listen()) << "the installer close socket could not be started"; + + int closeRequests = 0; + QObject::connect(&server, &InstallerCloseServer::closeRequested, &server, [&closeRequests]() { ++closeRequests; }); + + QLocalSocket socket; + socket.connectToServer(InstallerCloseServer::socketName(QCoreApplication::applicationPid())); + ASSERT_TRUE(socket.waitForConnected(HANDSHAKE_TIMEOUT_MS)) << "could not reach the installer close socket"; + ASSERT_EQ(CLOSE_COMMAND.size(), socket.write(CLOSE_COMMAND)) << "the close request was not accepted"; + + // No waitForBytesWritten(): on Windows QLocalSocket completes the write through the + // event dispatcher, and this test runs the server in the same thread, so a blocking + // wait would hold the loop the server needs to accept and read. The loop below is the + // real handshake: it flushes the write and delivers the server's readyRead. + QEventLoop loop; + QTimer timeout; + timeout.setSingleShot(true); + QObject::connect(&timeout, &QTimer::timeout, &loop, &QEventLoop::quit); + QObject::connect(&server, &InstallerCloseServer::closeRequested, &loop, &QEventLoop::quit); + timeout.start(HANDSHAKE_TIMEOUT_MS); + loop.exec(); + + EXPECT_EQ(1, closeRequests) << "the probe's line must reach the close handler exactly once"; +} + +int main(int argc, char **argv) +{ + QCoreApplication app(argc, argv); + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tools/installer-holder-probe/CMakeLists.txt b/tools/installer-holder-probe/CMakeLists.txt index e8ea6df16..99e7d5045 100644 --- a/tools/installer-holder-probe/CMakeLists.txt +++ b/tools/installer-holder-probe/CMakeLists.txt @@ -6,11 +6,23 @@ # it links nothing from this project: no Qt, and a static C runtime because the installed # runtime is exactly what the purge may have failed to replace. add_executable( - installer-holder-probe close_holders.cpp holder_line.cpp main.cpp payload_paths.cpp restart_manager_holders.cpp + installer-holder-probe + close_holders.cpp + holder_line.cpp + installer_close_name.cpp + installer_close_request.cpp + main.cpp + payload_locks.cpp + payload_paths.cpp + restart_manager_holders.cpp ) target_link_libraries(installer-holder-probe PRIVATE rstrtmgr) +# The close command is a header-only constant shared with the client. The probe still links +# nothing from this project: the header is plain C++ and pulls in no library. +target_include_directories(installer-holder-probe PRIVATE ${CMAKE_SOURCE_DIR}/libcockatrice_utility) + # The NSIS template embeds the built file by the same relative path that resolves # NSIS.definitions.nsh, and that path points at the build root. set_target_properties( diff --git a/tools/installer-holder-probe/close_holders.cpp b/tools/installer-holder-probe/close_holders.cpp index 7c599bdec..24fe7f0e7 100644 --- a/tools/installer-holder-probe/close_holders.cpp +++ b/tools/installer-holder-probe/close_holders.cpp @@ -1,5 +1,6 @@ #include "close_holders.h" +#include "installer_close_request.h" #include "payload_paths.h" #include "restart_manager_holders.h" @@ -248,19 +249,21 @@ closeCockatriceHolders(const std::vector &holders, unsigned long waitMil search.processId = holder.processId; EnumWindows(collectProcessWindow, reinterpret_cast(&search)); CloseTarget target = chooseCloseTarget(search.windows, askHiddenWindows); - if (target.main == nullptr) { - // Nothing of the application's own to post a close to: every window left is one of - // Qt's bookkeeping windows, or the only thing on screen is a question whose owner - // window is gone. Neither is a close the application would answer - a close to the - // notification-area window terminates without the confirmation - so the instance is - // named and the guard's end answer is what can free its files. + if (target.main == nullptr && !askHiddenWindows) { + // A launcher with no window on screen has already answered its own close and is + // stuck in shutdown (see the header): asking it again cannot reach a user who is + // not there, so it is named and the installer's wait ends it. ++result.withoutWindow; lines.push_back(processLabel(holder) + L" has no window of its own to close"); continue; } std::wstring line = processLabel(holder); - if (target.hidden) { + if (target.main == nullptr) { + // No window of the application's own at all: every window left is one of Qt's + // bookkeeping windows, which must never be asked to close. The installer socket + // below is the only thing that can reach it. + } else if (target.hidden) { line += L" is hidden in the notification area"; } else { if (target.toRaise == nullptr) { @@ -269,19 +272,40 @@ closeCockatriceHolders(const std::vector &holders, unsigned long waitMil line += bringForward(target.toRaise) ? L" brought forward" : L" could not be brought forward"; } - // Posted to the instance's own window even when a question of its own is up. The close - // is what that window's close button does, and the application is the one that answers - // it: a current client refuses the close while its confirmation is pending, leaving the - // question in front, and only a client from before that guard accepts it and hides the - // window with the question. Withholding the request therefore protected nobody - it - // left the installer asking the user to close a program it had never asked - while the - // guard's end answer is what frees the files when the close cannot. - // // Kept open across the wait so a pid reused after the process exits cannot be // mistaken for the instance that was asked to close. const HANDLE process = OpenProcess(SYNCHRONIZE, FALSE, holder.processId); const DWORD openError = process == nullptr ? GetLastError() : ERROR_SUCCESS; - if (PostMessageW(target.main, WM_CLOSE, 0, 0) == FALSE) { + + // Ask over the installer socket first. It is the same request as the window's close + // button - the application still answers its own confirmation - but it reaches an + // instance whose window is hidden in the notification area or is sitting on that + // confirmation, the two states a posted close cannot get through. A client from + // before the socket answers with a failed connect, so the posted close below stays + // the fallback and older versions keep behaving exactly as they did. + if (requestInstallerClose(holder.processId)) { + // "and" only when the line already says something about the instance's window. + line += target.main == nullptr ? L" asked to close over its installer socket" + : L" and asked to close over its installer socket"; + } else if (target.main == nullptr) { + // Nothing of the application's own to post a close to, and no socket to ask: the + // instance is named and the guard's end answer is what can free its files. + if (process != nullptr) { + CloseHandle(process); + } + ++result.withoutWindow; + lines.push_back(line + L" has no window of its own to close"); + continue; + } else if (PostMessageW(target.main, WM_CLOSE, 0, 0) == FALSE) { + // Posted to the instance's own window even when a question of its own is up. The + // close is what that window's close button does, and the application is the one + // that answers it: a current client refuses the close while its confirmation is + // pending, leaving the question in front, and only a client from before that guard + // accepts it and hides the window with the question. Withholding the request + // therefore protected nobody - it left the installer asking the user to close a + // program it had never asked - while the guard's end answer is what frees the files + // when the close cannot. + // // Read the error before CloseHandle, which is free to reset it. const DWORD postError = GetLastError(); if (process != nullptr) { @@ -290,8 +314,9 @@ closeCockatriceHolders(const std::vector &holders, unsigned long waitMil lines.push_back(line + L", and the close request could not be sent (win32 " + std::to_wstring(postError) + L")"); continue; + } else { + line += L" and asked to close"; } - line += L" and asked to close"; ++result.askedToClose; if (process == nullptr) { line += L", but its exit could not be watched (win32 " + std::to_wstring(openError) + L")"; @@ -311,6 +336,10 @@ closeCockatriceHolders(const std::vector &holders, unsigned long waitMil const bool exited = allExited || WaitForSingleObject(asked[index], 0) == WAIT_OBJECT_0; if (exited) { lines[askedLines[index]] += L", exited after " + std::to_wstring(elapsed) + L" ms"; + } else if (askedWindows[index] == nullptr) { + // Asked over the socket with no window of its own: there is no window state + // to describe, only that it is still there. + lines[askedLines[index]] += L", still running after " + std::to_wstring(elapsed) + L" ms"; } else { lines[askedLines[index]] += L", still running after " + std::to_wstring(elapsed) + L" ms" + describeWindowState(askedWindows[index], askedWasHidden[index]); diff --git a/tools/installer-holder-probe/close_holders.h b/tools/installer-holder-probe/close_holders.h index 91e6b316b..3f6c889fd 100644 --- a/tools/installer-holder-probe/close_holders.h +++ b/tools/installer-holder-probe/close_holders.h @@ -17,7 +17,7 @@ */ struct CloseHoldersResult { - unsigned int askedToClose = 0; ///< holders a WM_CLOSE was posted to + unsigned int askedToClose = 0; ///< holders asked to close, over the socket or with WM_CLOSE unsigned int withoutWindow = 0; ///< holders with no window of their own to ask std::wstring report; ///< one line, narrowed to ASCII when written }; @@ -42,16 +42,19 @@ bool isCockatriceImage(const std::wstring &imageName); * asked to close. Oracle and Servatrice are left to the user - Oracle writes the card * database in place - and so is every service. * - * One thing happens per instance: its own window is asked to close with WM_CLOSE, the same - * request as clicking that window's close button - whether it is on screen or only in the - * notification area, since the request does not need the window to be visible and the - * confirmation Cockatrice answers it with is a window Qt shows itself, painted, unlike one - * restored from outside. A window that is on screen is brought forward first, because that - * confirmation would otherwise open behind the installer's prompt; nothing is shown from - * here. Qt's own bookkeeping windows are never the target - posting a close to the - * notification-area one terminates the instance without its confirmation - so a process - * without a window of its own is named and left to the guard, which offers to end it. - * Stopping the process is never attempted here. + * One thing happens per instance: it is asked to close over the local socket it listens on, + * which is the same request as clicking its close button and is the only way to reach one + * whose window is hidden in the notification area or is sitting on its own confirmation. A + * client from before that socket is asked with WM_CLOSE on its own window instead, whether + * the window is on screen or only in the notification area, since the request does not need + * the window to be visible and the confirmation Cockatrice answers it with is a window Qt + * shows itself, painted, unlike one restored from outside. A window that is on screen is + * brought forward first, because that confirmation would otherwise open behind the + * installer's prompt; nothing is shown from here. Qt's own bookkeeping windows are never the + * target - posting a close to the notification-area one terminates the instance without its + * confirmation - so a process without a window of its own that does not answer the socket is + * named and left to the guard, which offers to end it. Stopping the process is never + * attempted here. * * Once the requests are out, this waits up to @c waitMilliseconds for the processes to * exit, so the caller can measure the purge immediately afterwards instead of sleeping diff --git a/tools/installer-holder-probe/installer_close_name.cpp b/tools/installer-holder-probe/installer_close_name.cpp new file mode 100644 index 000000000..74659e151 --- /dev/null +++ b/tools/installer-holder-probe/installer_close_name.cpp @@ -0,0 +1,12 @@ +#include "installer_close_name.h" + +#include + +std::wstring installerCloseSocketName(unsigned long processId, const std::wstring &userName) +{ + std::wstring name = L"CockatriceInstallerClose-"; + name += std::to_wstring(processId); + name += L"-"; + name += userName; + return name; +} diff --git a/tools/installer-holder-probe/installer_close_name.h b/tools/installer-holder-probe/installer_close_name.h new file mode 100644 index 000000000..b659f8b8f --- /dev/null +++ b/tools/installer-holder-probe/installer_close_name.h @@ -0,0 +1,24 @@ +#ifndef INSTALLER_CLOSE_NAME_H +#define INSTALLER_CLOSE_NAME_H + +#include + +/** + * @brief The local-socket name a running instance listens on for the installer's close + * request. + * + * The name carries the process id, so every instance of the payload has its own socket - a + * second Cockatrice that was started independently, an Oracle and a Servatrice - and the + * installer can ask each one instead of guessing which is the primary. It also carries the + * account name, so one user's installer cannot reach another user's session. + * + * This is the same string the client builds with scopedLocalServerName() in + * libcockatrice_utility/local_server_name.h: `CockatriceInstallerClose--`. It is + * plain C++ so the probe and the client can be tested against each other off Windows. + * + * @param processId the id of the process that listens on the socket + * @param userName the account name scopedLocalServerName() appends + */ +std::wstring installerCloseSocketName(unsigned long processId, const std::wstring &userName); + +#endif // INSTALLER_CLOSE_NAME_H diff --git a/tools/installer-holder-probe/installer_close_request.cpp b/tools/installer-holder-probe/installer_close_request.cpp new file mode 100644 index 000000000..0475aedf3 --- /dev/null +++ b/tools/installer-holder-probe/installer_close_request.cpp @@ -0,0 +1,96 @@ +#include "installer_close_request.h" + +#include "installer_close_name.h" + +// clang-format off +// windows.h has to come before anything that pulls in a Windows type, as everywhere in +// this tool. +#include +// clang-format on + +#include +#include +#include +#include + +namespace +{ +/// How long a busy pipe may take before this gives up. The client answers in milliseconds; +/// the wait only covers a machine under load. +constexpr DWORD PIPE_WAIT_MILLISECONDS = 1000; + +std::wstring environmentVariable(const wchar_t *name) +{ + for (DWORD size = MAX_PATH; size <= 32768U; size *= 2) { + std::vector buffer(size); + const DWORD length = GetEnvironmentVariableW(name, buffer.data(), size); + if (length == 0) { + return {}; + } + if (length < size) { + return std::wstring(buffer.data(), length); + } + } + return {}; +} + +/// The account name scopedLocalServerName() appends: USER, then USERNAME, then the last +/// component of the profile path - the same order and the same fallbacks the client uses. +std::wstring currentUserName() +{ + for (const wchar_t *name : {L"USER", L"USERNAME"}) { + const std::wstring value = environmentVariable(name); + if (!value.empty()) { + return value; + } + } + const std::wstring profile = environmentVariable(L"USERPROFILE"); + const std::size_t separator = profile.find_last_of(L"\\/"); + if (separator == std::wstring::npos) { + return profile; + } + return profile.substr(separator + 1); +} + +/// The named pipe QLocalServer creates for the client's socket, or empty when the account +/// name could not be read and there is nothing to connect to. +std::wstring pipePath(unsigned long processId) +{ + const std::wstring userName = currentUserName(); + if (userName.empty()) { + return {}; + } + return L"\\\\.\\pipe\\" + installerCloseSocketName(processId, userName); +} +} // namespace + +bool requestInstallerClose(unsigned long processId) +{ + const std::wstring path = pipePath(processId); + if (path.empty()) { + return false; + } + // A client that is still serving the previous connection answers ERROR_PIPE_BUSY; one + // retry covers that, while a client that is not listening at all (an older version, or + // one that has already exited) is answered with a plain failure and the caller falls + // back to WM_CLOSE. + for (int attempt = 0; attempt < 2; ++attempt) { + const HANDLE pipe = CreateFileW(path.c_str(), GENERIC_WRITE, 0, nullptr, OPEN_EXISTING, 0, nullptr); + if (pipe == INVALID_HANDLE_VALUE) { + if (GetLastError() == ERROR_PIPE_BUSY && WaitNamedPipeW(path.c_str(), PIPE_WAIT_MILLISECONDS) != FALSE) { + continue; + } + return false; + } + DWORD written = 0; + const BOOL ok = + WriteFile(pipe, INSTALLER_CLOSE_COMMAND, sizeof(INSTALLER_CLOSE_COMMAND) - 1, &written, nullptr); + CloseHandle(pipe); + // The request is in the pipe buffer, so the client reads it even though this handle + // is closed immediately. No acknowledgment is waited for: the caller only needs to + // know whether there was a listening instance to ask, and a hung client must not + // stall the guard. + return ok != FALSE && written == sizeof(INSTALLER_CLOSE_COMMAND) - 1; + } + return false; +} diff --git a/tools/installer-holder-probe/installer_close_request.h b/tools/installer-holder-probe/installer_close_request.h new file mode 100644 index 000000000..f30c49b2c --- /dev/null +++ b/tools/installer-holder-probe/installer_close_request.h @@ -0,0 +1,21 @@ +#ifndef INSTALLER_CLOSE_REQUEST_H +#define INSTALLER_CLOSE_REQUEST_H + +/** + * @brief Asks one running payload instance to close over its installer socket. + * + * A 3.1.3 client (and later) listens on a local socket named by + * installerCloseSocketName(). Connecting to it and writing one line is the same request as + * the window's close button, but it reaches an instance whose window is hidden in the + * notification area or is sitting on its own confirmation - the two states WM_CLOSE cannot + * get through. Older clients have no socket, so the caller still falls back to WM_CLOSE. + * + * Best effort: a failed connect or write is not an error. The caller has the window close + * and the guard's force-close answer left. + * + * @param processId the process to ask, as the Restart Manager named it + * @return true when the request was written to a listening instance + */ +bool requestInstallerClose(unsigned long processId); + +#endif // INSTALLER_CLOSE_REQUEST_H diff --git a/tools/installer-holder-probe/main.cpp b/tools/installer-holder-probe/main.cpp index f9ca84ac8..31901a0ec 100644 --- a/tools/installer-holder-probe/main.cpp +++ b/tools/installer-holder-probe/main.cpp @@ -1,5 +1,7 @@ #include "close_holders.h" #include "holder_line.h" +#include "payload_locks.h" +#include "payload_paths.h" #include "restart_manager_holders.h" #include @@ -33,11 +35,12 @@ constexpr unsigned long END_WAIT_MILLISECONDS = 5000; /// Exit codes the installer reads. "Something was found" is deliberately not 0, so an /// answer of "nothing holds the files" stays distinguishable from a probe that failed. /// In the close mode 1 means a close was actually sent and 0 that every holder was left -/// to the user, which is what the report line next to the code spells out. 3 is only for -/// a lookup that failed and named nobody: one that failed but still named a payload program -/// running from the directory is answered with that process. 4 is only the launcher mode: -/// the process the installer waits on has no window a user could answer, which is the one -/// answer the installer acts on by itself instead of asking the user. +/// to the user, which is what the report line next to the code spells out. 3 is for an +/// answer that could not name what it found: a lookup that failed and named nobody, or the +/// lookups naming nobody while a payload program refuses to be replaced, which is proof one +/// runs from the directory. 4 is only the launcher mode: the process the installer waits on +/// has no window a user could answer, which is the one answer the installer acts on by +/// itself instead of asking the user. enum ExitCode { EXIT_NO_HOLDERS = 0, @@ -60,12 +63,15 @@ enum ExitCode /// whether there was a window to ask at all, because an instance with /// no window has answered its own close and is stuck in shutdown /// -/// The guard's three modes answer from two lookups that cover each other's blind spot: the -/// Restart Manager names whoever has a payload file open, and every payload program running +/// The guard's three modes answer from three questions that cover each other's blind spots: +/// the Restart Manager names whoever has a payload file open, every payload program running /// from the directory itself is named as well, because a running instance holds its own image /// and the modules it loaded as mappings rather than open files and the Restart Manager does -/// not always report those. Neither lookup alone is the guard's authority: an instance the -/// file handles do not name is exactly the case that left a half-replaced install behind. +/// not always report those, and a payload executable that refuses to be written is running +/// even when neither lookup could name it (see payloadExecutablesInUse). No one of them is +/// the guard's authority: an instance the process lookups do not name, while its executable +/// is locked, is exactly the case that left a half-replaced install behind, so it is reported +/// as a check that could not name what it found instead of as a free payload. /// A program in a folder below the directory is not named by the second lookup: it is not an /// instance of this payload, and the guard could neither close nor end it. enum class Mode @@ -109,6 +115,24 @@ void writeLine(const std::wstring &text) WriteFile(GetStdHandle(STD_OUTPUT_HANDLE), narrow.data(), static_cast(narrow.size()), &written, nullptr); } +/// The line for a payload executable that is in use while no lookup could name the program +/// holding it: the answer the guard shows, so it names the file the user has to free and +/// says that the program doing it is still to be closed by hand. +std::wstring inUseLine(const std::vector &files) +{ + std::wstring names; + for (const std::wstring &file : files) { + if (!names.empty()) { + names += L", "; + } + names += fileNameOf(file); + } + if (files.size() == 1) { + return names + L" is in use and the program holding it could not be identified"; + } + return names + L" are in use and the programs holding them could not be identified"; +} + std::wstring environmentVariable(const wchar_t *name) { for (DWORD size = MAX_PATH; size <= 32768U; size *= 2) { @@ -226,15 +250,30 @@ int main(int argumentCount, char **arguments) } } - // A failed lookup only makes the probe unable to answer when it also named nobody: with - // a payload program running from the directory the installer still knows what to close, - // and that is worth more to the guard than the code of the query that missed it. - if (error != ERROR_SUCCESS && holders.empty()) { - writeLine(L"the Restart Manager lookup failed with win32 error " + std::to_wstring(error)); - return EXIT_PROBE_FAILED; - } - + // Nobody was named. That is not yet the answer that nothing holds the payload: a lookup + // that failed only makes the probe unable to answer when it also named nobody, and a + // payload executable that refuses to be replaced is running even when neither lookup + // could name it. if (holders.empty()) { + // Neither process lookup is proof that the payload is free: the Restart Manager does + // not report every mapping, and a program running under another account cannot be + // read from here, so "nobody was named" is not the answer "nothing is running". The + // file cannot be missed - a running image refuses the write the replacement needs - + // and an answer of "nothing is holding them" over that locked file is what sent + // updates into an uninstall that could only fail halfway. Reported as a check that + // could not name what it found, so the guard asks the user to close it and retry + // instead of replacing a payload that is still running. + const std::vector inUse = payloadExecutablesInUse(installDir); + if (!inUse.empty()) { + writeLine(inUseLine(inUse)); + return EXIT_PROBE_FAILED; + } + + if (error != ERROR_SUCCESS) { + writeLine(L"the Restart Manager lookup failed with win32 error " + std::to_wstring(error)); + return EXIT_PROBE_FAILED; + } + // No output in the naming mode: the installer words the "nothing is holding them" // case, and it owns the message either way. The acting modes say it themselves, // because their line is logged verbatim next to what they were asked to do. diff --git a/tools/installer-holder-probe/payload_locks.cpp b/tools/installer-holder-probe/payload_locks.cpp new file mode 100644 index 000000000..83b77ac58 --- /dev/null +++ b/tools/installer-holder-probe/payload_locks.cpp @@ -0,0 +1,67 @@ +#include "payload_locks.h" + +#include "payload_paths.h" + +#include + +namespace +{ +/// How long the second look waits before it re-tests the files that refused the write open. +/// Only those are re-tested, so the one answer costs this at most once: a scanner, or a +/// program that is only starting, holds a file without write sharing for a moment, and that +/// must not turn a free payload into a run the user has to answer. +constexpr DWORD RECHECK_DELAY_MILLISECONDS = 300; + +/// Whether @p path refuses an open that asks to write it. +/// +/// Windows opens a program's image, and every module loaded into it, with read and delete +/// sharing but not write sharing, so this is refused for as long as the program runs and by +/// nothing else that merely reads the file. A read-only file is skipped: it refuses a write +/// open for its own reason and would answer yes for a payload nobody is running, and the +/// purge clears that attribute before it deletes anything. Any other error is no answer at +/// all - access this probe does not have says nothing about whether the file is in use, and +/// still answering "in use" would stop installs that could have finished. +bool refusesWrite(const std::wstring &path) +{ + const DWORD attributes = GetFileAttributesW(path.c_str()); + if (attributes == INVALID_FILE_ATTRIBUTES || + (attributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_READONLY)) != 0) { + return false; + } + const HANDLE file = CreateFileW(path.c_str(), GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + if (file == INVALID_HANDLE_VALUE) { + return GetLastError() == ERROR_SHARING_VIOLATION; + } + // Nothing was written: the open is the whole test, and closing the handle leaves the + // file exactly as it was. + CloseHandle(file); + return false; +} +} // namespace + +std::vector payloadExecutablesInUse(const std::wstring &installDir) +{ + std::vector refused; + for (const wchar_t *name : PAYLOAD_PROGRAMS) { + const std::wstring path = installDir + L"\\" + name; + if (refusesWrite(path)) { + refused.push_back(path); + } + } + if (refused.empty()) { + return refused; + } + + // One re-test of the files that refused, and only those: the answer is shown to a user + // who then has to close a program and run the installer again, so it has to be the + // settled state rather than a moment in which something happened to hold the file. + Sleep(RECHECK_DELAY_MILLISECONDS); + std::vector settled; + for (const std::wstring &path : refused) { + if (refusesWrite(path)) { + settled.push_back(path); + } + } + return settled; +} diff --git a/tools/installer-holder-probe/payload_locks.h b/tools/installer-holder-probe/payload_locks.h new file mode 100644 index 000000000..1d23648a2 --- /dev/null +++ b/tools/installer-holder-probe/payload_locks.h @@ -0,0 +1,36 @@ +#ifndef PAYLOAD_LOCKS_H +#define PAYLOAD_LOCKS_H + +#include +#include + +/** + * @brief The payload executables in @p installDir that refuse to be written, as full paths: + * the programs that are running from it right now. + * + * The guard asks two questions and both can come back empty for a running instance: the + * Restart Manager does not reliably report a program's own image or the modules it mapped, + * and the image path of a process running under another account cannot be read from here, + * so the process is not named either. The file itself is the evidence neither question + * depends on: a program's image is opened without write sharing for as long as it runs, so + * the write access replacing it needs is refused for exactly that long, while a file that is + * merely being read still accepts it. + * + * An installer that answers "nothing is holding the payload" from two lookups that both + * missed is what sent updates into an uninstall that could only fail halfway, leaving the + * previous version half removed. This answer is what keeps that from being said: a payload + * executable that refuses the write is running, whether or not this process may name it. + * + * Only the programs the payload installs are tested, not every file the purge removes. A + * payload executable refusing the write proves that it is running; a mapped module belongs + * to a running executable, which is tested. A DLL or qt.conf on its own can be held by the + * program that is asking - the uninstaller runs in place from the directory it empties - and + * naming that as a holder is the loop the guard already had. + * + * @return one full path per executable that still refused after the re-test, in the order + * PAYLOAD_PROGRAMS names them; empty when every one of them can be written, or none + * is installed. + */ +std::vector payloadExecutablesInUse(const std::wstring &installDir); + +#endif diff --git a/tools/installer-holder-probe/payload_paths.cpp b/tools/installer-holder-probe/payload_paths.cpp index 8fac40040..8d6274e91 100644 --- a/tools/installer-holder-probe/payload_paths.cpp +++ b/tools/installer-holder-probe/payload_paths.cpp @@ -6,10 +6,6 @@ namespace { -/// The payload programs, by image name. A process running one of these is what the guard -/// exists to name, so askingProcessIds() stops its walk up the parent chain at one. -constexpr const wchar_t *PAYLOAD_PROGRAMS[] = {L"cockatrice.exe", L"oracle.exe", L"servatrice.exe"}; - /// Compares two file names the way the file system does, without a locale. /// /// Only the names above are ever compared against, and all of them are ASCII, so folding diff --git a/tools/installer-holder-probe/payload_paths.h b/tools/installer-holder-probe/payload_paths.h index 247fc64ed..ea63732f5 100644 --- a/tools/installer-holder-probe/payload_paths.h +++ b/tools/installer-holder-probe/payload_paths.h @@ -3,6 +3,18 @@ #include +/** + * @brief The file names of the programs the payload installs, in the order the guard names + * them. + * + * The single list behind isPayloadProgram(), so the guard acts on exactly the programs it + * names, and so a payload program added later is closed, ended and tested for a lock + * without a second list to update. A payload executable that refuses to be written is + * running, which is what makes this list the evidence the two process lookups cannot + * provide - see payloadExecutablesInUse(). + */ +inline constexpr const wchar_t *PAYLOAD_PROGRAMS[] = {L"cockatrice.exe", L"oracle.exe", L"servatrice.exe"}; + /** * @brief The last name in @p path: what follows its last separator, or @p path itself when * it has none. diff --git a/tools/installer-holder-probe/restart_manager_holders.cpp b/tools/installer-holder-probe/restart_manager_holders.cpp index e3fb3e3c8..21ddd6988 100644 --- a/tools/installer-holder-probe/restart_manager_holders.cpp +++ b/tools/installer-holder-probe/restart_manager_holders.cpp @@ -27,9 +27,6 @@ constexpr int MAX_PLUGIN_DEPTH = 4; /// only keeps a snapshot with a cycle in it from walking forever. constexpr int MAX_ASKER_LEVELS = 8; -/// The payload files the installer replaces by name; the DLLs are matched by mask below. -constexpr const wchar_t *PAYLOAD_FILES[] = {L"cockatrice.exe", L"oracle.exe", L"servatrice.exe", L"qt.conf"}; - void collectFilesOf(const std::wstring &directory, int depth, int maxDepth, std::vector &files) { if (files.size() >= MAX_PROBED_FILES) { @@ -129,7 +126,12 @@ Holder holderOf(const RM_PROCESS_INFO &info) std::vector collectPayloadFiles(const std::wstring &installDir) { std::vector files; - for (const wchar_t *name : PAYLOAD_FILES) { + // The programs the payload installs, by name, plus the configuration file next to + // them: what the purge deletes by name, and therefore what an instance can have + // mapped. + std::vector replaced(std::begin(PAYLOAD_PROGRAMS), std::end(PAYLOAD_PROGRAMS)); + replaced.emplace_back(L"qt.conf"); + for (const std::wstring &name : replaced) { const std::wstring path = installDir + L"\\" + name; const DWORD attributes = GetFileAttributesW(path.c_str()); if (attributes != INVALID_FILE_ATTRIBUTES && (attributes & FILE_ATTRIBUTE_DIRECTORY) == 0) { From d30d2752961fcbf3c3d1c7123f33bad5efe7fac4 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:44:07 +0100 Subject: [PATCH 051/108] [Refactor] Move AnnotationDialog and action data types into game/ (#7411) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folds in the closed #7410 (Move AnnotationDialog from game/ logic to game_graphics/) now that layers 1-2 are closed and this stack bottoms on master directly: - Add game_graphics/dialogs/dlg_annotation.{h,cpp} and move the QInputDialog subclass there; game/ no longer declares any widget type. - Add game/player/token_info.h and game/player/move_top_cards_until_options.h. - Move CardMenuActionType to game/player/card_menu_action_type.h. - Graphics dialog headers include the new game/ headers instead of defining the structs themselves. No behavior change. Co-authored-by: Lukas Brübach --- cockatrice/CMakeLists.txt | 1 + .../player/card_menu_action_type.h | 2 +- .../player/move_top_cards_until_options.h | 19 +++++++++++++++ cockatrice/src/game/player/player_actions.cpp | 14 ----------- cockatrice/src/game/player/player_actions.h | 6 ++--- cockatrice/src/game/player/player_logic.h | 12 ---------- cockatrice/src/game/player/token_info.h | 23 +++++++++++++++++++ .../game_graphics/dialogs/dlg_annotation.cpp | 13 +++++++++++ .../game_graphics/dialogs/dlg_annotation.h | 22 ++++++++++++++++++ .../game_graphics/dialogs/dlg_create_token.h | 13 ++--------- .../dialogs/dlg_move_top_cards_until.h | 9 ++------ .../game_graphics/player/menu/card_menu.cpp | 2 +- .../game_graphics/player/menu/move_menu.cpp | 2 +- .../game_graphics/player/player_dialogs.cpp | 1 + 14 files changed, 89 insertions(+), 50 deletions(-) rename cockatrice/src/{game_graphics => game}/player/card_menu_action_type.h (95%) create mode 100644 cockatrice/src/game/player/move_top_cards_until_options.h create mode 100644 cockatrice/src/game/player/token_info.h create mode 100644 cockatrice/src/game_graphics/dialogs/dlg_annotation.cpp create mode 100644 cockatrice/src/game_graphics/dialogs/dlg_annotation.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 4bd1bde56..8a40e3dc8 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -89,6 +89,7 @@ set(cockatrice_SOURCES src/game_graphics/deckview/deck_view.cpp src/game_graphics/deckview/deck_view_container.cpp src/game_graphics/deckview/tabbed_deck_view_container.cpp + src/game_graphics/dialogs/dlg_annotation.cpp src/game_graphics/dialogs/dlg_create_token.cpp src/game_graphics/dialogs/dlg_move_top_cards_until.cpp src/game_graphics/dialogs/dlg_roll_dice.cpp diff --git a/cockatrice/src/game_graphics/player/card_menu_action_type.h b/cockatrice/src/game/player/card_menu_action_type.h similarity index 95% rename from cockatrice/src/game_graphics/player/card_menu_action_type.h rename to cockatrice/src/game/player/card_menu_action_type.h index 4cae22716..e5c4cd84e 100644 --- a/cockatrice/src/game_graphics/player/card_menu_action_type.h +++ b/cockatrice/src/game/player/card_menu_action_type.h @@ -1,6 +1,6 @@ /** * @file card_menu_action_type.h - * @ingroup GameMenusPlayers + * @ingroup GameLogicPlayers */ //! \todo Document this file. diff --git a/cockatrice/src/game/player/move_top_cards_until_options.h b/cockatrice/src/game/player/move_top_cards_until_options.h new file mode 100644 index 000000000..da8028b0f --- /dev/null +++ b/cockatrice/src/game/player/move_top_cards_until_options.h @@ -0,0 +1,19 @@ +/** + * @file move_top_cards_until_options.h + * @ingroup GameLogicPlayers + */ +//! \todo Document this file. + +#ifndef COCKATRICE_MOVE_TOP_CARDS_UNTIL_OPTIONS_H +#define COCKATRICE_MOVE_TOP_CARDS_UNTIL_OPTIONS_H + +#include + +struct MoveTopCardsUntilOptions +{ + QStringList exprs = {}; + int numberOfHits = 1; + bool autoPlay = false; +}; + +#endif // COCKATRICE_MOVE_TOP_CARDS_UNTIL_OPTIONS_H diff --git a/cockatrice/src/game/player/player_actions.cpp b/cockatrice/src/game/player/player_actions.cpp index 35682ede8..2be646572 100644 --- a/cockatrice/src/game/player/player_actions.cpp +++ b/cockatrice/src/game/player/player_actions.cpp @@ -3,8 +3,6 @@ #include "../../client/settings/cache_settings.h" #include "../../game_graphics/board/card_item.h" #include "../../game_graphics/dialogs/dlg_create_token.h" -#include "../../game_graphics/dialogs/dlg_move_top_cards_until.h" -#include "../../game_graphics/player/card_menu_action_type.h" #include "../../game_graphics/zones/table_zone.h" #include "../../interface/deck_loader/loaded_deck.h" #include "../abstract_game.h" @@ -31,8 +29,6 @@ #include #include #include -#include -#include #include #include #include @@ -1495,16 +1491,6 @@ void PlayerActions::actReduceLifeByPower(QList selectedCards) sendGameCommand(prepareGameCommand(cmd)); } -void AnnotationDialog::keyPressEvent(QKeyEvent *event) -{ - if (event->key() == Qt::Key_Return && event->modifiers() & Qt::ControlModifier) { - event->accept(); - accept(); - return; - } - QInputDialog::keyPressEvent(event); -} - void PlayerActions::actRequestSetAnnotationDialog(QList selectedCards) { QString oldAnnotation; diff --git a/cockatrice/src/game/player/player_actions.h b/cockatrice/src/game/player/player_actions.h index 8fff81635..e0825465c 100644 --- a/cockatrice/src/game/player/player_actions.h +++ b/cockatrice/src/game/player/player_actions.h @@ -9,11 +9,11 @@ #define COCKATRICE_PLAYER_ACTIONS_H #include "../../game_graphics/board/card_item.h" // IWYU pragma: keep - moc instantiates the metatype for QList, which Qt 6.4 needs complete -#include "../../game_graphics/dialogs/dlg_create_token.h" -#include "../../game_graphics/dialogs/dlg_move_top_cards_until.h" -#include "../../game_graphics/player/card_menu_action_type.h" #include "../board/card_list.h" +#include "card_menu_action_type.h" #include "libcockatrice/card/card_info.h" +#include "move_top_cards_until_options.h" +#include "token_info.h" #include #include diff --git a/cockatrice/src/game/player/player_logic.h b/cockatrice/src/game/player/player_logic.h index 59da7cc32..aa94dbb53 100644 --- a/cockatrice/src/game/player/player_logic.h +++ b/cockatrice/src/game/player/player_logic.h @@ -17,7 +17,6 @@ #include "libcockatrice/utility/playmat_params.h" #include -#include #include #include #include @@ -258,15 +257,4 @@ private: bool hasRemotePlaymat = false; }; -class AnnotationDialog : public QInputDialog -{ - Q_OBJECT - void keyPressEvent(QKeyEvent *e) override; - -public: - explicit AnnotationDialog(QWidget *parent = nullptr) : QInputDialog(parent) - { - } -}; - #endif diff --git a/cockatrice/src/game/player/token_info.h b/cockatrice/src/game/player/token_info.h new file mode 100644 index 000000000..99869f33e --- /dev/null +++ b/cockatrice/src/game/player/token_info.h @@ -0,0 +1,23 @@ +/** + * @file token_info.h + * @ingroup GameLogicPlayers + */ +//! \todo Document this file. + +#ifndef COCKATRICE_TOKEN_INFO_H +#define COCKATRICE_TOKEN_INFO_H + +#include + +struct TokenInfo +{ + QString name; + QString color; + QString pt; + QString annotation; + bool destroy = true; + bool faceDown = false; + QString providerId; +}; + +#endif // COCKATRICE_TOKEN_INFO_H diff --git a/cockatrice/src/game_graphics/dialogs/dlg_annotation.cpp b/cockatrice/src/game_graphics/dialogs/dlg_annotation.cpp new file mode 100644 index 000000000..c812f9c74 --- /dev/null +++ b/cockatrice/src/game_graphics/dialogs/dlg_annotation.cpp @@ -0,0 +1,13 @@ +#include "dlg_annotation.h" + +#include + +void AnnotationDialog::keyPressEvent(QKeyEvent *event) +{ + if (event->key() == Qt::Key_Return && event->modifiers() & Qt::ControlModifier) { + event->accept(); + accept(); + return; + } + QInputDialog::keyPressEvent(event); +} diff --git a/cockatrice/src/game_graphics/dialogs/dlg_annotation.h b/cockatrice/src/game_graphics/dialogs/dlg_annotation.h new file mode 100644 index 000000000..686377d3c --- /dev/null +++ b/cockatrice/src/game_graphics/dialogs/dlg_annotation.h @@ -0,0 +1,22 @@ +/** + * @file dlg_annotation.h + * @ingroup GameDialogs + */ + +#ifndef COCKATRICE_DLG_ANNOTATION_H +#define COCKATRICE_DLG_ANNOTATION_H + +#include + +class AnnotationDialog : public QInputDialog +{ + Q_OBJECT + void keyPressEvent(QKeyEvent *e) override; + +public: + explicit AnnotationDialog(QWidget *parent = nullptr) : QInputDialog(parent) + { + } +}; + +#endif // COCKATRICE_DLG_ANNOTATION_H diff --git a/cockatrice/src/game_graphics/dialogs/dlg_create_token.h b/cockatrice/src/game_graphics/dialogs/dlg_create_token.h index a03a1046b..c63c5d7b2 100644 --- a/cockatrice/src/game_graphics/dialogs/dlg_create_token.h +++ b/cockatrice/src/game_graphics/dialogs/dlg_create_token.h @@ -7,6 +7,8 @@ #ifndef DLG_CREATETOKEN_H #define DLG_CREATETOKEN_H +#include "../../game/player/token_info.h" + #include #include #include @@ -25,17 +27,6 @@ class CardInfoPictureWidget; class QModelIndex; class QWidget; -struct TokenInfo -{ - QString name; - QString color; - QString pt; - QString annotation; - bool destroy = true; - bool faceDown = false; - QString providerId; -}; - class DlgCreateToken : public QDialog { Q_OBJECT diff --git a/cockatrice/src/game_graphics/dialogs/dlg_move_top_cards_until.h b/cockatrice/src/game_graphics/dialogs/dlg_move_top_cards_until.h index b9080bcc1..3b0fe19ec 100644 --- a/cockatrice/src/game_graphics/dialogs/dlg_move_top_cards_until.h +++ b/cockatrice/src/game_graphics/dialogs/dlg_move_top_cards_until.h @@ -7,6 +7,8 @@ #ifndef DLG_MOVE_TOP_CARDS_UNTIL_H #define DLG_MOVE_TOP_CARDS_UNTIL_H +#include "../../game/player/move_top_cards_until_options.h" + #include #include #include @@ -21,13 +23,6 @@ class QLabel; class QSpinBox; class QWidget; -struct MoveTopCardsUntilOptions -{ - QStringList exprs = {}; - int numberOfHits = 1; - bool autoPlay = false; -}; - class DlgMoveTopCardsUntil : public QDialog { Q_OBJECT diff --git a/cockatrice/src/game_graphics/player/menu/card_menu.cpp b/cockatrice/src/game_graphics/player/menu/card_menu.cpp index 137ec2ed1..eda71a2be 100644 --- a/cockatrice/src/game_graphics/player/menu/card_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/card_menu.cpp @@ -4,6 +4,7 @@ #include "../../../client/settings/card_counter_settings.h" #include "../../../client/settings/shortcuts_settings.h" #include "../../../game/abstract_game.h" +#include "../../../game/player/card_menu_action_type.h" #include "../../../game/player/player_info.h" #include "../../../game/player/player_manager.h" #include "../../../game/zones/card_zone_logic.h" @@ -12,7 +13,6 @@ #include "../../game/player/player_logic.h" #include "../../game/zones/view_zone_logic.h" #include "../../game_scene.h" -#include "../card_menu_action_type.h" #include "../player_graphics_item.h" #include "libcockatrice/card/card_info.h" #include "libcockatrice/card/database/card_database_querier.h" diff --git a/cockatrice/src/game_graphics/player/menu/move_menu.cpp b/cockatrice/src/game_graphics/player/menu/move_menu.cpp index 2991100ec..80d748e19 100644 --- a/cockatrice/src/game_graphics/player/menu/move_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/move_menu.cpp @@ -2,10 +2,10 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" +#include "../../../game/player/card_menu_action_type.h" #include "../../game/player/player_actions.h" #include "../../game/player/player_logic.h" #include "../../game_scene.h" -#include "../card_menu_action_type.h" #include "../player_graphics_item.h" #include diff --git a/cockatrice/src/game_graphics/player/player_dialogs.cpp b/cockatrice/src/game_graphics/player/player_dialogs.cpp index 74f5de601..674b31ffa 100644 --- a/cockatrice/src/game_graphics/player/player_dialogs.cpp +++ b/cockatrice/src/game_graphics/player/player_dialogs.cpp @@ -7,6 +7,7 @@ #include "../../interface/widgets/utility/get_text_with_max.h" #include "../board/abstract_counter.h" #include "../board/card_item.h" +#include "../dialogs/dlg_annotation.h" #include "../dialogs/dlg_create_token.h" #include "../dialogs/dlg_move_top_cards_until.h" #include "../dialogs/dlg_roll_dice.h" From 52c5bd2ea3a5669e3bf964b2a3a4a4092d379d33 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:44:07 +0100 Subject: [PATCH 052/108] [Refactor] Move table row mapping from TableZone into TableZoneLogic (#7413) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Refactor] Move table row mapping from TableZone into TableZoneLogic * [Game] Use TableZoneLogic::ROW_COUNT directly per review * [Format] Match clang-format line breaking in table_zone --------- Co-authored-by: Lukas Brübach --- cockatrice/src/game/player/player_actions.cpp | 13 ++++----- .../src/game/zones/table_zone_logic.cpp | 21 +++++++++++++- cockatrice/src/game/zones/table_zone_logic.h | 15 ++++++++++ .../src/game_graphics/zones/table_zone.cpp | 28 ++++--------------- .../src/game_graphics/zones/table_zone.h | 13 +-------- 5 files changed, 47 insertions(+), 43 deletions(-) diff --git a/cockatrice/src/game/player/player_actions.cpp b/cockatrice/src/game/player/player_actions.cpp index 2be646572..85633b756 100644 --- a/cockatrice/src/game/player/player_actions.cpp +++ b/cockatrice/src/game/player/player_actions.cpp @@ -3,7 +3,6 @@ #include "../../client/settings/cache_settings.h" #include "../../game_graphics/board/card_item.h" #include "../../game_graphics/dialogs/dlg_create_token.h" -#include "../../game_graphics/zones/table_zone.h" #include "../../interface/deck_loader/loaded_deck.h" #include "../abstract_game.h" #include "../board/counter_state.h" @@ -121,7 +120,7 @@ void PlayerActions::playCard(CardItem *card, bool faceDown) cmd.set_y(0); } else { tableRow = faceDown ? 2 : info.getUiAttributes().tableRow; - QPoint gridPoint = QPoint(-1, TableZone::tableRowToGridY(tableRow)); + QPoint gridPoint = QPoint(-1, TableZoneLogic::tableRowToGridY(tableRow)); cardToMove->set_face_down(faceDown); if (!faceDown) { cardToMove->set_pt(info.getPowTough().toStdString()); @@ -161,7 +160,7 @@ void PlayerActions::playCardToTable(const CardItem *card, bool faceDown) const CardInfo &info = exactCard.getInfo(); int tableRow = faceDown ? 2 : info.getUiAttributes().tableRow; - QPoint gridPoint = QPoint(-1, TableZone::tableRowToGridY(tableRow)); + QPoint gridPoint = QPoint(-1, TableZoneLogic::tableRowToGridY(tableRow)); cardToMove->set_face_down(faceDown); if (!faceDown) { cardToMove->set_pt(info.getPowTough().toStdString()); @@ -925,7 +924,7 @@ void PlayerActions::actCreateToken(TokenInfo tokenToCreate) if (correctedCard) { lastTokenInfo.name = correctedCard.getName(); int tableRow = lastTokenInfo.faceDown ? 2 : correctedCard.getInfo().getUiAttributes().tableRow; - lastTokenTableRow = TableZone::tableRowToGridY(tableRow); + lastTokenTableRow = TableZoneLogic::tableRowToGridY(tableRow); if (lastTokenInfo.pt.isEmpty()) { lastTokenInfo.pt = correctedCard.getInfo().getPowTough(); } @@ -979,7 +978,7 @@ void PlayerActions::setLastTokenInfo(CardInfoPtr cardInfo) .destroy = true, .providerId = SettingsCache::instance().cardOverrides().getCardPreferenceOverride(cardInfo->getName())}; - lastTokenTableRow = TableZone::tableRowToGridY(cardInfo->getUiAttributes().tableRow); + lastTokenTableRow = TableZoneLogic::tableRowToGridY(cardInfo->getUiAttributes().tableRow); emit requestEnableAndSetCreateAnotherTokenAction(lastTokenInfo.name); } @@ -1191,7 +1190,7 @@ void PlayerActions::createCard(const CardItem *sourceCard, return; } - QPoint gridPoint = QPoint(-1, TableZone::tableRowToGridY(cardInfo->getUiAttributes().tableRow)); + QPoint gridPoint = QPoint(-1, TableZoneLogic::tableRowToGridY(cardInfo->getUiAttributes().tableRow)); // create the token for the related card Command_CreateToken cmd; @@ -1977,7 +1976,7 @@ void PlayerActions::cardMenuAction(QList selectedCards, CardMenuActi ctm->set_tapped(info.getUiAttributes().cipt); } - cmd->set_y(TableZone::tableRowToGridY(tableRow)); + cmd->set_y(TableZoneLogic::tableRowToGridY(tableRow)); commandList.append(cmd); } break; diff --git a/cockatrice/src/game/zones/table_zone_logic.cpp b/cockatrice/src/game/zones/table_zone_logic.cpp index 07e4a759b..9343e34b0 100644 --- a/cockatrice/src/game/zones/table_zone_logic.cpp +++ b/cockatrice/src/game/zones/table_zone_logic.cpp @@ -40,4 +40,23 @@ CardItem *TableZoneLogic::takeCard(int position, int cardId, bool toNewZone) emit contentSizeChanged(); } return result; -} \ No newline at end of file +} + +int TableZoneLogic::clampValidTableRow(const int row) +{ + if (row < 0) { + return 0; + } + if (row >= ROW_COUNT) { + return ROW_COUNT - 1; + } + return row; +} + +int TableZoneLogic::tableRowToGridY(int tableRow) +{ + if (tableRow > 2) { + tableRow = 1; + } + return clampValidTableRow(2 - tableRow); +} diff --git a/cockatrice/src/game/zones/table_zone_logic.h b/cockatrice/src/game/zones/table_zone_logic.h index 8078bfd05..353a497e9 100644 --- a/cockatrice/src/game/zones/table_zone_logic.h +++ b/cockatrice/src/game/zones/table_zone_logic.h @@ -29,6 +29,21 @@ public: bool _contentsKnown, QObject *parent = nullptr); + /** @brief Number of rows in the table zone grid (0=creatures, 1=noncreatures, 2=lands). */ + static const int ROW_COUNT = 3; + + /** + * Clamps a grid row index into [0, ROW_COUNT - 1]. + */ + static int clampValidTableRow(const int row); + + /** + * Converts a card's logical table row (0=creatures, 1=noncreatures, 2=lands) + * to the corresponding grid Y coordinate. Cards with tableRow > 2 (e.g., + * instants/sorceries) default to the noncreatures row. + */ + static int tableRowToGridY(int tableRow); + protected: void addCardImpl(CardItem *card, int x, int y) override; diff --git a/cockatrice/src/game_graphics/zones/table_zone.cpp b/cockatrice/src/game_graphics/zones/table_zone.cpp index b704b79e0..f98d7e5cb 100644 --- a/cockatrice/src/game_graphics/zones/table_zone.cpp +++ b/cockatrice/src/game_graphics/zones/table_zone.cpp @@ -62,7 +62,8 @@ TableZone::TableZone(TableZoneLogic *_logic, bool _mirrored, QGraphicsItem *pare updateBg(); - height = MARGIN_TOP + MARGIN_BOTTOM + TABLEROWS * CardDimensions::HEIGHT + (TABLEROWS - 1) * PADDING_Y; + height = MARGIN_TOP + MARGIN_BOTTOM + TableZoneLogic::ROW_COUNT * CardDimensions::HEIGHT + + (TableZoneLogic::ROW_COUNT - 1) * PADDING_Y; width = MIN_WIDTH; currentMinimumWidth = width; @@ -397,7 +398,7 @@ QPointF TableZone::mapFromGrid(QPoint gridPoint) const } if (isInverted()) { - gridPoint.setY(TABLEROWS - 1 - gridPoint.y()); + gridPoint.setY(TableZoneLogic::ROW_COUNT - 1 - gridPoint.y()); } // Start with margin plus stacked card offset @@ -423,10 +424,10 @@ QPoint TableZone::mapToGrid(const QPointF &mapPoint) const const int gridPointHeight = CardDimensions::HEIGHT + PADDING_Y; int gridPointY = (y + PADDING_Y / 2) / gridPointHeight; - gridPointY = clampValidTableRow(gridPointY); + gridPointY = TableZoneLogic::clampValidTableRow(gridPointY); if (isInverted()) { - gridPointY = TABLEROWS - 1 - gridPointY; + gridPointY = TableZoneLogic::ROW_COUNT - 1 - gridPointY; } // Calculating the x-coordinate of the grid space requires adding up the @@ -471,22 +472,3 @@ QPointF TableZone::closestGridPoint(const QPointF &point) } return mapFromGrid(gridPoint); } - -int TableZone::clampValidTableRow(const int row) -{ - if (row < 0) { - return 0; - } - if (row >= TABLEROWS) { - return TABLEROWS - 1; - } - return row; -} - -int TableZone::tableRowToGridY(int tableRow) -{ - if (tableRow > 2) { - tableRow = 1; - } - return clampValidTableRow(2 - tableRow); -} diff --git a/cockatrice/src/game_graphics/zones/table_zone.h b/cockatrice/src/game_graphics/zones/table_zone.h index ccd1d8e81..f3fc0dc5a 100644 --- a/cockatrice/src/game_graphics/zones/table_zone.h +++ b/cockatrice/src/game_graphics/zones/table_zone.h @@ -7,6 +7,7 @@ #ifndef TABLEZONE_H #define TABLEZONE_H +#include "../../game/zones/table_zone_logic.h" #include "../animated_item.h" #include "../card_dimensions.h" #include "select_zone.h" @@ -25,7 +26,6 @@ class CardZoneLogic; class QColor; class QGraphicsItem; class QPainter; -class TableZoneLogic; template class QList; /** @@ -42,8 +42,6 @@ signals: void sizeChanged(); private: - static const int TABLEROWS = 3; - /* Margins between table edges and cards, paddings between cards */ @@ -181,15 +179,6 @@ public: QPointF closestGridPoint(const QPointF &point) override; - static int clampValidTableRow(const int row); - - /** - * Converts a card's logical table row (0=creatures, 1=noncreatures, 2=lands) - * to the corresponding grid Y coordinate. Cards with tableRow > 2 (e.g., - * instants/sorceries) default to the noncreatures row. - */ - static int tableRowToGridY(int tableRow); - /** Resizes the TableZone in case CardItems are within or outside of the TableZone constraints. From 93c5d85996643db48580d75970628c0151bff7f1 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:44:08 +0100 Subject: [PATCH 053/108] [Refactor] Move attach and arrow previews from game logic into CardMenu (#7414) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- cockatrice/src/game/player/player_actions.cpp | 18 -------------- cockatrice/src/game/player/player_actions.h | 2 -- .../src/game/player/player_event_handler.h | 2 +- cockatrice/src/game/player/player_logic.h | 2 +- .../game_graphics/player/menu/card_menu.cpp | 24 +++++++++++++++---- .../src/game_graphics/player/menu/card_menu.h | 6 +++-- .../game_graphics/player/menu/player_menu.cpp | 2 +- .../game_graphics/player/menu/player_menu.h | 2 +- 8 files changed, 28 insertions(+), 30 deletions(-) diff --git a/cockatrice/src/game/player/player_actions.cpp b/cockatrice/src/game/player/player_actions.cpp index 85633b756..060f5ca6e 100644 --- a/cockatrice/src/game/player/player_actions.cpp +++ b/cockatrice/src/game/player/player_actions.cpp @@ -1417,14 +1417,6 @@ void PlayerActions::actSetPT(QList selectedCards, const QString &pt) player->getGame()->getGameEventHandler()->sendGameCommand(prepareGameCommand(commandList), playerid); } -void PlayerActions::actDrawArrow() -{ - auto *card = player->getGame()->getActiveCard(); - if (card) { - card->drawArrow(Qt::red); - } -} - void PlayerActions::actIncP(QList selectedCards) { actIncPT(selectedCards, 1, 0); @@ -1516,16 +1508,6 @@ void PlayerActions::actSetAnnotation(QList selectedCards, const QStr sendGameCommand(prepareGameCommand(commandList)); } -void PlayerActions::actAttach() -{ - auto *card = player->getGame()->getActiveCard(); - if (!card) { - return; - } - - card->drawAttachArrow(); -} - void PlayerActions::actUnattach(QList selectedCards) { QList commandList; diff --git a/cockatrice/src/game/player/player_actions.h b/cockatrice/src/game/player/player_actions.h index e0825465c..e0b1c461f 100644 --- a/cockatrice/src/game/player/player_actions.h +++ b/cockatrice/src/game/player/player_actions.h @@ -197,9 +197,7 @@ public slots: void actRequestSetCardCounterDialog(QList selectedCards, int counterId); void actSetCardCounter(QList selectedCards, int counterId, const QString &counterValue); void actIncrementAllCardCounters(QList cardsToUpdate); - void actAttach(); void actUnattach(QList selectedCards); - void actDrawArrow(); void actIncPT(QList selectedCards, int deltaP, int deltaT); void actResetPT(QList selectedCards); void actRequestSetPTDialog(QList selectedCards); diff --git a/cockatrice/src/game/player/player_event_handler.h b/cockatrice/src/game/player/player_event_handler.h index 88254119b..44b43c3f1 100644 --- a/cockatrice/src/game/player/player_event_handler.h +++ b/cockatrice/src/game/player/player_event_handler.h @@ -267,7 +267,7 @@ signals: /** @} */ void cardZoneChanged(CardItem *card, bool sameZone); - void requestCardMenuUpdate(const CardItem *card); + void requestCardMenuUpdate(CardItem *card); private: /** Owning player instance. */ diff --git a/cockatrice/src/game/player/player_logic.h b/cockatrice/src/game/player/player_logic.h index aa94dbb53..e6a48af99 100644 --- a/cockatrice/src/game/player/player_logic.h +++ b/cockatrice/src/game/player/player_logic.h @@ -66,7 +66,7 @@ signals: /** @brief Emitted when the remote playmat (card/params) is updated from player properties. */ void playmatChanged(); void newCardAdded(AbstractCardItem *card); - void requestCardMenuUpdate(const CardItem *card); + void requestCardMenuUpdate(CardItem *card); void counterAdded(CounterState *state); void counterRemoved(int counterId); void rearrangeCounters(); diff --git a/cockatrice/src/game_graphics/player/menu/card_menu.cpp b/cockatrice/src/game_graphics/player/menu/card_menu.cpp index eda71a2be..769bdd53a 100644 --- a/cockatrice/src/game_graphics/player/menu/card_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/card_menu.cpp @@ -67,7 +67,7 @@ static QAction *makeAction(QObject *parent, Slot &&slot, bool checkable = false, return a; } -CardMenu::CardMenu(PlayerGraphicsItem *_player, const CardItem *_card, bool _shortcutsActive) +CardMenu::CardMenu(PlayerGraphicsItem *_player, CardItem *_card, bool _shortcutsActive) : player(_player), card(_card), shortcutsActive(_shortcutsActive) { const QList &players = player->getLogic()->getGame()->getPlayerManager()->getPlayers().values(); @@ -108,15 +108,17 @@ CardMenu::CardMenu(PlayerGraphicsItem *_player, const CardItem *_card, bool _sho aHide = makeAction(this, [actions, sel]() { actions->actHide(sel()); }); aReduceLifeByPower = makeAction(this, [actions, sel]() { actions->actReduceLifeByPower(sel()); }); - // Actions that use activeCard, not selection — direct connection + // Actions bound to the card this menu was opened for aAttach = new QAction(this); aDrawArrow = new QAction(this); + + // Actions that use activeCard, not selection — direct connection aSelectAll = new QAction(this); aSelectRow = new QAction(this); aSelectColumn = new QAction(this); - connect(aAttach, &QAction::triggered, actions, &PlayerActions::actAttach); - connect(aDrawArrow, &QAction::triggered, actions, &PlayerActions::actDrawArrow); + connect(aAttach, &QAction::triggered, this, &CardMenu::attachCard); + connect(aDrawArrow, &QAction::triggered, this, &CardMenu::drawArrow); connect(aSelectAll, &QAction::triggered, actions, &PlayerActions::actSelectAll); connect(aSelectRow, &QAction::triggered, actions, &PlayerActions::actSelectRow); connect(aSelectColumn, &QAction::triggered, actions, &PlayerActions::actSelectColumn); @@ -501,6 +503,20 @@ void CardMenu::addRelatedCardActions() } } +void CardMenu::attachCard() +{ + if (card) { + card->drawAttachArrow(); + } +} + +void CardMenu::drawArrow() +{ + if (card) { + card->drawArrow(Qt::red); + } +} + void CardMenu::retranslateUi() { aSelectAll->setText(tr("&Select All")); diff --git a/cockatrice/src/game_graphics/player/menu/card_menu.h b/cockatrice/src/game_graphics/player/menu/card_menu.h index 337d77b3e..2c80483b1 100644 --- a/cockatrice/src/game_graphics/player/menu/card_menu.h +++ b/cockatrice/src/game_graphics/player/menu/card_menu.h @@ -27,7 +27,7 @@ signals: void cardInfoRequested(const CardRef &cardRef); public: - explicit CardMenu(PlayerGraphicsItem *player, const CardItem *card, bool shortcutsActive); + explicit CardMenu(PlayerGraphicsItem *player, CardItem *card, bool shortcutsActive); void removePlayer(PlayerLogic *playerToRemove); void createTableMenu(bool canModifyCard); void createStackMenu(bool canModifyCard); @@ -53,11 +53,13 @@ public: private: PlayerGraphicsItem *player; - const CardItem *card; + CardItem *card; QList> playersInfo; bool shortcutsActive; void addRelatedCardActions(); + void attachCard(); + void drawArrow(); void retranslateUi(); void initContextualPlayersMenu(QMenu *menu, QAction *allPlayersAction); void setShortcutsActive(); diff --git a/cockatrice/src/game_graphics/player/menu/player_menu.cpp b/cockatrice/src/game_graphics/player/menu/player_menu.cpp index 44b184cfe..6a23890ee 100644 --- a/cockatrice/src/game_graphics/player/menu/player_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/player_menu.cpp @@ -87,7 +87,7 @@ void PlayerMenu::setMenusForGraphicItems() } } -QMenu *PlayerMenu::updateCardMenu(const CardItem *card) +QMenu *PlayerMenu::updateCardMenu(CardItem *card) { if (!card) { emit cardMenuUpdated(nullptr); diff --git a/cockatrice/src/game_graphics/player/menu/player_menu.h b/cockatrice/src/game_graphics/player/menu/player_menu.h index 97dd2fc56..27a5efe7e 100644 --- a/cockatrice/src/game_graphics/player/menu/player_menu.h +++ b/cockatrice/src/game_graphics/player/menu/player_menu.h @@ -43,7 +43,7 @@ signals: public slots: void setMenusForGraphicItems(); - QMenu *updateCardMenu(const CardItem *card); + QMenu *updateCardMenu(CardItem *card); private slots: void refreshShortcuts(); From 94b91be5a29f0a7b5e650ce76d74dacf60217eab Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:44:08 +0100 Subject: [PATCH 054/108] [Refactor] Pass the target card into PlayerActions instead of reading activeCard (#7415) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- cockatrice/src/game/player/player_actions.cpp | 20 ++++++------------ cockatrice/src/game/player/player_actions.h | 10 ++++----- .../game_graphics/player/menu/card_menu.cpp | 21 +++++++++---------- 3 files changed, 21 insertions(+), 30 deletions(-) diff --git a/cockatrice/src/game/player/player_actions.cpp b/cockatrice/src/game/player/player_actions.cpp index 060f5ca6e..d8c02396e 100644 --- a/cockatrice/src/game/player/player_actions.cpp +++ b/cockatrice/src/game/player/player_actions.cpp @@ -757,9 +757,8 @@ static void selectCardsInZone( } } -void PlayerActions::actSelectAll() +void PlayerActions::actSelectAll(const CardItem *card) { - const CardItem *card = player->getGame()->getActiveCard(); if (!card) { return; } @@ -767,9 +766,8 @@ void PlayerActions::actSelectAll() selectCardsInZone(card->getZone()); } -void PlayerActions::actSelectRow() +void PlayerActions::actSelectRow(const CardItem *card) { - const CardItem *card = player->getGame()->getActiveCard(); if (!card) { return; } @@ -780,9 +778,8 @@ void PlayerActions::actSelectRow() selectCardsInZone(card->getZone(), isSameRow); } -void PlayerActions::actSelectColumn() +void PlayerActions::actSelectColumn(const CardItem *card) { - const CardItem *card = player->getGame()->getActiveCard(); if (!card) { return; } @@ -996,25 +993,20 @@ void PlayerActions::actCreatePredefinedToken() actCreateAnotherToken(); } -void PlayerActions::actCreateRelatedCard() +void PlayerActions::actCreateRelatedCard(const CardItem *sourceCard, int index) { - const CardItem *sourceCard = player->getGame()->getActiveCard(); - if (!sourceCard) { return; } - auto *action = static_cast(sender()); - // If there is a better way of passing a CardRelation through a QAction, please add it here. auto relatedCards = sourceCard->getCardInfo().getAllRelatedCards(); - CardRelation *cardRelation = relatedCards.at(action->data().toInt()); + CardRelation *cardRelation = relatedCards.at(index); actRequestCreateRelatedFromRelationDialog(sourceCard, cardRelation); } -void PlayerActions::actCreateAllRelatedCards() +void PlayerActions::actCreateAllRelatedCards(const CardItem *sourceCard) { - const CardItem *sourceCard = player->getGame()->getActiveCard(); if (!sourceCard) { return; } diff --git a/cockatrice/src/game/player/player_actions.h b/cockatrice/src/game/player/player_actions.h index e0b1c461f..427fd8f66 100644 --- a/cockatrice/src/game/player/player_actions.h +++ b/cockatrice/src/game/player/player_actions.h @@ -164,9 +164,9 @@ public slots: void actRequestMoveBottomCardsToDialog(const QString &targetZone, const QString &zoneDisplayName, bool faceDown); void moveBottomCardsTo(int number, const QString &targetZone, bool faceDown); - void actSelectAll(); - void actSelectRow(); - void actSelectColumn(); + void actSelectAll(const CardItem *card); + void actSelectRow(const CardItem *card); + void actSelectColumn(const CardItem *card); void actViewLibrary(); void actViewHand(); @@ -187,8 +187,8 @@ public slots: void actOpenDeckInDeckEditor(); void actCreatePredefinedToken(); - void actCreateRelatedCard(); - void actCreateAllRelatedCards(); + void actCreateRelatedCard(const CardItem *sourceCard, int index); + void actCreateAllRelatedCards(const CardItem *sourceCard); void actRequestMoveCardXCardsFromTopDialog(); void actMoveCardXCardsFromTop(QList selectedCards, int number); diff --git a/cockatrice/src/game_graphics/player/menu/card_menu.cpp b/cockatrice/src/game_graphics/player/menu/card_menu.cpp index 769bdd53a..d384f5521 100644 --- a/cockatrice/src/game_graphics/player/menu/card_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/card_menu.cpp @@ -108,20 +108,18 @@ CardMenu::CardMenu(PlayerGraphicsItem *_player, CardItem *_card, bool _shortcuts aHide = makeAction(this, [actions, sel]() { actions->actHide(sel()); }); aReduceLifeByPower = makeAction(this, [actions, sel]() { actions->actReduceLifeByPower(sel()); }); - // Actions bound to the card this menu was opened for + // Actions bound to the card this menu was opened for, not to the current selection aAttach = new QAction(this); aDrawArrow = new QAction(this); - - // Actions that use activeCard, not selection — direct connection aSelectAll = new QAction(this); aSelectRow = new QAction(this); aSelectColumn = new QAction(this); connect(aAttach, &QAction::triggered, this, &CardMenu::attachCard); connect(aDrawArrow, &QAction::triggered, this, &CardMenu::drawArrow); - connect(aSelectAll, &QAction::triggered, actions, &PlayerActions::actSelectAll); - connect(aSelectRow, &QAction::triggered, actions, &PlayerActions::actSelectRow); - connect(aSelectColumn, &QAction::triggered, actions, &PlayerActions::actSelectColumn); + connect(aSelectAll, &QAction::triggered, this, [this, actions]() { actions->actSelectAll(card); }); + connect(aSelectRow, &QAction::triggered, this, [this, actions]() { actions->actSelectRow(card); }); + connect(aSelectColumn, &QAction::triggered, this, [this, actions]() { actions->actSelectColumn(card); }); aRevealToAll = new QAction(this); @@ -443,6 +441,7 @@ void CardMenu::addRelatedCardActions() if (relatedCards.isEmpty()) { return; } + auto *playerActions = player->getLogic()->getPlayerActions(); addSeparator(); int index = 0; @@ -486,9 +485,9 @@ void CardMenu::addRelatedCardActions() } auto *createRelated = new QAction(text, this); - createRelated->setData(QVariant(index++)); - connect(createRelated, &QAction::triggered, player->getLogic()->getPlayerActions(), - &PlayerActions::actCreateRelatedCard); + const int relatedIndex = index++; + connect(createRelated, &QAction::triggered, this, + [this, playerActions, relatedIndex] { playerActions->actCreateRelatedCard(card, relatedIndex); }); addAction(createRelated); } @@ -497,8 +496,8 @@ void CardMenu::addRelatedCardActions() createRelatedCards->setShortcuts( SettingsCache::instance().shortcuts().getShortcut("Player/aCreateRelatedTokens")); } - connect(createRelatedCards, &QAction::triggered, player->getLogic()->getPlayerActions(), - &PlayerActions::actCreateAllRelatedCards); + connect(createRelatedCards, &QAction::triggered, this, + [this, playerActions] { playerActions->actCreateAllRelatedCards(card); }); addAction(createRelatedCards); } } From 4972ebcb979445556997f3dbe8ab4a23660d9710 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:44:08 +0100 Subject: [PATCH 055/108] [Refactor] Move the active card from AbstractGame into GameScene (#7416) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- cockatrice/src/game/abstract_game.cpp | 7 ------- cockatrice/src/game/abstract_game.h | 7 ------- cockatrice/src/game_graphics/board/card_item.cpp | 9 ++++++--- cockatrice/src/game_graphics/game_scene.cpp | 14 ++++++++++++-- cockatrice/src/game_graphics/game_scene.h | 7 +++++++ .../src/game_graphics/player/menu/player_menu.cpp | 2 +- 6 files changed, 26 insertions(+), 20 deletions(-) diff --git a/cockatrice/src/game/abstract_game.cpp b/cockatrice/src/game/abstract_game.cpp index efc575eff..7c342d1a9 100644 --- a/cockatrice/src/game/abstract_game.cpp +++ b/cockatrice/src/game/abstract_game.cpp @@ -18,8 +18,6 @@ AbstractGame::AbstractGame(QObject *_parent) : QObject(_parent) { gameMetaInfo = new GameMetaInfo(this); gameEventHandler = new GameEventHandler(this); - - activeCard = nullptr; } bool AbstractGame::isHost() const @@ -48,11 +46,6 @@ void AbstractGame::loadReplay(const GameReplay *replay) gameMetaInfo->setSpectatorsOmniscient(true); } -void AbstractGame::setActiveCard(CardItem *card) -{ - activeCard = card; -} - CardItem *AbstractGame::getCard(int playerId, const QString &zoneName, int cardId) const { PlayerLogic *player = playerManager->getPlayer(playerId); diff --git a/cockatrice/src/game/abstract_game.h b/cockatrice/src/game/abstract_game.h index 326af8ac9..093916aa9 100644 --- a/cockatrice/src/game/abstract_game.h +++ b/cockatrice/src/game/abstract_game.h @@ -30,7 +30,6 @@ public: GameState *gameState; GameEventHandler *gameEventHandler; PlayerManager *playerManager; - CardItem *activeCard; GameMetaInfo *getGameMetaInfo() { @@ -59,12 +58,6 @@ public: void loadReplay(const GameReplay *replay); CardItem *getCard(int playerId, const QString &zoneName, int cardId) const; - - void setActiveCard(CardItem *card); - CardItem *getActiveCard() const - { - return activeCard; - } }; #endif // COCKATRICE_ABSTRACT_GAME_H diff --git a/cockatrice/src/game_graphics/board/card_item.cpp b/cockatrice/src/game_graphics/board/card_item.cpp index 1901a634f..eae7cee18 100644 --- a/cockatrice/src/game_graphics/board/card_item.cpp +++ b/cockatrice/src/game_graphics/board/card_item.cpp @@ -67,9 +67,10 @@ CardItem::CardItem(PlayerLogic *_owner, void CardItem::prepareDelete() { if (owner != nullptr) { - if (owner->getGame()->getActiveCard() == this) { + auto *gameScene = qobject_cast(scene()); + if (gameScene && gameScene->getActiveCard() == this) { emit owner->requestCardMenuUpdate(nullptr); - owner->getGame()->setActiveCard(nullptr); + gameScene->setActiveCard(nullptr); } owner = nullptr; } @@ -567,7 +568,9 @@ QVariant CardItem::itemChange(GraphicsItemChange change, const QVariant &value) bool selected = value.toBool(); if (selected) { - owner->getGame()->setActiveCard(this); + if (auto *gameScene = qobject_cast(scene())) { + gameScene->setActiveCard(this); + } } emit selectionChanged(this, selected); diff --git a/cockatrice/src/game_graphics/game_scene.cpp b/cockatrice/src/game_graphics/game_scene.cpp index cc3f9f1d3..77a876030 100644 --- a/cockatrice/src/game_graphics/game_scene.cpp +++ b/cockatrice/src/game_graphics/game_scene.cpp @@ -124,6 +124,16 @@ QList GameScene::selectedCards() const return selectedCards; } +CardItem *GameScene::getActiveCard() const +{ + return activeCard; +} + +void GameScene::setActiveCard(CardItem *card) +{ + activeCard = card; +} + void GameScene::onCardSelectionChanged(AbstractCardItem *abstractCard, bool selected) { CardItem *card = qobject_cast(abstractCard); @@ -139,7 +149,7 @@ void GameScene::onCardSelectionChanged(AbstractCardItem *abstractCard, bool sele } if (selectedItems().isEmpty()) { - owner->getGame()->setActiveCard(nullptr); + setActiveCard(nullptr); owner->requestCardMenuUpdate(nullptr); } } @@ -158,7 +168,7 @@ void GameScene::onCardRightClicked(AbstractCardItem *abstractCard, QPoint screen return; } - card->getOwner()->getGame()->setActiveCard(card); + setActiveCard(card); if (auto *menu = view->getPlayerMenu()->updateCardMenu(card)) { menu->popup(screenPos); diff --git a/cockatrice/src/game_graphics/game_scene.h b/cockatrice/src/game_graphics/game_scene.h index c7cae1e76..03f76c7d7 100644 --- a/cockatrice/src/game_graphics/game_scene.h +++ b/cockatrice/src/game_graphics/game_scene.h @@ -69,6 +69,7 @@ private: QList zoneViews; ///< Active zone view widgets QSize viewSize; ///< Current view size QPointer hoveredCard; ///< Currently hovered card + QPointer activeCard; ///< Card the currently open context menu targets QBasicTimer *animationTimer; ///< Timer for scene animations QHash animatedItems; ///< Items currently animating QHash @@ -108,6 +109,12 @@ public: /** @brief Gets all selected CardItems. */ QList selectedCards() const; + /** @brief Card the currently open context menu targets, or null. */ + CardItem *getActiveCard() const; + + /** @brief Sets the card the currently open context menu targets. */ + void setActiveCard(CardItem *card); + /** * @brief Adds a player to the scene and stores their graphics item. * @param player Player to add. diff --git a/cockatrice/src/game_graphics/player/menu/player_menu.cpp b/cockatrice/src/game_graphics/player/menu/player_menu.cpp index 6a23890ee..7e69df66e 100644 --- a/cockatrice/src/game_graphics/player/menu/player_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/player_menu.cpp @@ -98,7 +98,7 @@ QMenu *PlayerMenu::updateCardMenu(CardItem *card) // only update the menu if the card is actually selected if ((player->getLogic()->getGame()->getPlayerManager()->isSpectator() && !player->getLogic()->getGame()->getPlayerManager()->isJudge()) || - player->getLogic()->getGame()->getActiveCard() != card) { + player->getGameScene()->getActiveCard() != card) { return nullptr; } From 563833a88ae310f7b5f1464627405d5872a916ea Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:44:09 +0100 Subject: [PATCH 056/108] [Refactor] Build Command_MoveCard in game/ instead of the zone views (#7417) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- .../src/game/player/card_move_request.h | 22 ++++++++++++ cockatrice/src/game/player/player_actions.cpp | 36 +++++++++++++++++++ cockatrice/src/game/player/player_actions.h | 13 +++++++ .../src/game_graphics/zones/hand_zone.cpp | 17 +++------ .../src/game_graphics/zones/pile_zone.cpp | 21 +++-------- .../src/game_graphics/zones/stack_zone.cpp | 19 +++------- .../src/game_graphics/zones/table_zone.cpp | 28 +++++---------- .../src/game_graphics/zones/view_zone.cpp | 20 +++-------- 8 files changed, 98 insertions(+), 78 deletions(-) create mode 100644 cockatrice/src/game/player/card_move_request.h diff --git a/cockatrice/src/game/player/card_move_request.h b/cockatrice/src/game/player/card_move_request.h new file mode 100644 index 000000000..bed46ab60 --- /dev/null +++ b/cockatrice/src/game/player/card_move_request.h @@ -0,0 +1,22 @@ +/** + * @file card_move_request.h + * @ingroup GameLogicActions + */ +//! \todo Document this file. + +#ifndef COCKATRICE_CARD_MOVE_REQUEST_H +#define COCKATRICE_CARD_MOVE_REQUEST_H + +#include + +/** + * @brief Describes one card a view wants moved, in logic terms rather than wire terms. + */ +struct CardMoveRequest +{ + int cardId = -1; + bool faceDown = false; + QString pt = {}; +}; + +#endif // COCKATRICE_CARD_MOVE_REQUEST_H diff --git a/cockatrice/src/game/player/player_actions.cpp b/cockatrice/src/game/player/player_actions.cpp index d8c02396e..0156146ca 100644 --- a/cockatrice/src/game/player/player_actions.cpp +++ b/cockatrice/src/game/player/player_actions.cpp @@ -1999,6 +1999,42 @@ PendingCommand *PlayerActions::prepareGameCommand(const QList &cards) +{ + if (!startZone || !targetZone) { + return; + } + + Command_MoveCard cmd; + cmd.set_start_player_id(startZone->getPlayer()->getPlayerInfo()->getId()); + cmd.set_start_zone(startZone->getName().toStdString()); + cmd.set_target_player_id(targetZone->getPlayer()->getPlayerInfo()->getId()); + cmd.set_target_zone(targetZone->getName().toStdString()); + cmd.set_x(x); + cmd.set_y(y); + + if (auto *viewLogic = qobject_cast(targetZone)) { + cmd.set_is_reversed(viewLogic->getIsReversed()); + } + + for (const CardMoveRequest &card : cards) { + auto *cardToMove = cmd.mutable_cards_to_move()->add_card(); + cardToMove->set_card_id(card.cardId); + if (card.faceDown) { + cardToMove->set_face_down(true); + } + if (!card.pt.isEmpty()) { + cardToMove->set_pt(card.pt.toStdString()); + } + } + + sendGameCommand(cmd); +} + void PlayerActions::sendGameCommand(const google::protobuf::Message &command) { if (player->getPlayerInfo()->getJudge() && !player->getPlayerInfo()->getLocal()) { diff --git a/cockatrice/src/game/player/player_actions.h b/cockatrice/src/game/player/player_actions.h index 427fd8f66..89e4dbbf7 100644 --- a/cockatrice/src/game/player/player_actions.h +++ b/cockatrice/src/game/player/player_actions.h @@ -11,6 +11,7 @@ #include "../../game_graphics/board/card_item.h" // IWYU pragma: keep - moc instantiates the metatype for QList, which Qt 6.4 needs complete #include "../board/card_list.h" #include "card_menu_action_type.h" +#include "card_move_request.h" #include "libcockatrice/card/card_info.h" #include "move_top_cards_until_options.h" #include "token_info.h" @@ -36,6 +37,7 @@ class Message; } } // namespace google +class CardZoneLogic; class Command_MoveCard; class PendingCommand; class PlayerLogic; @@ -58,6 +60,17 @@ public: PendingCommand *prepareGameCommand(const ::google::protobuf::Message &cmd); PendingCommand *prepareGameCommand(const QList &cmdList); + /** + * @brief Moves the requested cards between zones on behalf of a view. + * @param startZone Zone the cards are dragged out of. + * @param targetZone Zone the cards are dropped onto. + * @param x Horizontal target index within the zone. + * @param y Vertical target index within the zone. + * @param cards The cards to move, as chosen by the view. + */ + void + moveCards(CardZoneLogic *startZone, CardZoneLogic *targetZone, int x, int y, const QList &cards); + void moveOneCardUntil(CardItem *card); void stopMoveTopCardsUntil(); diff --git a/cockatrice/src/game_graphics/zones/hand_zone.cpp b/cockatrice/src/game_graphics/zones/hand_zone.cpp index 3e527c081..600b7efa9 100644 --- a/cockatrice/src/game_graphics/zones/hand_zone.cpp +++ b/cockatrice/src/game_graphics/zones/hand_zone.cpp @@ -18,7 +18,6 @@ #include #include #include -#include #include #include @@ -58,19 +57,13 @@ void HandZone::handleDropEvent(const QList &dragItems, x = calcDropIndexFromY(dropPoint.y(), !sameZone); } - Command_MoveCard cmd; - cmd.set_start_player_id(startZone->getPlayer()->getPlayerInfo()->getId()); - cmd.set_start_zone(startZone->getName().toStdString()); - cmd.set_target_player_id(getLogic()->getPlayer()->getPlayerInfo()->getId()); - cmd.set_target_zone(getLogic()->getName().toStdString()); - cmd.set_x(x); - cmd.set_y(-1); - - for (int i = 0; i < dragItems.size(); ++i) { - cmd.mutable_cards_to_move()->add_card()->set_card_id(dragItems[i]->getId()); + QList cards; + cards.reserve(dragItems.size()); + for (const CardDragItem *item : dragItems) { + cards.append(CardMoveRequest{item->getId()}); } - getLogic()->getPlayer()->getPlayerActions()->sendGameCommand(cmd); + getLogic()->getPlayer()->getPlayerActions()->moveCards(startZone, getLogic(), x, -1, cards); } QRectF HandZone::boundingRect() const diff --git a/cockatrice/src/game_graphics/zones/pile_zone.cpp b/cockatrice/src/game_graphics/zones/pile_zone.cpp index 77a87a4af..aa578097a 100644 --- a/cockatrice/src/game_graphics/zones/pile_zone.cpp +++ b/cockatrice/src/game_graphics/zones/pile_zone.cpp @@ -24,7 +24,6 @@ #include #include #include -#include #include #include #include @@ -80,23 +79,13 @@ void PileZone::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*optio void PileZone::handleDropEvent(const QList &dragItems, CardZoneLogic *startZone, const QPoint &) { - Command_MoveCard cmd; - cmd.set_start_player_id(startZone->getPlayer()->getPlayerInfo()->getId()); - cmd.set_start_zone(startZone->getName().toStdString()); - cmd.set_target_player_id(getLogic()->getPlayer()->getPlayerInfo()->getId()); - cmd.set_target_zone(getLogic()->getName().toStdString()); - cmd.set_x(0); - cmd.set_y(0); - - for (int i = 0; i < dragItems.size(); ++i) { - auto cardToMove = cmd.mutable_cards_to_move()->add_card(); - cardToMove->set_card_id(dragItems[i]->getId()); - if (dragItems[i]->isForceFaceDown()) { - cardToMove->set_face_down(true); - } + QList cards; + cards.reserve(dragItems.size()); + for (const CardDragItem *item : dragItems) { + cards.append(CardMoveRequest{item->getId(), item->isForceFaceDown()}); } - getLogic()->getPlayer()->getPlayerActions()->sendGameCommand(cmd); + getLogic()->getPlayer()->getPlayerActions()->moveCards(startZone, getLogic(), 0, 0, cards); } void PileZone::reorganizeCards() diff --git a/cockatrice/src/game_graphics/zones/stack_zone.cpp b/cockatrice/src/game_graphics/zones/stack_zone.cpp index de1ff8ba9..b94cc8e41 100644 --- a/cockatrice/src/game_graphics/zones/stack_zone.cpp +++ b/cockatrice/src/game_graphics/zones/stack_zone.cpp @@ -19,7 +19,6 @@ #include #include #include -#include #include #include @@ -79,25 +78,15 @@ void StackZone::handleDropEvent(const QList &dragItems, } } - Command_MoveCard cmd; - cmd.set_start_player_id(startZone->getPlayer()->getPlayerInfo()->getId()); - cmd.set_start_zone(startZone->getName().toStdString()); - cmd.set_target_player_id(getLogic()->getPlayer()->getPlayerInfo()->getId()); - cmd.set_target_zone(getLogic()->getName().toStdString()); - cmd.set_x(index); - cmd.set_y(0); - + QList cards; + cards.reserve(dragItems.size()); for (const CardDragItem *item : dragItems) { if (item) { - auto *cardToMove = cmd.mutable_cards_to_move()->add_card(); - cardToMove->set_card_id(item->getId()); - if (item->isForceFaceDown()) { - cardToMove->set_face_down(true); - } + cards.append(CardMoveRequest{item->getId(), item->isForceFaceDown()}); } } - getLogic()->getPlayer()->getPlayerActions()->sendGameCommand(cmd); + getLogic()->getPlayer()->getPlayerActions()->moveCards(startZone, getLogic(), index, 0, cards); } void StackZone::setHeight(qreal newHeight) diff --git a/cockatrice/src/game_graphics/zones/table_zone.cpp b/cockatrice/src/game_graphics/zones/table_zone.cpp index f98d7e5cb..9d6a3830b 100644 --- a/cockatrice/src/game_graphics/zones/table_zone.cpp +++ b/cockatrice/src/game_graphics/zones/table_zone.cpp @@ -32,7 +32,6 @@ #include #include #include -#include #include #include #include @@ -209,29 +208,20 @@ void TableZone::handleDropEventByGrid(const QList &dragItems, CardZoneLogic *startZone, const QPoint &gridPoint) { - Command_MoveCard cmd; - cmd.set_start_player_id(startZone->getPlayer()->getPlayerInfo()->getId()); - cmd.set_start_zone(startZone->getName().toStdString()); - cmd.set_target_player_id(getLogic()->getPlayer()->getPlayerInfo()->getId()); - cmd.set_target_zone(getLogic()->getName().toStdString()); - cmd.set_x(gridPoint.x()); - cmd.set_y(gridPoint.y()); - - for (const auto &item : dragItems) { - CardToMove *ctm = cmd.mutable_cards_to_move()->add_card(); - ctm->set_card_id(item->getId()); - if (item->isForceFaceDown()) { - ctm->set_face_down(true); - } + QList cards; + cards.reserve(dragItems.size()); + for (const CardDragItem *item : dragItems) { + CardMoveRequest card{item->getId(), item->isForceFaceDown()}; if (startZone->getName() != getLogic()->getName() && !item->isForceFaceDown()) { - const auto &card = item->getItem()->getCard(); - if (card) { - ctm->set_pt(card.getInfo().getPowTough().toStdString()); + const auto &cardInfo = item->getItem()->getCard(); + if (cardInfo) { + card.pt = cardInfo.getInfo().getPowTough(); } } + cards.append(card); } - startZone->getPlayer()->getPlayerActions()->sendGameCommand(cmd); + startZone->getPlayer()->getPlayerActions()->moveCards(startZone, getLogic(), gridPoint.x(), gridPoint.y(), cards); } void TableZone::reorganizeCards() diff --git a/cockatrice/src/game_graphics/zones/view_zone.cpp b/cockatrice/src/game_graphics/zones/view_zone.cpp index a2aa124dc..b6659ddda 100644 --- a/cockatrice/src/game_graphics/zones/view_zone.cpp +++ b/cockatrice/src/game_graphics/zones/view_zone.cpp @@ -27,7 +27,6 @@ #include #include #include -#include #include #include #include @@ -301,24 +300,13 @@ void ZoneViewZone::handleDropEvent(const QList &dragItems, CardZoneLogic *startZone, const QPoint & /*dropPoint*/) { - Command_MoveCard cmd; - cmd.set_start_player_id(startZone->getPlayer()->getPlayerInfo()->getId()); - cmd.set_start_zone(startZone->getName().toStdString()); - cmd.set_target_player_id(getLogic()->getPlayer()->getPlayerInfo()->getId()); - cmd.set_target_zone(getLogic()->getName().toStdString()); - cmd.set_x(0); - cmd.set_y(0); - cmd.set_is_reversed(qobject_cast(getLogic())->getIsReversed()); - + QList cards; + cards.reserve(dragItems.size()); for (int i = 0; i < dragItems.size(); ++i) { - auto cardToMove = cmd.mutable_cards_to_move()->add_card(); - cardToMove->set_card_id(dragItems[i]->getId()); - if (dragItems[i]->isForceFaceDown()) { - cardToMove->set_face_down(true); - } + cards.append(CardMoveRequest{dragItems[i]->getId(), dragItems[i]->isForceFaceDown()}); } - getLogic()->getPlayer()->getPlayerActions()->sendGameCommand(cmd); + getLogic()->getPlayer()->getPlayerActions()->moveCards(startZone, getLogic(), 0, 0, cards); } void ZoneViewZone::setGeometry(const QRectF &rect) From 095b135640e7b22762284d35e73c53862c0bb7ab Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:44:09 +0100 Subject: [PATCH 057/108] [Refactor] Move the remaining zone commands out of game_graphics/zones/ (#7418) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Refactor] Move the remaining zone commands out of game_graphics/zones/ * [Game] Move zone dump request onto ZoneViewZoneLogic per review --------- Co-authored-by: Lukas Brübach --- cockatrice/src/game/player/player_actions.cpp | 35 +++++++++++++++++ cockatrice/src/game/player/player_actions.h | 15 ++++++++ cockatrice/src/game/zones/view_zone_logic.cpp | 14 ++++++- cockatrice/src/game/zones/view_zone_logic.h | 11 ++++++ .../src/game_graphics/zones/table_zone.cpp | 38 +++++-------------- .../src/game_graphics/zones/view_zone.cpp | 14 ++----- .../game_graphics/zones/view_zone_widget.cpp | 3 +- 7 files changed, 89 insertions(+), 41 deletions(-) diff --git a/cockatrice/src/game/player/player_actions.cpp b/cockatrice/src/game/player/player_actions.cpp index 0156146ca..bdef380e5 100644 --- a/cockatrice/src/game/player/player_actions.cpp +++ b/cockatrice/src/game/player/player_actions.cpp @@ -50,6 +50,7 @@ #include #include #include +#include #include #include #include @@ -2035,6 +2036,40 @@ void PlayerActions::moveCards(CardZoneLogic *startZone, sendGameCommand(cmd); } +PendingCommand *PlayerActions::prepareZoneDump(CardZoneLogic *zone, int numberCards) +{ + if (!zone) { + return nullptr; + } + + Command_DumpZone cmd; + cmd.set_player_id(zone->getPlayer()->getPlayerInfo()->getId()); + cmd.set_zone_name(zone->getName().toStdString()); + cmd.set_number_cards(numberCards); + if (auto *viewLogic = qobject_cast(zone)) { + cmd.set_is_reversed(viewLogic->getIsReversed()); + } + + return prepareGameCommand(cmd); +} + +void PlayerActions::actToggleTapped(const QString &zoneName, const QList &cards, bool tapped) +{ + QList cmdList; + for (CardItem *card : cards) { + if (card->getTapped() != tapped) { + auto *cmd = new Command_SetCardAttr; + cmd->set_zone(zoneName.toStdString()); + cmd->set_card_id(card->getId()); + cmd->set_attribute(AttrTapped); + cmd->set_attr_value(tapped ? "1" : "0"); + cmdList.append(cmd); + } + } + + sendGameCommand(prepareGameCommand(cmdList)); +} + void PlayerActions::sendGameCommand(const google::protobuf::Message &command) { if (player->getPlayerInfo()->getJudge() && !player->getPlayerInfo()->getLocal()) { diff --git a/cockatrice/src/game/player/player_actions.h b/cockatrice/src/game/player/player_actions.h index 89e4dbbf7..d8b2b3030 100644 --- a/cockatrice/src/game/player/player_actions.h +++ b/cockatrice/src/game/player/player_actions.h @@ -71,6 +71,21 @@ public: void moveCards(CardZoneLogic *startZone, CardZoneLogic *targetZone, int x, int y, const QList &cards); + /** + * @brief Prepares a dump of the given zone, for the caller to connect to and send. + * @param zone Zone to dump. + * @param numberCards How many cards to dump, -1 for all. + */ + PendingCommand *prepareZoneDump(CardZoneLogic *zone, int numberCards); + + /** + * @brief Sets the tapped state of the cards that do not already have it. + * @param zoneName Zone the cards belong to. + * @param cards Cards the view wants toggled. + * @param tapped Desired tapped state. + */ + void actToggleTapped(const QString &zoneName, const QList &cards, bool tapped); + void moveOneCardUntil(CardItem *card); void stopMoveTopCardsUntil(); diff --git a/cockatrice/src/game/zones/view_zone_logic.cpp b/cockatrice/src/game/zones/view_zone_logic.cpp index eb3b7715f..fbda8eaae 100644 --- a/cockatrice/src/game/zones/view_zone_logic.cpp +++ b/cockatrice/src/game/zones/view_zone_logic.cpp @@ -3,6 +3,8 @@ #include "../../client/settings/cache_settings.h" #include "../../game_graphics/board/card_item.h" #include "../board/card_list.h" +#include "../player/player_actions.h" +#include "../player/player_logic.h" #include #include @@ -177,4 +179,14 @@ void ZoneViewZoneLogic::setWriteableRevealZone(bool _writeableRevealZone) emit removeFromViews(); } writeableRevealZone = _writeableRevealZone; -} \ No newline at end of file +} + +PendingCommand *ZoneViewZoneLogic::requestZoneDump() +{ + PlayerActions *actions = getPlayer()->getPlayerActions(); + PendingCommand *pend = actions->prepareZoneDump(this, numberCards); + if (pend != nullptr) { + actions->sendGameCommand(pend); + } + return pend; +} diff --git a/cockatrice/src/game/zones/view_zone_logic.h b/cockatrice/src/game/zones/view_zone_logic.h index 6fae301e4..1abcd0516 100644 --- a/cockatrice/src/game/zones/view_zone_logic.h +++ b/cockatrice/src/game/zones/view_zone_logic.h @@ -12,6 +12,7 @@ class PlayerLogic; class QObject; +class PendingCommand; class ZoneViewZoneLogic : public CardZoneLogic { @@ -73,6 +74,16 @@ public: return origZone; } + /** + * @brief Prepares and sends the zone-dump command for this view. + * @return The pending command, or nullptr when there is no zone to dump. + * + * Keeps command construction in game/ so graphics views don't reach + * through the player into PlayerActions to set it up. The command is sent + * asynchronously, so callers connect to finished() after calling this. + */ + PendingCommand *requestZoneDump(); + protected: void addCardImpl(CardItem *card, int x, int y) override; }; diff --git a/cockatrice/src/game_graphics/zones/table_zone.cpp b/cockatrice/src/game_graphics/zones/table_zone.cpp index 9d6a3830b..748434033 100644 --- a/cockatrice/src/game_graphics/zones/table_zone.cpp +++ b/cockatrice/src/game_graphics/zones/table_zone.cpp @@ -32,7 +32,6 @@ #include #include #include -#include #include #include #include @@ -267,35 +266,18 @@ void TableZone::reorganizeCards() void TableZone::toggleTapped() { - QList selectedItemsRaw = scene()->selectedItems(); - QList selectedItems; - - auto isCardOnTable = [](const QGraphicsItem *item) { - if (auto card = qgraphicsitem_cast(item)) { - return card->getZone()->getName() == ZoneNames::TABLE; - } - return false; - }; - - std::copy_if(selectedItemsRaw.begin(), selectedItemsRaw.end(), std::back_inserter(selectedItems), isCardOnTable); - - bool tapAll = std::any_of(selectedItems.begin(), selectedItems.end(), [](const QGraphicsItem *item) { - return !qgraphicsitem_cast(item)->getTapped(); - }); - QList cmdList; - for (const auto &selectedItem : selectedItems) { - CardItem *temp = qgraphicsitem_cast(selectedItem); - if (temp->getTapped() != tapAll) { - Command_SetCardAttr *cmd = new Command_SetCardAttr; - cmd->set_zone(getLogic()->getName().toStdString()); - cmd->set_card_id(temp->getId()); - cmd->set_attribute(AttrTapped); - cmd->set_attr_value(tapAll ? "1" : "0"); - cmdList.append(cmd); + QList selectedCards; + for (auto *item : scene()->selectedItems()) { + auto *card = qgraphicsitem_cast(item); + if (card && card->getZone()->getName() == ZoneNames::TABLE) { + selectedCards.append(card); } } - getLogic()->getPlayer()->getPlayerActions()->sendGameCommand( - getLogic()->getPlayer()->getPlayerActions()->prepareGameCommand(cmdList)); + + const bool tapAll = std::any_of(selectedCards.begin(), selectedCards.end(), + [](const CardItem *card) { return !card->getTapped(); }); + + getLogic()->getPlayer()->getPlayerActions()->actToggleTapped(getLogic()->getName(), selectedCards, tapAll); } void TableZone::resizeToContents() diff --git a/cockatrice/src/game_graphics/zones/view_zone.cpp b/cockatrice/src/game_graphics/zones/view_zone.cpp index b6659ddda..716d2b963 100644 --- a/cockatrice/src/game_graphics/zones/view_zone.cpp +++ b/cockatrice/src/game_graphics/zones/view_zone.cpp @@ -26,7 +26,6 @@ #include #include #include -#include #include #include #include @@ -98,15 +97,10 @@ void ZoneViewZone::initializeCards(const QList &cardLis } reorganizeCards(); } else if (!qobject_cast(getLogic())->getOriginalZone()->contentsKnown()) { - Command_DumpZone cmd; - cmd.set_player_id(getLogic()->getPlayer()->getPlayerInfo()->getId()); - cmd.set_zone_name(getLogic()->getName().toStdString()); - cmd.set_number_cards(numberCards); - cmd.set_is_reversed(qobject_cast(getLogic())->getIsReversed()); - - PendingCommand *pend = getLogic()->getPlayer()->getPlayerActions()->prepareGameCommand(cmd); - connect(pend, &PendingCommand::finished, this, &ZoneViewZone::zoneDumpReceived); - getLogic()->getPlayer()->getPlayerActions()->sendGameCommand(pend); + PendingCommand *pend = qobject_cast(getLogic())->requestZoneDump(); + if (pend != nullptr) { + connect(pend, &PendingCommand::finished, this, &ZoneViewZone::zoneDumpReceived); + } } else { const CardList &c = qobject_cast(getLogic())->getOriginalZone()->getCards(); int number = numberCards == -1 ? c.size() : (numberCards < c.size() ? numberCards : c.size()); diff --git a/cockatrice/src/game_graphics/zones/view_zone_widget.cpp b/cockatrice/src/game_graphics/zones/view_zone_widget.cpp index 8c82d6401..d6d42c3d9 100644 --- a/cockatrice/src/game_graphics/zones/view_zone_widget.cpp +++ b/cockatrice/src/game_graphics/zones/view_zone_widget.cpp @@ -45,7 +45,6 @@ #include #include #include -#include #include #include #include @@ -570,7 +569,7 @@ void ZoneViewWidget::closeEvent(QCloseEvent *event) // manually call zone->close in order to remove it from the origZones views zone->close(); if (shuffleCheckBox.isChecked()) { - player->getPlayerActions()->sendGameCommand(Command_Shuffle()); + player->getPlayerActions()->actShuffle(); } zoneDeleted(); event->accept(); From 73d5a2a0aba721abe886e18ac44923ef45f1b161 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:44:10 +0100 Subject: [PATCH 058/108] [Refactor] Emit phase toolbar intent instead of protobuf commands (#7419) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- cockatrice/src/game/game_event_handler.cpp | 22 +++++++++++ cockatrice/src/game/game_event_handler.h | 6 +++ .../src/game_graphics/phases_toolbar.cpp | 39 ++----------------- cockatrice/src/game_graphics/phases_toolbar.h | 21 +++++----- .../src/interface/widgets/tabs/tab_game.cpp | 10 ++++- 5 files changed, 49 insertions(+), 49 deletions(-) diff --git a/cockatrice/src/game/game_event_handler.cpp b/cockatrice/src/game/game_event_handler.cpp index 7b08af47c..bd2f4de58 100644 --- a/cockatrice/src/game/game_event_handler.cpp +++ b/cockatrice/src/game/game_event_handler.cpp @@ -29,11 +29,14 @@ #include #include #include +#include #include #include #include #include #include +#include +#include #include #include #include @@ -48,6 +51,7 @@ #include #include #include +#include #include #include @@ -223,6 +227,24 @@ void GameEventHandler::handleActivePhaseChanged(int phase) sendGameCommand(cmd); } +void GameEventHandler::handleUntapAll() +{ + Command_SetCardAttr cmd; + cmd.set_zone(ZoneNames::TABLE); + cmd.set_attribute(AttrTapped); + cmd.set_attr_value("0"); + + sendGameCommand(cmd); +} + +void GameEventHandler::handleDrawCard() +{ + Command_DrawCards cmd; + cmd.set_number(1); + + sendGameCommand(cmd); +} + void GameEventHandler::handleGameLeft() { sendGameCommand(Command_LeaveGame()); diff --git a/cockatrice/src/game/game_event_handler.h b/cockatrice/src/game/game_event_handler.h index 30021d295..8b2354b9e 100644 --- a/cockatrice/src/game/game_event_handler.h +++ b/cockatrice/src/game/game_event_handler.h @@ -127,6 +127,12 @@ public: */ void handleActivePhaseChanged(int phase); + /** @brief Request untapping every permanent on the table. */ + void handleUntapAll(); + + /** @brief Request drawing a single card. */ + void handleDrawCard(); + /** @brief Leave the current game session. */ void handleGameLeft(); diff --git a/cockatrice/src/game_graphics/phases_toolbar.cpp b/cockatrice/src/game_graphics/phases_toolbar.cpp index d0a3f74fd..bc05e8901 100644 --- a/cockatrice/src/game_graphics/phases_toolbar.cpp +++ b/cockatrice/src/game_graphics/phases_toolbar.cpp @@ -14,11 +14,6 @@ #include #include #include -#include -#include -#include -#include -#include #include #include @@ -120,9 +115,9 @@ PhasesToolbar::PhasesToolbar(QGraphicsItem *parent) : QGraphicsItem(parent), width(100), height(100), ySpacing(1), symbolSize(8) { auto *aUntapAll = new QAction(this); - connect(aUntapAll, &QAction::triggered, this, &PhasesToolbar::actUntapAll); + connect(aUntapAll, &QAction::triggered, this, &PhasesToolbar::untapAllRequested); auto *aDrawCard = new QAction(this); - connect(aDrawCard, &QAction::triggered, this, &PhasesToolbar::actDrawCard); + connect(aDrawCard, &QAction::triggered, this, &PhasesToolbar::drawCardRequested); PhaseButton *untapButton = new PhaseButton("untap", this, aUntapAll); PhaseButton *upkeepButton = new PhaseButton("upkeep", this); @@ -144,7 +139,7 @@ PhasesToolbar::PhasesToolbar(QGraphicsItem *parent) } nextTurnButton = new PhaseButton("nextturn", this, nullptr, false); - connect(nextTurnButton, &PhaseButton::clicked, this, &PhasesToolbar::actNextTurn); + connect(nextTurnButton, &PhaseButton::clicked, this, &PhasesToolbar::nextTurnRequested); rearrangeButtons(); @@ -265,31 +260,5 @@ void PhasesToolbar::phaseButtonClicked() button->triggerDoubleClickAction(); } - Command_SetActivePhase cmd; - cmd.set_phase(static_cast(buttonList.indexOf(button))); - - emit sendGameCommand(cmd, -1); -} - -void PhasesToolbar::actNextTurn() -{ - emit sendGameCommand(Command_NextTurn(), -1); -} - -void PhasesToolbar::actUntapAll() -{ - Command_SetCardAttr cmd; - cmd.set_zone(ZoneNames::TABLE); - cmd.set_attribute(AttrTapped); - cmd.set_attr_value("0"); - - emit sendGameCommand(cmd, -1); -} - -void PhasesToolbar::actDrawCard() -{ - Command_DrawCards cmd; - cmd.set_number(1); - - emit sendGameCommand(cmd, -1); + emit setActivePhaseRequested(buttonList.indexOf(button)); } diff --git a/cockatrice/src/game_graphics/phases_toolbar.h b/cockatrice/src/game_graphics/phases_toolbar.h index 376775284..0565f1351 100644 --- a/cockatrice/src/game_graphics/phases_toolbar.h +++ b/cockatrice/src/game_graphics/phases_toolbar.h @@ -17,14 +17,6 @@ class QAction; class QTimer; -namespace google -{ -namespace protobuf -{ -class Message; -} -} // namespace google - class PhaseButton : public QObject, public QGraphicsItem { Q_OBJECT @@ -95,11 +87,16 @@ public slots: void triggerPhaseAction(int phase); private slots: void phaseButtonClicked(); - void actNextTurn(); - void actUntapAll(); - void actDrawCard(); signals: - void sendGameCommand(const ::google::protobuf::Message &command, int playerId); + /** @name Intent signals + * The view reports what the user asked for; GameEventHandler builds the commands. + * @{ + */ + void setActivePhaseRequested(int phase); + void nextTurnRequested(); + void untapAllRequested(); + void drawCardRequested(); + /** @} */ protected: void paint(QPainter *painter, const QStyleOptionGraphicsItem * /*option*/, QWidget * /*widget*/) override; diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.cpp b/cockatrice/src/interface/widgets/tabs/tab_game.cpp index 11813cdf4..c637dd0dd 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_game.cpp @@ -1276,8 +1276,14 @@ void TabGame::createPlayAreaWidget(bool bReplay) { phasesToolbar = new PhasesToolbar; if (!bReplay) { - connect(phasesToolbar, &PhasesToolbar::sendGameCommand, game->getGameEventHandler(), - qOverload(&GameEventHandler::sendGameCommand)); + connect(phasesToolbar, &PhasesToolbar::setActivePhaseRequested, game->getGameEventHandler(), + &GameEventHandler::handleActivePhaseChanged); + connect(phasesToolbar, &PhasesToolbar::nextTurnRequested, game->getGameEventHandler(), + &GameEventHandler::handleNextTurn); + connect(phasesToolbar, &PhasesToolbar::untapAllRequested, game->getGameEventHandler(), + &GameEventHandler::handleUntapAll); + connect(phasesToolbar, &PhasesToolbar::drawCardRequested, game->getGameEventHandler(), + &GameEventHandler::handleDrawCard); } scene = new GameScene(phasesToolbar, this); connect(game->getPlayerManager(), &PlayerManager::playerConceded, scene, &GameScene::rearrange); From 674a6079607ed791c283113dc62cbb42ab79b305 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:44:10 +0100 Subject: [PATCH 059/108] [Refactor] Build player counter commands in game/ instead of AbstractCounter (#7420) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- cockatrice/src/game/player/player_actions.cpp | 19 +++++++++++++++++++ cockatrice/src/game/player/player_actions.h | 6 ++++++ .../game_graphics/board/abstract_counter.cpp | 17 +++-------------- 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/cockatrice/src/game/player/player_actions.cpp b/cockatrice/src/game/player/player_actions.cpp index bdef380e5..19674f113 100644 --- a/cockatrice/src/game/player/player_actions.cpp +++ b/cockatrice/src/game/player/player_actions.cpp @@ -60,6 +60,7 @@ #include #include #include +#include #include #include #include @@ -1450,6 +1451,24 @@ void PlayerActions::actFlowT(QList selectedCards) actIncPT(selectedCards, -1, 1); } +void PlayerActions::actIncrementCounter(int counterId, int delta) +{ + Command_IncCounter cmd; + cmd.set_counter_id(counterId); + cmd.set_delta(delta); + + sendGameCommand(cmd); +} + +void PlayerActions::actSetCounter(int counterId, int value) +{ + Command_SetCounter cmd; + cmd.set_counter_id(counterId); + cmd.set_value(value); + + sendGameCommand(cmd); +} + void PlayerActions::actReduceLifeByPower(QList selectedCards) { // find life counter diff --git a/cockatrice/src/game/player/player_actions.h b/cockatrice/src/game/player/player_actions.h index d8b2b3030..c63b38d00 100644 --- a/cockatrice/src/game/player/player_actions.h +++ b/cockatrice/src/game/player/player_actions.h @@ -239,6 +239,12 @@ public slots: void actFlowP(QList selectedCards); void actFlowT(QList selectedCards); + /** @brief Adjusts a player counter by a signed delta. */ + void actIncrementCounter(int counterId, int delta); + + /** @brief Sets a player counter to an absolute value. */ + void actSetCounter(int counterId, int value); + void actReduceLifeByPower(QList selectedCards); void actRequestSetAnnotationDialog(QList selectedCards); diff --git a/cockatrice/src/game_graphics/board/abstract_counter.cpp b/cockatrice/src/game_graphics/board/abstract_counter.cpp index 8500b9c51..93eb9e011 100644 --- a/cockatrice/src/game_graphics/board/abstract_counter.cpp +++ b/cockatrice/src/game_graphics/board/abstract_counter.cpp @@ -23,8 +23,6 @@ #include #include #include -#include -#include #include #include @@ -151,10 +149,7 @@ void AbstractCounter::mousePressEvent(QGraphicsSceneMouseEvent *event) menu->exec(event->screenPos()); } } else { - Command_IncCounter cmd; - cmd.set_counter_id(id); - cmd.set_delta(event->button() == Qt::LeftButton ? 1 : -1); - player->getPlayerActions()->sendGameCommand(cmd); + player->getPlayerActions()->actIncrementCounter(id, event->button() == Qt::LeftButton ? 1 : -1); } event->accept(); } @@ -172,10 +167,7 @@ void AbstractCounter::hoverLeaveEvent(QGraphicsSceneHoverEvent *) void AbstractCounter::incrementCounter() { - Command_IncCounter cmd; - cmd.set_counter_id(id); - cmd.set_delta(static_cast(sender())->data().toInt()); - player->getPlayerActions()->sendGameCommand(cmd); + player->getPlayerActions()->actIncrementCounter(id, static_cast(sender())->data().toInt()); } void AbstractCounter::setCounter() @@ -199,10 +191,7 @@ void AbstractCounter::setCounter() } Expression exp(value); - Command_SetCounter cmd; - cmd.set_counter_id(id); - cmd.set_value(static_cast(exp.parse(dlg.textValue()))); - player->getPlayerActions()->sendGameCommand(cmd); + player->getPlayerActions()->actSetCounter(id, static_cast(exp.parse(dlg.textValue()))); } AbstractCounterDialog::AbstractCounterDialog(const QString &name, const QString &value, QWidget *parent) From 77f882ca37fd877a5e2203c343351d741a6d3621 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:44:10 +0100 Subject: [PATCH 060/108] [Refactor] Build deck setup commands in GameEventHandler instead of DeckViewContainer (#7421) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- cockatrice/src/game/game_event_handler.cpp | 70 +++++++++++++++++++ cockatrice/src/game/game_event_handler.h | 66 +++++++++++++++++ .../deckview/deck_view_container.cpp | 44 ++---------- 3 files changed, 143 insertions(+), 37 deletions(-) diff --git a/cockatrice/src/game/game_event_handler.cpp b/cockatrice/src/game/game_event_handler.cpp index bd2f4de58..856475dfa 100644 --- a/cockatrice/src/game/game_event_handler.cpp +++ b/cockatrice/src/game/game_event_handler.cpp @@ -28,14 +28,19 @@ #include #include #include +#include #include #include #include #include #include +#include #include #include #include +#include +#include +#include #include #include #include @@ -245,6 +250,71 @@ void GameEventHandler::handleDrawCard() sendGameCommand(cmd); } +PendingCommand *GameEventHandler::prepareDeckSelect(const QString &deckList) +{ + Command_DeckSelect cmd; + cmd.set_deck(deckList.toStdString()); + + return prepareGameCommand(cmd); +} + +PendingCommand *GameEventHandler::prepareDeckSelectId(int deckId) +{ + Command_DeckSelect cmd; + cmd.set_deck_id(deckId); + + return prepareGameCommand(cmd); +} + +PendingCommand *GameEventHandler::prepareSetPlaymat(const PlaymatInfo &playmat) +{ + Command_SetPlaymat cmd; + auto *pp = cmd.mutable_playmat_params(); + pp->set_card_name(playmat.card.name.toStdString()); + pp->set_card_provider_id(playmat.card.providerId.toStdString()); + pp->set_margin_pct_l(playmat.params.marginPctL); + pp->set_margin_pct_r(playmat.params.marginPctR); + pp->set_vertical_offset(playmat.params.verticalOffset); + pp->set_zoom(playmat.params.zoom); + + return prepareGameCommand(cmd); +} + +void GameEventHandler::sendReadyStart(bool ready, int playerId) +{ + Command_ReadyStart cmd; + cmd.set_ready(ready); + + sendGameCommand(cmd, playerId); +} + +void GameEventHandler::sendForceStart(int playerId) +{ + Command_ReadyStart cmd; + cmd.set_force_start(true); + cmd.set_ready(true); + + sendGameCommand(cmd, playerId); +} + +void GameEventHandler::sendSetSideboardLock(bool locked, int playerId) +{ + Command_SetSideboardLock cmd; + cmd.set_locked(locked); + + sendGameCommand(cmd, playerId); +} + +void GameEventHandler::sendSetSideboardPlan(const QList &plan, int playerId) +{ + Command_SetSideboardPlan cmd; + for (const auto &zone : plan) { + cmd.add_move_list()->CopyFrom(zone); + } + + sendGameCommand(cmd, playerId); +} + void GameEventHandler::handleGameLeft() { sendGameCommand(Command_LeaveGame()); diff --git a/cockatrice/src/game/game_event_handler.h b/cockatrice/src/game/game_event_handler.h index 8b2354b9e..15e6b771c 100644 --- a/cockatrice/src/game/game_event_handler.h +++ b/cockatrice/src/game/game_event_handler.h @@ -29,8 +29,10 @@ // GameEventContext, ServerInfo_Player and ServerInfo_User are passed by value // to signals and slots, so the moc-generated code needs the complete types. #include +#include #include #include +#include class AbstractClient; class AbstractGame; @@ -151,6 +153,70 @@ public: void handleArrowDeletion(int creatorId, int arrowId); void handleArrowDeletionFinished(const Response &response, int creatorId, int arrowId); + /** + * @brief Prepare a deck selection from its native deck list text. + * + * The returned command is not sent yet, so the caller can connect to + * PendingCommand::finished before handing it to sendGameCommand(). + * + * @param deckList Serialized deck list. + * @return The pending command awaiting the deck download response. + */ + PendingCommand *prepareDeckSelect(const QString &deckList); + + /** + * @brief Prepare a deck selection for a deck stored on the server. + * + * The returned command is not sent yet, so the caller can connect to + * PendingCommand::finished before handing it to sendGameCommand(). + * + * @param deckId Server-side deck identifier. + * @return The pending command awaiting the deck download response. + */ + PendingCommand *prepareDeckSelectId(int deckId); + + /** + * @brief Build the playmat command for a player without sending it. + * + * The returned command is not sent yet, so the caller can connect to + * PendingCommand::finished before handing it to sendGameCommand(). + * + * @param playmat Resolved playmat card and positioning parameters. + * @return The pending command awaiting the playmat response. + */ + PendingCommand *prepareSetPlaymat(const PlaymatInfo &playmat); + + /** + * @brief Set a player's ready state. + * + * @param ready Whether the player is ready. + * @param playerId Player the state applies to. + */ + void sendReadyStart(bool ready, int playerId); + + /** + * @brief Force-start the game, kicking every player who is not ready. + * + * @param playerId Player issuing the force start. + */ + void sendForceStart(int playerId); + + /** + * @brief Lock or unlock a player's sideboard. + * + * @param locked Whether the sideboard is locked. + * @param playerId Player the lock applies to. + */ + void sendSetSideboardLock(bool locked, int playerId); + + /** + * @brief Send a player's sideboard plan. + * + * @param plan Cards to move between zones when leaving sideboarding. + * @param playerId Player the plan belongs to. + */ + void sendSetSideboardPlan(const QList &plan, int playerId); + /** @} */ /** @name Incoming event processing diff --git a/cockatrice/src/game_graphics/deckview/deck_view_container.cpp b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp index 90b3bea11..eb858cf63 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view_container.cpp +++ b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp @@ -34,11 +34,6 @@ #include #include #include -#include -#include -#include -#include -#include #include #include #include @@ -314,9 +309,7 @@ void DeckViewContainer::loadDeckFromDeckList(const DeckList &deck) return; } - Command_DeckSelect cmd; - cmd.set_deck(deckString.toStdString()); - PendingCommand *pend = parentGame->getGame()->getGameEventHandler()->prepareGameCommand(cmd); + PendingCommand *pend = parentGame->getGame()->getGameEventHandler()->prepareDeckSelect(deckString); connect(pend, &PendingCommand::finished, this, &DeckViewContainer::deckSelectFinished); parentGame->getGame()->getGameEventHandler()->sendGameCommand(pend, playerId); @@ -345,15 +338,7 @@ void DeckViewContainer::resolveAndSendPlaymat() lastResolvedPlaymat = resolved; - Command_SetPlaymat playmatCmd; - auto *pp = playmatCmd.mutable_playmat_params(); - pp->set_card_name(resolved.card.name.toStdString()); - pp->set_card_provider_id(resolved.card.providerId.toStdString()); - pp->set_margin_pct_l(resolved.params.marginPctL); - pp->set_margin_pct_r(resolved.params.marginPctR); - pp->set_vertical_offset(resolved.params.verticalOffset); - pp->set_zoom(resolved.params.zoom); - PendingCommand *playmatPend = parentGame->getGame()->getGameEventHandler()->prepareGameCommand(playmatCmd); + PendingCommand *playmatPend = parentGame->getGame()->getGameEventHandler()->prepareSetPlaymat(resolved); const QString sentName = resolved.card.name; connect(playmatPend, &PendingCommand::finished, this, [this, sentName](const Response &r) { // Only RespFunctionNotAllowed signals a card art DENY rule. Older servers that predate @@ -384,9 +369,7 @@ void DeckViewContainer::loadRemoteDeck() { DlgLoadRemoteDeck dlg(parentGame->getGame()->getClientForPlayer(playerId), this); if (dlg.exec()) { - Command_DeckSelect cmd; - cmd.set_deck_id(dlg.getDeckId()); - PendingCommand *pend = parentGame->getGame()->getGameEventHandler()->prepareGameCommand(cmd); + PendingCommand *pend = parentGame->getGame()->getGameEventHandler()->prepareDeckSelectId(dlg.getDeckId()); connect(pend, &PendingCommand::finished, this, &DeckViewContainer::deckSelectFinished); parentGame->getGame()->getGameEventHandler()->sendGameCommand(pend, playerId); } @@ -446,28 +429,17 @@ void DeckViewContainer::forceStart() return; } - Command_ReadyStart cmd; - cmd.set_force_start(true); - cmd.set_ready(true); - parentGame->getGame()->getGameEventHandler()->sendGameCommand(cmd, playerId); + parentGame->getGame()->getGameEventHandler()->sendForceStart(playerId); } void DeckViewContainer::sideboardLockButtonClicked() { - Command_SetSideboardLock cmd; - cmd.set_locked(sideboardLockButton->getState()); - - parentGame->getGame()->getGameEventHandler()->sendGameCommand(cmd, playerId); + parentGame->getGame()->getGameEventHandler()->sendSetSideboardLock(sideboardLockButton->getState(), playerId); } void DeckViewContainer::sideboardPlanChanged() { - Command_SetSideboardPlan cmd; - const QList &newPlan = deckView->getSideboardPlan(); - for (const auto &i : newPlan) { - cmd.add_move_list()->CopyFrom(i); - } - parentGame->getGame()->getGameEventHandler()->sendGameCommand(cmd, playerId); + parentGame->getGame()->getGameEventHandler()->sendSetSideboardPlan(deckView->getSideboardPlan(), playerId); } /** @@ -479,9 +451,7 @@ void DeckViewContainer::sendReadyStartCommand(bool ready) resolveAndSendPlaymat(); } - Command_ReadyStart cmd; - cmd.set_ready(ready); - parentGame->getGame()->getGameEventHandler()->sendGameCommand(cmd, playerId); + parentGame->getGame()->getGameEventHandler()->sendReadyStart(ready, playerId); } /** From 3ccd44155d2b02faf6523857cc79b93d19cab28c Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:44:11 +0100 Subject: [PATCH 061/108] [Refactor] Build arrow and attach commands in game/ instead of ArrowItem (#7422) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- cockatrice/src/game/player/player_actions.cpp | 57 ++++++++++++++++++ cockatrice/src/game/player/player_actions.h | 21 ++++++- .../src/game_graphics/board/arrow_item.cpp | 60 ++++--------------- .../src/game_graphics/board/arrow_item.h | 1 - 4 files changed, 88 insertions(+), 51 deletions(-) diff --git a/cockatrice/src/game/player/player_actions.cpp b/cockatrice/src/game/player/player_actions.cpp index 19674f113..d7547ef73 100644 --- a/cockatrice/src/game/player/player_actions.cpp +++ b/cockatrice/src/game/player/player_actions.cpp @@ -5,6 +5,7 @@ #include "../../game_graphics/dialogs/dlg_create_token.h" #include "../../interface/deck_loader/loaded_deck.h" #include "../abstract_game.h" +#include "../board/arrow_data.h" #include "../board/counter_state.h" #include "../game_event_handler.h" #include "../zones/card_zone_logic.h" @@ -48,6 +49,7 @@ #include #include #include +#include #include #include #include @@ -66,6 +68,7 @@ #include #include #include +#include #include #include #include @@ -174,6 +177,60 @@ void PlayerActions::playCardToTable(const CardItem *card, bool faceDown) sendGameCommand(cmd); } +void PlayerActions::attachCards(CardItem *startCard, const CardItem *targetCard) +{ + if (targetCard->getAttachedTo() || targetCard->getZone()->getName() != ZoneNames::TABLE) { + return; + } + + // move card onto table first if attaching from some other zone + if (startCard->getZone()->getName() != ZoneNames::TABLE) { + playCardToTable(startCard, false); + } + + Command_AttachCard cmd; + cmd.set_start_zone(ZoneNames::TABLE); + cmd.set_card_id(startCard->getId()); + cmd.set_target_player_id(targetCard->getZone()->getPlayer()->getPlayerInfo()->getId()); + cmd.set_target_zone(targetCard->getZone()->getName().toStdString()); + cmd.set_target_card_id(targetCard->getId()); + sendGameCommand(cmd); +} + +void PlayerActions::createArrow(CardItem *startCard, const ArrowData &arrow, int deleteInPhase) +{ + Command_CreateArrow cmd; + cmd.mutable_arrow_color()->CopyFrom(convertQColorToColor(arrow.color)); + cmd.set_start_player_id(arrow.startPlayerId); + cmd.set_start_zone(arrow.startZone.toStdString()); + cmd.set_start_card_id(arrow.startCardId); + cmd.set_target_player_id(arrow.targetPlayerId); + + if (!arrow.isPlayerTargeted()) { + cmd.set_target_zone(arrow.targetZone.toStdString()); + cmd.set_target_card_id(arrow.targetCardId); + } + + // if the card is in hand then it has just been moved to the stack or table as part of drawing the arrow + if (arrow.startZone == ZoneNames::HAND) { + CardInfoPtr ci = startCard->getCard().getCardPtr(); + bool playToStack = SettingsCache::instance().userInterface().getPlayToStack(); + if (ci && ((!playToStack && ci->getUiAttributes().tableRow == 3) || + (playToStack && ci->getUiAttributes().tableRow != 0 && + startCard->getZone()->getName() != ZoneNames::STACK))) { + cmd.set_start_zone(ZoneNames::STACK); + } else { + cmd.set_start_zone(playToStack ? ZoneNames::STACK : ZoneNames::TABLE); + } + } + + if (deleteInPhase != 0) { + cmd.set_delete_in_phase(deleteInPhase); + } + + sendGameCommand(cmd); +} + void PlayerActions::actViewLibrary() { emit requestZoneViewToggle(ZoneNames::DECK, -1); diff --git a/cockatrice/src/game/player/player_actions.h b/cockatrice/src/game/player/player_actions.h index c63b38d00..6f51650b5 100644 --- a/cockatrice/src/game/player/player_actions.h +++ b/cockatrice/src/game/player/player_actions.h @@ -41,7 +41,7 @@ class CardZoneLogic; class Command_MoveCard; class PendingCommand; class PlayerLogic; - +struct ArrowData; class PlayerActions : public QObject { Q_OBJECT @@ -86,6 +86,25 @@ public: */ void actToggleTapped(const QString &zoneName, const QList &cards, bool tapped); + /** + * @brief Attaches one card to another on the table on behalf of a view. + * @param startCard Card being attached. + * @param targetCard Card being attached to. + */ + void attachCards(CardItem *startCard, const CardItem *targetCard); + + /** + * @brief Draws an arrow from a card to another card or a player on behalf of a view. + * + * The view resolves the endpoints before playing the card out of hand, so the + * arrow still records the zone the card started in. + * + * @param startCard Card the arrow starts from. + * @param arrow Arrow endpoints and color, as resolved by the view. + * @param deleteInPhase Phase the arrow expires in, 0 to keep it until removed. + */ + void createArrow(CardItem *startCard, const ArrowData &arrow, int deleteInPhase); + void moveOneCardUntil(CardItem *card); void stopMoveTopCardsUntil(); diff --git a/cockatrice/src/game_graphics/board/arrow_item.cpp b/cockatrice/src/game_graphics/board/arrow_item.cpp index c5a9b9c5e..90a49819a 100644 --- a/cockatrice/src/game_graphics/board/arrow_item.cpp +++ b/cockatrice/src/game_graphics/board/arrow_item.cpp @@ -27,12 +27,7 @@ #include #include #include -#include -#include -#include #include -#include -#include #include #include #include @@ -426,19 +421,19 @@ void ArrowDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) CardZoneLogic *startZone = startCard->getZone(); - Command_CreateArrow cmd; - cmd.mutable_arrow_color()->CopyFrom(convertQColorToColor(data->color)); - cmd.set_start_player_id(startZone->getPlayer()->getPlayerInfo()->getId()); - cmd.set_start_zone(startZone->getName().toStdString()); - cmd.set_start_card_id(startCard->getId()); + ArrowData arrow; + arrow.color = data->color; + arrow.startPlayerId = startZone->getPlayer()->getPlayerInfo()->getId(); + arrow.startZone = startZone->getName(); + arrow.startCardId = startCard->getId(); if (auto *targetCard = qgraphicsitem_cast(targetItem)) { CardZoneLogic *targetZone = targetCard->getZone(); - cmd.set_target_player_id(targetZone->getPlayer()->getPlayerInfo()->getId()); - cmd.set_target_zone(targetZone->getName().toStdString()); - cmd.set_target_card_id(targetCard->getId()); + arrow.targetPlayerId = targetZone->getPlayer()->getPlayerInfo()->getId(); + arrow.targetZone = targetZone->getName(); + arrow.targetCardId = targetCard->getId(); } else if (auto *targetPlayer = qgraphicsitem_cast(targetItem)) { - cmd.set_target_player_id(targetPlayer->getOwner()->getPlayerInfo()->getId()); + arrow.targetPlayerId = targetPlayer->getOwner()->getPlayerInfo()->getId(); } else { delArrow(); return; @@ -447,22 +442,9 @@ void ArrowDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) // if the card is in hand then we will move the card to stack or table as part of drawing the arrow if (startZone->getName() == ZoneNames::HAND) { startCard->playCard(false); - CardInfoPtr ci = startCard->getCard().getCardPtr(); - bool playToStack = SettingsCache::instance().userInterface().getPlayToStack(); - if (ci && ((!playToStack && ci->getUiAttributes().tableRow == 3) || - (playToStack && ci->getUiAttributes().tableRow != 0 && - startCard->getZone()->getName() != ZoneNames::STACK))) { - cmd.set_start_zone(ZoneNames::STACK); - } else { - cmd.set_start_zone(playToStack ? ZoneNames::STACK : ZoneNames::TABLE); - } } - if (deleteInPhase != 0) { - cmd.set_delete_in_phase(deleteInPhase); - } - - player->getPlayerActions()->sendGameCommand(cmd); + player->getPlayerActions()->createArrow(startCard, arrow, deleteInPhase); } delArrow(); @@ -554,7 +536,7 @@ void ArrowAttachItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) auto *startCard = qgraphicsitem_cast(startItem); auto *targetCard = qgraphicsitem_cast(targetItem); if (startCard && targetCard) { - attachCards(startCard, targetCard); + player->getPlayerActions()->attachCards(startCard, targetCard); } } @@ -563,23 +545,3 @@ void ArrowAttachItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) child->mouseReleaseEvent(event); } } - -void ArrowAttachItem::attachCards(CardItem *startCard, const CardItem *targetCard) -{ - if (targetCard->getAttachedTo() || targetCard->getZone()->getName() != ZoneNames::TABLE) { - return; - } - - // move card onto table first if attaching from some other zone - if (startCard->getZone()->getName() != ZoneNames::TABLE) { - player->getPlayerActions()->playCardToTable(startCard, false); - } - - Command_AttachCard cmd; - cmd.set_start_zone(ZoneNames::TABLE); - cmd.set_card_id(startCard->getId()); - cmd.set_target_player_id(targetCard->getZone()->getPlayer()->getPlayerInfo()->getId()); - cmd.set_target_zone(targetCard->getZone()->getName().toStdString()); - cmd.set_target_card_id(targetCard->getId()); - player->getPlayerActions()->sendGameCommand(cmd); -} \ No newline at end of file diff --git a/cockatrice/src/game_graphics/board/arrow_item.h b/cockatrice/src/game_graphics/board/arrow_item.h index 2395165fc..ab11f6d84 100644 --- a/cockatrice/src/game_graphics/board/arrow_item.h +++ b/cockatrice/src/game_graphics/board/arrow_item.h @@ -131,7 +131,6 @@ private: PlayerLogic *player; QList childArrows; QMetaObject::Connection positionConnection; - void attachCards(CardItem *startCard, const CardItem *targetCard); public: explicit ArrowAttachItem(ArrowTarget *_startItem); From 85fd052fdb29e985ec3270b22bf14e247d97cec0 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:44:11 +0100 Subject: [PATCH 062/108] [Refactor] Move ArrowRegistry out of game/ into game_graphics/board (#7423) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- cockatrice/CMakeLists.txt | 2 +- .../src/{game => game_graphics/board}/arrow_registry.cpp | 4 ++-- .../src/{game => game_graphics/board}/arrow_registry.h | 2 ++ cockatrice/src/game_graphics/game_scene.cpp | 2 +- cockatrice/src/game_graphics/game_scene.h | 6 +++++- 5 files changed, 11 insertions(+), 5 deletions(-) rename cockatrice/src/{game => game_graphics/board}/arrow_registry.cpp (93%) rename cockatrice/src/{game => game_graphics/board}/arrow_registry.h (95%) diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 8a40e3dc8..80e4dd339 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -72,12 +72,12 @@ set(cockatrice_SOURCES src/filters/filter_tree_model.cpp src/filters/syntax_help.cpp src/game/abstract_game.cpp - src/game/arrow_registry.cpp src/game_graphics/board/abstract_card_drag_item.cpp src/game_graphics/board/abstract_card_item.cpp src/game_graphics/board/abstract_counter.cpp src/game/board/arrow_data.cpp src/game_graphics/board/arrow_item.cpp + src/game_graphics/board/arrow_registry.cpp src/game_graphics/board/arrow_target.cpp src/game_graphics/board/card_drag_item.cpp src/game_graphics/board/card_item.cpp diff --git a/cockatrice/src/game/arrow_registry.cpp b/cockatrice/src/game_graphics/board/arrow_registry.cpp similarity index 93% rename from cockatrice/src/game/arrow_registry.cpp rename to cockatrice/src/game_graphics/board/arrow_registry.cpp index c05134a75..fe1c34263 100644 --- a/cockatrice/src/game/arrow_registry.cpp +++ b/cockatrice/src/game_graphics/board/arrow_registry.cpp @@ -1,7 +1,7 @@ #include "arrow_registry.h" -#include "../game_graphics/board/arrow_item.h" -#include "board/arrow_data.h" +#include "../../game/board/arrow_data.h" +#include "arrow_item.h" #include diff --git a/cockatrice/src/game/arrow_registry.h b/cockatrice/src/game_graphics/board/arrow_registry.h similarity index 95% rename from cockatrice/src/game/arrow_registry.h rename to cockatrice/src/game_graphics/board/arrow_registry.h index ca3b04090..f07c5c469 100644 --- a/cockatrice/src/game/arrow_registry.h +++ b/cockatrice/src/game_graphics/board/arrow_registry.h @@ -1,6 +1,8 @@ #ifndef COCKATRICE_ARROW_REGISTRY_H #define COCKATRICE_ARROW_REGISTRY_H +#include "../../game/board/arrow_data.h" + #include #include #include diff --git a/cockatrice/src/game_graphics/game_scene.cpp b/cockatrice/src/game_graphics/game_scene.cpp index 77a876030..f70ab7e12 100644 --- a/cockatrice/src/game_graphics/game_scene.cpp +++ b/cockatrice/src/game_graphics/game_scene.cpp @@ -2,7 +2,6 @@ #include "../client/settings/cache_settings.h" #include "../game/abstract_game.h" -#include "../game/arrow_registry.h" #include "../game/board/arrow_data.h" #include "../game/player/player_actions.h" #include "../game/player/player_event_handler.h" @@ -13,6 +12,7 @@ #include "animated_item.h" #include "board/abstract_card_item.h" #include "board/arrow_item.h" +#include "board/arrow_registry.h" #include "board/card_item.h" #include "phases_toolbar.h" #include "player/menu/player_menu.h" diff --git a/cockatrice/src/game_graphics/game_scene.h b/cockatrice/src/game_graphics/game_scene.h index 03f76c7d7..e7eb7778b 100644 --- a/cockatrice/src/game_graphics/game_scene.h +++ b/cockatrice/src/game_graphics/game_scene.h @@ -1,7 +1,11 @@ #ifndef GAMESCENE_H #define GAMESCENE_H -#include "../game/arrow_registry.h" +#include "../game/board/arrow_data.h" +#include "../game/zones/card_zone_logic.h" +#include "animated_item.h" +#include "board/arrow_item.h" +#include "board/arrow_registry.h" #include #include From 6f0c5dca1238fcdd84cf02f383fea31dc83aeeaf Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:44:11 +0100 Subject: [PATCH 063/108] [Refactor] Move LoadedDeck and DeckFileFormat data types into libcockatrice_deck_list (#7424) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- cockatrice/CMakeLists.txt | 2 -- cockatrice/src/filters/deck_filter_string.cpp | 2 +- cockatrice/src/filters/deck_filter_string.h | 1 + cockatrice/src/game/player/player_actions.cpp | 2 +- cockatrice/src/game/player/player_logic.h | 7 +++++++ .../src/game_graphics/deckview/deck_view_container.cpp | 4 ++-- cockatrice/src/interface/deck_loader/deck_loader.h | 7 ++++--- .../src/interface/intents/intent_open_local_deck.cpp | 4 ++-- cockatrice/src/interface/intents/intent_open_shared_deck.h | 2 +- .../interface/widgets/deck_editor/deck_state_manager.cpp | 2 +- .../src/interface/widgets/deck_editor/deck_state_manager.h | 3 ++- .../widgets/dialogs/dlg_convert_deck_to_cod_format.cpp | 2 +- .../widgets/dialogs/dlg_load_deck_from_clipboard.h | 2 ++ cockatrice/src/interface/widgets/general/home_widget.cpp | 4 ++-- .../interface/widgets/tabs/abstract_tab_deck_editor.cpp | 4 ++-- .../display/archidekt_api_response_deck_display_widget.cpp | 2 +- .../interface/widgets/tabs/api/edhrec/tab_edhrec_main.cpp | 2 +- cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp | 4 ++-- cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp | 2 +- cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp | 2 +- .../tabs/visual_deck_editor/tab_deck_editor_visual.cpp | 2 +- .../tabs/visual_deck_storage/tab_deck_storage_visual.cpp | 4 ++-- .../deck_preview/deck_preview_deck_tags_display_widget.cpp | 4 ++-- .../deck_preview/deck_preview_widget.cpp | 2 +- .../visual_deck_storage/visual_deck_storage_model.cpp | 4 ++-- .../visual_deck_storage/visual_deck_storage_model.h | 4 +++- .../visual_deck_storage_sort_filter_proxy_model.cpp | 2 +- libcockatrice_deck_list/CMakeLists.txt | 4 ++++ .../libcockatrice/deck_list}/deck_file_format.cpp | 0 .../libcockatrice/deck_list}/deck_file_format.h | 0 .../libcockatrice/deck_list}/loaded_deck.cpp | 0 .../libcockatrice/deck_list}/loaded_deck.h | 0 32 files changed, 51 insertions(+), 35 deletions(-) rename {cockatrice/src/interface/deck_loader => libcockatrice_deck_list/libcockatrice/deck_list}/deck_file_format.cpp (100%) rename {cockatrice/src/interface/deck_loader => libcockatrice_deck_list/libcockatrice/deck_list}/deck_file_format.h (100%) rename {cockatrice/src/interface/deck_loader => libcockatrice_deck_list/libcockatrice/deck_list}/loaded_deck.cpp (100%) rename {cockatrice/src/interface/deck_loader => libcockatrice_deck_list/libcockatrice/deck_list}/loaded_deck.h (100%) diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 80e4dd339..2df79e0f6 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -25,9 +25,7 @@ set(cockatrice_SOURCES src/client/settings/shortcut_treeview.cpp src/client/settings/shortcuts_settings.cpp src/interface/deck_loader/card_node_function.cpp - src/interface/deck_loader/deck_file_format.cpp src/interface/deck_loader/deck_loader.cpp - src/interface/deck_loader/loaded_deck.cpp src/interface/widgets/dialogs/dlg_connect.cpp src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp src/interface/widgets/dialogs/dlg_create_game.cpp diff --git a/cockatrice/src/filters/deck_filter_string.cpp b/cockatrice/src/filters/deck_filter_string.cpp index 616311750..f0df897fc 100644 --- a/cockatrice/src/filters/deck_filter_string.cpp +++ b/cockatrice/src/filters/deck_filter_string.cpp @@ -1,6 +1,5 @@ #include "deck_filter_string.h" -#include "../interface/deck_loader/loaded_deck.h" #include "libcockatrice/card/card_localization.h" #include "libcockatrice/card/database/card_database_querier.h" #include "libcockatrice/deck_list/deck_list.h" @@ -15,6 +14,7 @@ #include #include #include +#include #include #include #include diff --git a/cockatrice/src/filters/deck_filter_string.h b/cockatrice/src/filters/deck_filter_string.h index 76b891b59..b27cf87ca 100644 --- a/cockatrice/src/filters/deck_filter_string.h +++ b/cockatrice/src/filters/deck_filter_string.h @@ -11,6 +11,7 @@ #include #include #include +#include struct CardSearchLanguage; struct LoadedDeck; diff --git a/cockatrice/src/game/player/player_actions.cpp b/cockatrice/src/game/player/player_actions.cpp index d7547ef73..994221a98 100644 --- a/cockatrice/src/game/player/player_actions.cpp +++ b/cockatrice/src/game/player/player_actions.cpp @@ -3,7 +3,6 @@ #include "../../client/settings/cache_settings.h" #include "../../game_graphics/board/card_item.h" #include "../../game_graphics/dialogs/dlg_create_token.h" -#include "../../interface/deck_loader/loaded_deck.h" #include "../abstract_game.h" #include "../board/arrow_data.h" #include "../board/counter_state.h" @@ -47,6 +46,7 @@ #include #include #include +#include #include #include #include diff --git a/cockatrice/src/game/player/player_logic.h b/cockatrice/src/game/player/player_logic.h index e6a48af99..d516fede0 100644 --- a/cockatrice/src/game/player/player_logic.h +++ b/cockatrice/src/game/player/player_logic.h @@ -7,6 +7,8 @@ #ifndef PLAYER_H #define PLAYER_H +#include "../board/arrow_data.h" +#include "../board/counter_state.h" #include "../zones/card_zone_logic.h" #include "../zones/hand_zone_logic.h" #include "../zones/pile_zone_logic.h" @@ -22,6 +24,11 @@ #include #include #include +#include +#include +#include +#include +#include #include #include diff --git a/cockatrice/src/game_graphics/deckview/deck_view_container.cpp b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp index eb858cf63..870392aa6 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view_container.cpp +++ b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp @@ -6,9 +6,7 @@ #include "../../game/game_event_handler.h" #include "../../game/game_state.h" #include "../../interface/card_picture_loader/card_picture_loader.h" -#include "../../interface/deck_loader/deck_file_format.h" #include "../../interface/deck_loader/deck_loader.h" -#include "../../interface/deck_loader/loaded_deck.h" #include "../../interface/widgets/dialogs/dlg_load_deck.h" #include "../../interface/widgets/dialogs/dlg_load_deck_from_clipboard.h" #include "../../interface/widgets/dialogs/dlg_load_deck_from_website.h" @@ -33,6 +31,8 @@ #include #include #include +#include +#include #include #include #include diff --git a/cockatrice/src/interface/deck_loader/deck_loader.h b/cockatrice/src/interface/deck_loader/deck_loader.h index 16402b14b..39a12bed4 100644 --- a/cockatrice/src/interface/deck_loader/deck_loader.h +++ b/cockatrice/src/interface/deck_loader/deck_loader.h @@ -7,13 +7,14 @@ #ifndef DECK_LOADER_H #define DECK_LOADER_H -#include "deck_file_format.h" -#include "loaded_deck.h" - #include #include +#include #include #include +#include +#include +#include #include #include diff --git a/cockatrice/src/interface/intents/intent_open_local_deck.cpp b/cockatrice/src/interface/intents/intent_open_local_deck.cpp index 5b673d106..20704a551 100644 --- a/cockatrice/src/interface/intents/intent_open_local_deck.cpp +++ b/cockatrice/src/interface/intents/intent_open_local_deck.cpp @@ -1,14 +1,14 @@ #include "intent_open_local_deck.h" -#include "../deck_loader/deck_file_format.h" #include "../deck_loader/deck_loader.h" -#include "../deck_loader/loaded_deck.h" #include "../widgets/tabs/tab_supervisor.h" #include "intent_wait_for_database_load.h" #include "libcockatrice/card/database/card_database.h" #include "libcockatrice/card/database/card_database_loader.h" #include +#include +#include #include IntentOpenLocalDeck::IntentOpenLocalDeck(TabSupervisor *_tabSupervisor, const QString &_file) diff --git a/cockatrice/src/interface/intents/intent_open_shared_deck.h b/cockatrice/src/interface/intents/intent_open_shared_deck.h index afce2370f..2d9dc5528 100644 --- a/cockatrice/src/interface/intents/intent_open_shared_deck.h +++ b/cockatrice/src/interface/intents/intent_open_shared_deck.h @@ -1,7 +1,6 @@ #ifndef COCKATRICE_INTENT_OPEN_SHARED_DECK_H #define COCKATRICE_INTENT_OPEN_SHARED_DECK_H -#include "../deck_loader/loaded_deck.h" #include "contexts/context_open_deck.h" #include "intent.h" @@ -9,6 +8,7 @@ #include #include #include +#include #include #include diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp index d4715c8d5..a7e231ecf 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp @@ -1,7 +1,6 @@ #include "deck_state_manager.h" #include "../../../client/settings/cache_settings.h" -#include "../../deck_loader/loaded_deck.h" #include "deck_list_model.h" #include "libcockatrice/card/card_info.h" #include "libcockatrice/card/database/card_database_querier.h" @@ -18,6 +17,7 @@ #include #include #include +#include #include #include #include diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h index 98876947b..54c59eb79 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h +++ b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h @@ -1,7 +1,7 @@ #ifndef COCKATRICE_DECK_STATE_MANAGER_H #define COCKATRICE_DECK_STATE_MANAGER_H -#include "../../deck_loader/loaded_deck.h" +#include "deck_list_model.h" #include #include @@ -10,6 +10,7 @@ #include #include #include +#include #include class DeckListHistoryManager; diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp index 31841b9cd..5659b3388 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp @@ -1,7 +1,6 @@ #include "dlg_convert_deck_to_cod_format.h" #include "../../../client/settings/cache_settings.h" -#include "../../deck_loader/deck_file_format.h" #include #include @@ -12,6 +11,7 @@ #include #include #include +#include #include #include diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_clipboard.h b/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_clipboard.h index f3cbb7b62..6cf6021b0 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_clipboard.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_clipboard.h @@ -10,8 +10,10 @@ #include "libcockatrice/deck_list/deck_list.h" +#include #include #include +#include #include class QPlainTextEdit; diff --git a/cockatrice/src/interface/widgets/general/home_widget.cpp b/cockatrice/src/interface/widgets/general/home_widget.cpp index 7555d1f45..5905b2c40 100644 --- a/cockatrice/src/interface/widgets/general/home_widget.cpp +++ b/cockatrice/src/interface/widgets/general/home_widget.cpp @@ -2,9 +2,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../../interface/widgets/tabs/tab_supervisor.h" -#include "../../deck_loader/deck_file_format.h" #include "../../deck_loader/deck_loader.h" -#include "../../deck_loader/loaded_deck.h" #include "../../pixel_map_generator.h" #include "../../theme_config.h" #include "../../theme_manager.h" @@ -57,6 +55,8 @@ #include #include #include +#include +#include #include #include #include diff --git a/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.cpp b/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.cpp index 4044f03db..1e80b2715 100644 --- a/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.cpp +++ b/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.cpp @@ -11,9 +11,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" -#include "../../deck_loader/deck_file_format.h" #include "../../deck_loader/deck_loader.h" -#include "../../deck_loader/loaded_deck.h" #include "../cards/additional_info/deck_color_identity.h" #include "../cards/card_info_frame_widget.h" #include "../client/network/interfaces/deck_stats_interface.h" @@ -55,6 +53,8 @@ #include #include #include +#include +#include #include #include #include diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_display_widget.cpp index 3abf3f9aa..46d2466c6 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_display_widget.cpp @@ -2,7 +2,6 @@ #include "../../../../../../client/settings/cache_settings.h" #include "../../../../../deck_loader/card_node_function.h" -#include "../../../../../deck_loader/loaded_deck.h" #include "../../../../cards/deck_card_zone_display_widget.h" #include "../../../../visual_deck_editor/visual_deck_display_options_widget.h" #include "../../../../visual_deck_editor/visual_deck_editor_widget.h" @@ -30,6 +29,7 @@ #include #include #include +#include #include #include #include diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.cpp index 741a1e85f..c8822930e 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.cpp @@ -1,7 +1,6 @@ #include "tab_edhrec_main.h" #include "../../../../../client/settings/cache_settings.h" -#include "../../../../deck_loader/loaded_deck.h" #include "../../../cards/card_size_widget.h" #include "../../../quick_settings/settings_button_widget.h" #include "../../../utility/completer_utils.h" @@ -28,6 +27,7 @@ #include #include #include +#include #include #include #include diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp index 9db1952f1..3dfdc956b 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp @@ -1,9 +1,7 @@ #include "tab_deck_storage.h" #include "../../../client/settings/cache_settings.h" -#include "../../deck_loader/deck_file_format.h" #include "../../deck_loader/deck_loader.h" -#include "../../deck_loader/loaded_deck.h" #include "../../pixel_map_generator.h" #include "../cards/additional_info/deck_color_identity.h" #include "../deck_share/deck_share_utils.h" @@ -49,7 +47,9 @@ #include #include #include +#include #include +#include #include #include #include diff --git a/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp b/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp index 9c28abd0b..ce37ce440 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp @@ -2,7 +2,6 @@ #include "../../../client/settings/cache_settings.h" #include "../../deck_loader/deck_loader.h" -#include "../../deck_loader/loaded_deck.h" #include "../cards/card_size_widget.h" #include "../general/layout_containers/flow_widget.h" #include "../visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.h" @@ -28,6 +27,7 @@ #include #include #include +#include #include #include #include diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index c614fe7f6..06d60a756 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -5,7 +5,6 @@ #include "../../../game/abstract_game.h" #include "../../../game/game_event_handler.h" #include "../../../game/game_meta_info.h" -#include "../../deck_loader/loaded_deck.h" #include "../../intents/contexts/context_connect_to_server.h" #include "../../intents/contexts/context_join_game.h" #include "../../intents/contexts/context_join_room.h" @@ -63,6 +62,7 @@ #include #include #include +#include #include #include #include diff --git a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp index 964babcae..bf4e15aa6 100644 --- a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp +++ b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp @@ -2,7 +2,6 @@ #include "../../../../client/settings/cache_settings.h" #include "../../../../client/settings/shortcuts_settings.h" -#include "../../../deck_loader/loaded_deck.h" #include "../../deck_editor/deck_editor_card_database_dock_widget.h" #include "../../deck_editor/deck_editor_card_info_dock_widget.h" #include "../../deck_editor/deck_editor_deck_dock_widget.h" @@ -44,6 +43,7 @@ #include #include #include +#include #include #include #include diff --git a/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.cpp b/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.cpp index 74c149b2d..ece9881b4 100644 --- a/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.cpp +++ b/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.cpp @@ -1,9 +1,7 @@ #include "tab_deck_storage_visual.h" #include "../../../../client/settings/cache_settings.h" -#include "../../../deck_loader/deck_file_format.h" #include "../../../deck_loader/deck_loader.h" -#include "../../../deck_loader/loaded_deck.h" #include "../../cards/additional_info/deck_color_identity.h" #include "../../deck_share/deck_share_utils.h" #include "../../deck_share/share_bar_widget.h" @@ -17,7 +15,9 @@ #include #include #include +#include #include +#include #include #include #include diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.cpp index 11c8df54a..cebe92495 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.cpp @@ -1,9 +1,7 @@ #include "deck_preview_deck_tags_display_widget.h" #include "../../../../client/settings/cache_settings.h" -#include "../../../deck_loader/deck_file_format.h" #include "../../../deck_loader/deck_loader.h" -#include "../../../deck_loader/loaded_deck.h" #include "../../general/layout_containers/flow_widget.h" #include "deck_preview_tag_addition_widget.h" #include "deck_preview_tag_dialog.h" @@ -16,6 +14,8 @@ #include #include #include +#include +#include #include #include #include diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp index 220c55788..f2002d4e4 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp @@ -3,7 +3,6 @@ #include "../../../../client/settings/cache_settings.h" #include "../../../../interface/widgets/dialogs/dlg_convert_deck_to_cod_format.h" #include "../../../deck_loader/deck_loader.h" -#include "../../../deck_loader/loaded_deck.h" #include "../../cards/additional_info/color_identity_widget.h" #include "../../cards/deck_preview_card_picture_widget.h" #include "../visual_deck_storage_model.h" @@ -45,6 +44,7 @@ #include #include #include +#include #include #include #include diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp index e84631e52..3611a8c1f 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp @@ -1,8 +1,6 @@ #include "visual_deck_storage_model.h" -#include "../../deck_loader/deck_file_format.h" #include "../../deck_loader/deck_loader.h" -#include "../../deck_loader/loaded_deck.h" #include "../cards/additional_info/deck_color_identity.h" #include "libcockatrice/deck_list/deck_list.h" @@ -21,6 +19,8 @@ #include #include #include +#include +#include #include #include #include diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.h index 57eaebacf..f9af6f318 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.h @@ -13,7 +13,6 @@ #ifndef VISUAL_DECK_STORAGE_MODEL_H #define VISUAL_DECK_STORAGE_MODEL_H -#include "../../deck_loader/loaded_deck.h" #include "libcockatrice/utility/card_ref.h" #include @@ -24,6 +23,9 @@ #include #include #include +#include +#include +#include #include #include diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.cpp index abe3327af..c93e496ec 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.cpp @@ -1,7 +1,6 @@ #include "visual_deck_storage_sort_filter_proxy_model.h" #include "../../../client/settings/cache_settings.h" -#include "../../deck_loader/loaded_deck.h" #include "../../filters/deck_filter_string.h" #include "visual_deck_storage_model.h" @@ -16,6 +15,7 @@ #include #include #include +#include #include VisualDeckStorageSortFilterProxyModel::VisualDeckStorageSortFilterProxyModel(QObject *parent) diff --git a/libcockatrice_deck_list/CMakeLists.txt b/libcockatrice_deck_list/CMakeLists.txt index e61a5702c..224c10942 100644 --- a/libcockatrice_deck_list/CMakeLists.txt +++ b/libcockatrice_deck_list/CMakeLists.txt @@ -7,11 +7,13 @@ set(HEADERS libcockatrice/deck_list/tree/abstract_deck_list_node.h libcockatrice/deck_list/tree/deck_list_card_node.h libcockatrice/deck_list/tree/inner_deck_list_node.h + libcockatrice/deck_list/deck_file_format.h libcockatrice/deck_list/deck_list.h libcockatrice/deck_list/deck_list_history_manager.h libcockatrice/deck_list/deck_list_node_tree.h libcockatrice/deck_list/deck_list_memento.h libcockatrice/deck_list/deck_list_plain_text_parser.h + libcockatrice/deck_list/loaded_deck.h libcockatrice/deck_list/playmat_resolver.h libcockatrice/deck_list/sideboard_plan.h ) @@ -25,10 +27,12 @@ add_library( libcockatrice/deck_list/tree/abstract_deck_list_node.cpp libcockatrice/deck_list/tree/deck_list_card_node.cpp libcockatrice/deck_list/tree/inner_deck_list_node.cpp + libcockatrice/deck_list/deck_file_format.cpp libcockatrice/deck_list/deck_list.cpp libcockatrice/deck_list/deck_list_history_manager.cpp libcockatrice/deck_list/deck_list_node_tree.cpp libcockatrice/deck_list/deck_list_plain_text_parser.cpp + libcockatrice/deck_list/loaded_deck.cpp libcockatrice/deck_list/playmat_resolver.cpp libcockatrice/deck_list/sideboard_plan.cpp ) diff --git a/cockatrice/src/interface/deck_loader/deck_file_format.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/deck_file_format.cpp similarity index 100% rename from cockatrice/src/interface/deck_loader/deck_file_format.cpp rename to libcockatrice_deck_list/libcockatrice/deck_list/deck_file_format.cpp diff --git a/cockatrice/src/interface/deck_loader/deck_file_format.h b/libcockatrice_deck_list/libcockatrice/deck_list/deck_file_format.h similarity index 100% rename from cockatrice/src/interface/deck_loader/deck_file_format.h rename to libcockatrice_deck_list/libcockatrice/deck_list/deck_file_format.h diff --git a/cockatrice/src/interface/deck_loader/loaded_deck.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/loaded_deck.cpp similarity index 100% rename from cockatrice/src/interface/deck_loader/loaded_deck.cpp rename to libcockatrice_deck_list/libcockatrice/deck_list/loaded_deck.cpp diff --git a/cockatrice/src/interface/deck_loader/loaded_deck.h b/libcockatrice_deck_list/libcockatrice/deck_list/loaded_deck.h similarity index 100% rename from cockatrice/src/interface/deck_loader/loaded_deck.h rename to libcockatrice_deck_list/libcockatrice/deck_list/loaded_deck.h From c9853734ad9b3333e8e6b2cc1ede6471c7a62de7 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:44:12 +0100 Subject: [PATCH 064/108] [Refactor] Move card identity and card data into CardState (#7425) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- cockatrice/src/game/board/card_state.cpp | 55 +++++++++++- cockatrice/src/game/board/card_state.h | 72 ++++++++++++++- .../board/abstract_card_item.cpp | 89 +++++++------------ .../game_graphics/board/abstract_card_item.h | 56 ++++++------ .../src/game_graphics/board/card_item.cpp | 15 ++-- .../src/game_graphics/board/card_item.h | 13 +-- 6 files changed, 200 insertions(+), 100 deletions(-) diff --git a/cockatrice/src/game/board/card_state.cpp b/cockatrice/src/game/board/card_state.cpp index 3f879a849..2bbc8429a 100644 --- a/cockatrice/src/game/board/card_state.cpp +++ b/cockatrice/src/game/board/card_state.cpp @@ -1,6 +1,9 @@ #include "card_state.h" #include +#include +#include +#include void CardState::resetState(bool keepAnnotations) { @@ -13,6 +16,56 @@ void CardState::resetState(bool keepAnnotations) attachedTo = nullptr; } +void CardState::setCardRef(const CardRef &_cardRef) +{ + if (cardRef == _cardRef) { + return; + } + + const CardRef oldCardRef = cardRef; + if (exactCard) { + disconnect(exactCard.getCardPtr().data(), nullptr, this, nullptr); + } + cardRef = _cardRef; + + emit cardRefChanged(oldCardRef, cardRef); + refreshCardInfo(); +} + +void CardState::refreshCardInfo() +{ + exactCard = CardDatabaseManager::query()->getCard(cardRef); + + if (!exactCard && !cardRef.name.isEmpty()) { + CardInfo::UiAttributes attributes = {.tableRow = -1}; + auto info = CardInfo::newInstance(cardRef.name, "", true, {}, {}, {}, {}, attributes); + exactCard = ExactCard(info); + } + if (exactCard) { + connect(exactCard.getCardPtr().data(), &CardInfo::pixmapUpdated, this, &CardState::cardPixmapUpdated); + } + + emit cardInfoChanged(); +} + +void CardState::setTapped(bool _tapped, bool canAnimate) +{ + if (tapped == _tapped) { + return; + } + + tapped = _tapped; + emit tappedChanged(tapped, canAnimate); + emit stateChanged(); +} + +void CardState::setFaceDown(bool _facedown) +{ + facedown = _facedown; + emit facedownChanged(facedown); + emit stateChanged(); +} + void CardState::setZone(CardZoneLogic *_zone) { if (zone == _zone) { @@ -110,4 +163,4 @@ void CardState::setAttachedTo(CardItem *_attachedTo) attachedTo = _attachedTo; emit attachedToChanged(_attachedTo); emit stateChanged(); -} \ No newline at end of file +} diff --git a/cockatrice/src/game/board/card_state.h b/cockatrice/src/game/board/card_state.h index ca7aaf927..af85fea10 100644 --- a/cockatrice/src/game/board/card_state.h +++ b/cockatrice/src/game/board/card_state.h @@ -3,7 +3,10 @@ #include #include +#include #include +#include +#include #include class CardZoneLogic; @@ -14,6 +17,13 @@ class CardState : public QObject Q_OBJECT private: + int id = -1; + CardRef cardRef; + ExactCard exactCard; + bool tapped = false; + bool facedown = false; + QPoint gridPoint; + bool attacking = false; QMap counters; QString annotation; @@ -27,6 +37,12 @@ private: signals: void stateChanged(); + void cardRefChanged(const CardRef &oldCardRef, const CardRef &newCardRef); + void cardInfoChanged(); + void cardPixmapUpdated(); + void tappedChanged(bool newTapped, bool canAnimate); + void facedownChanged(bool newFaceDown); + void attackingChanged(bool newValue); void countersChanged(const QMap &newCounters); void annotationChanged(const QString &newAnnotation); @@ -37,12 +53,66 @@ signals: void zoneChanged(CardState *changedCard, CardZoneLogic *newZone); public: - explicit CardState(QObject *parent, CardZoneLogic *_zone) : QObject(parent), zone(_zone) + explicit CardState(QObject *parent, CardZoneLogic *_zone = nullptr, const CardRef &_cardRef = {}, int _id = -1) + : QObject(parent), id(_id), cardRef(_cardRef), zone(_zone) { } void resetState(bool keepAnnotations); + int getId() const + { + return id; + } + + void setId(int _id) + { + id = _id; + } + + const CardRef &getCardRef() const + { + return cardRef; + } + + void setCardRef(const CardRef &_cardRef); + + ExactCard getCard() const + { + return exactCard; + } + + const CardInfo &getCardInfo() const + { + return exactCard.getInfo(); + } + + void refreshCardInfo(); + + bool getTapped() const + { + return tapped; + } + + void setTapped(bool _tapped, bool canAnimate = false); + + bool getFaceDown() const + { + return facedown; + } + + void setFaceDown(bool _facedown); + + const QPoint &getGridPoint() const + { + return gridPoint; + } + + void setGridPoint(const QPoint &_gridPoint) + { + gridPoint = _gridPoint; + } + CardZoneLogic *getZone() const { return zone; diff --git a/cockatrice/src/game_graphics/board/abstract_card_item.cpp b/cockatrice/src/game_graphics/board/abstract_card_item.cpp index cc121cfb2..3bc0e1156 100644 --- a/cockatrice/src/game_graphics/board/abstract_card_item.cpp +++ b/cockatrice/src/game_graphics/board/abstract_card_item.cpp @@ -34,7 +34,6 @@ #include #include #include -#include #include #include #include @@ -43,13 +42,19 @@ class QSizeF; AbstractCardItem::AbstractCardItem(QGraphicsItem *parent, const CardRef &cardRef, PlayerLogic *_owner, int _id) - : ArrowTarget(_owner, parent), id(_id), cardRef(cardRef), tapped(false), facedown(false), tapAngle(0), - bgColor(Qt::transparent), isHovered(false), realZValue(0), pendingSelectionCollapse(false) + : ArrowTarget(_owner, parent), state(new CardState(this, nullptr, cardRef, _id)), tapAngle(0), + bgColor(Qt::transparent), isHovered(false), realZValue(0) { setCursor(Qt::OpenHandCursor); setFlag(ItemIsSelectable); setCacheMode(DeviceCoordinateCache); + connect(state, &CardState::cardPixmapUpdated, this, &AbstractCardItem::pixmapUpdated); + connect(state, &CardState::cardInfoChanged, this, &AbstractCardItem::onCardInfoChanged); + connect(state, &CardState::cardRefChanged, this, &AbstractCardItem::onCardRefChanged); + connect(state, &CardState::tappedChanged, this, &AbstractCardItem::onTappedChanged); + connect(state, &CardState::facedownChanged, this, [this] { update(); }); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::displayCardNamesChanged, this, [this] { update(); }); connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this, @@ -67,7 +72,7 @@ AbstractCardItem::AbstractCardItem(QGraphicsItem *parent, const CardRef &cardRef AbstractCardItem::~AbstractCardItem() { - emit deleteCardInfoPopup(cardRef.name); + emit deleteCardInfoPopup(state->getCardRef().name); } QRectF AbstractCardItem::boundingRect() const @@ -90,30 +95,29 @@ void AbstractCardItem::pixmapUpdated() emit sigPixmapUpdated(); } -void AbstractCardItem::refreshCardInfo() +void AbstractCardItem::onCardInfoChanged() { - exactCard = CardDatabaseManager::query()->getCard(cardRef); - - if (!exactCard && !cardRef.name.isEmpty()) { - CardInfo::UiAttributes attributes = {.tableRow = -1}; - auto info = CardInfo::newInstance(cardRef.name, "", true, {}, {}, {}, {}, attributes); - exactCard = ExactCard(info); - } - if (exactCard) { - connect(exactCard.getCardPtr().data(), &CardInfo::pixmapUpdated, this, &AbstractCardItem::pixmapUpdated); - } - cacheBgColor(); update(); } +void AbstractCardItem::onCardRefChanged(const CardRef &oldCardRef, const CardRef & /*newCardRef*/) +{ + emit deleteCardInfoPopup(oldCardRef.name); +} + +void AbstractCardItem::refreshCardInfo() +{ + state->refreshCardInfo(); +} + /** * Convenience method to get the CardInfo of the exactCard * @return A const reference to the CardInfo, or an empty CardInfo if card was null */ const CardInfo &AbstractCardItem::getCardInfo() const { - return exactCard.getInfo(); + return state->getCardInfo(); } void AbstractCardItem::setRealZValue(qreal _zValue) @@ -158,13 +162,13 @@ void AbstractCardItem::paintPicture(QPainter *painter, const QSizeF &translatedS QPixmap translatedPixmap; bool paintImage = true; - if (facedown || cardRef.name.isEmpty()) { + if (state->getFaceDown() || state->getCardRef().name.isEmpty()) { // never reveal card color, always paint the card back CardPictureLoader::getCardBackPixmap(translatedPixmap, translatedSize.toSize()); } else { // don't even spend time trying to load the picture if our size is too small if (translatedSize.width() > 10) { - CardPictureLoader::getPixmap(translatedPixmap, exactCard, translatedSize.toSize()); + CardPictureLoader::getPixmap(translatedPixmap, state->getCard(), translatedSize.toSize()); if (translatedPixmap.isNull()) { paintImage = false; } @@ -185,19 +189,20 @@ void AbstractCardItem::paintPicture(QPainter *painter, const QSizeF &translatedS painter->drawPath(shape()); } - if (translatedPixmap.isNull() || SettingsCache::instance().cardsDisplay().getDisplayCardNames() || facedown) { + if (translatedPixmap.isNull() || SettingsCache::instance().cardsDisplay().getDisplayCardNames() || + state->getFaceDown()) { painter->save(); transformPainter(painter, translatedSize, angle); painter->setPen(Qt::white); painter->setBackground(Qt::black); painter->setBackgroundMode(Qt::OpaqueMode); QString nameStr; - if (facedown) { - nameStr = "# " + QString::number(id); + if (state->getFaceDown()) { + nameStr = "# " + QString::number(state->getId()); } else { QString prefix = ""; if (SettingsCache::instance().debug().getShowCardId()) { - prefix = "#" + QString::number(id) + " "; + prefix = "#" + QString::number(state->getId()) + " "; } nameStr = prefix + CardLocalization::displayName(getCardInfo()); } @@ -235,21 +240,6 @@ void AbstractCardItem::paint(QPainter *painter, const QStyleOptionGraphicsItem * painter->restore(); } -void AbstractCardItem::setCardRef(const CardRef &_cardRef) -{ - if (cardRef == _cardRef) { - return; - } - - emit deleteCardInfoPopup(cardRef.name); - if (exactCard) { - disconnect(exactCard.getCardPtr().data(), nullptr, this, nullptr); - } - cardRef = _cardRef; - - refreshCardInfo(); -} - void AbstractCardItem::setHovered(bool _hovered) { if (isHovered == _hovered) { @@ -284,7 +274,7 @@ void AbstractCardItem::cacheBgColor() { QChar colorChar; if (color.isEmpty()) { - colorChar = exactCard.getInfo().getColorChar(); + colorChar = state->getCardInfo().getColorChar(); } else { colorChar = color.at(0); } @@ -314,17 +304,12 @@ void AbstractCardItem::cacheBgColor() } } -void AbstractCardItem::setTapped(bool _tapped, bool canAnimate) +void AbstractCardItem::onTappedChanged(bool newTapped, bool canAnimate) { - if (tapped == _tapped) { - return; - } - - tapped = _tapped; if (SettingsCache::instance().cardsDisplay().getTapAnimation() && canAnimate) { static_cast(scene())->registerAnimationItem(this); } else { - tapAngle = tapped ? 90 : 0; + tapAngle = newTapped ? 90 : 0; setTransform(QTransform() .translate(CardDimensions::WIDTH_HALF_F, CardDimensions::HEIGHT_HALF_F) .rotate(tapAngle) @@ -338,12 +323,6 @@ bool AbstractCardItem::animationEvent() return false; } -void AbstractCardItem::setFaceDown(bool _facedown) -{ - facedown = _facedown; - update(); -} - void AbstractCardItem::mousePressEvent(QGraphicsSceneMouseEvent *event) { cancelPendingSelectionCollapse(); @@ -357,7 +336,7 @@ void AbstractCardItem::mousePressEvent(QGraphicsSceneMouseEvent *event) } } if ((event->modifiers() & Qt::AltModifier) && event->button() == Qt::LeftButton) { - emit cardShiftClicked(cardRef.name); + emit cardShiftClicked(state->getCardRef().name); } else if ((event->modifiers() & Qt::ControlModifier)) { setSelected(!isSelected()); } else if (!isSelected()) { @@ -371,7 +350,7 @@ void AbstractCardItem::mousePressEvent(QGraphicsSceneMouseEvent *event) if (event->button() == Qt::LeftButton) { setCursor(Qt::ClosedHandCursor); } else if (event->button() == Qt::MiddleButton) { - emit showCardInfoPopup(event->screenPos(), cardRef); + emit showCardInfoPopup(event->screenPos(), state->getCardRef()); } event->accept(); } @@ -405,7 +384,7 @@ void AbstractCardItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) } if (event->button() == Qt::MiddleButton) { - emit deleteCardInfoPopup(cardRef.name); + emit deleteCardInfoPopup(state->getCardRef().name); } // This function ensures the parent function doesn't mess around with our selection. diff --git a/cockatrice/src/game_graphics/board/abstract_card_item.h b/cockatrice/src/game_graphics/board/abstract_card_item.h index e2b150ce8..709265a2a 100644 --- a/cockatrice/src/game_graphics/board/abstract_card_item.h +++ b/cockatrice/src/game_graphics/board/abstract_card_item.h @@ -7,19 +7,11 @@ #ifndef ABSTRACTCARDITEM_H #define ABSTRACTCARDITEM_H +#include "../../game/board/card_state.h" #include "../animated_item.h" #include "arrow_target.h" #include "graphics_item_type.h" -#include -#include -#include -#include -#include -#include -#include -#include - class PlayerLogic; class CardInfo; class QPainter; @@ -29,11 +21,7 @@ class AbstractCardItem : public ArrowTarget, public IAnimatedItem { Q_OBJECT protected: - ExactCard exactCard; - int id; - CardRef cardRef; - bool tapped; - bool facedown; + CardState *state; int tapAngle; QString color; QColor bgColor; @@ -45,6 +33,9 @@ private: int selectionCollapseGeneration = 0; ///< Guards the deferred collapse timer against stale firings private slots: void pixmapUpdated(); + void onCardInfoChanged(); + void onCardRefChanged(const CardRef &oldCardRef, const CardRef &newCardRef); + void onTappedChanged(bool newTapped, bool canAnimate); public slots: void refreshCardInfo(); @@ -80,31 +71,38 @@ public: QSizeF getTranslatedSize(QPainter *painter) const; void paintPicture(QPainter *painter, const QSizeF &translatedSize, int angle); void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; + [[nodiscard]] CardState *getState() const + { + return state; + } ExactCard getCard() const { - return exactCard; + return state->getCard(); } const CardInfo &getCardInfo() const; int getId() const { - return id; + return state->getId(); } void setId(int _id) { - id = _id; + state->setId(_id); } QString getName() const { - return cardRef.name; + return state->getCardRef().name; } QString getProviderId() const { - return cardRef.providerId; + return state->getCardRef().providerId; + } + void setCardRef(const CardRef &_cardRef) + { + state->setCardRef(_cardRef); } - void setCardRef(const CardRef &_cardRef); CardRef getCardRef() const { - return cardRef; + return state->getCardRef(); } qreal getRealZValue() const { @@ -123,20 +121,26 @@ public: void setColor(const QString &_color); bool getTapped() const { - return tapped; + return state->getTapped(); + } + void setTapped(bool _tapped, bool canAnimate = false) + { + state->setTapped(_tapped, canAnimate); } - void setTapped(bool _tapped, bool canAnimate = false); bool getFaceDown() const { - return facedown; + return state->getFaceDown(); + } + void setFaceDown(bool _facedown) + { + state->setFaceDown(_facedown); } - void setFaceDown(bool _facedown); void processHoverEvent(); /** @brief Drop a pending collapse and disarm any deferred collapse timer. */ void cancelPendingSelectionCollapse(); void deleteCardInfoPopup() { - emit deleteCardInfoPopup(cardRef.name); + emit deleteCardInfoPopup(state->getCardRef().name); } /** @brief Default: no per-tick animation. Subclasses override to animate. */ diff --git a/cockatrice/src/game_graphics/board/card_item.cpp b/cockatrice/src/game_graphics/board/card_item.cpp index eae7cee18..6c91773fb 100644 --- a/cockatrice/src/game_graphics/board/card_item.cpp +++ b/cockatrice/src/game_graphics/board/card_item.cpp @@ -53,8 +53,9 @@ CardItem::CardItem(PlayerLogic *_owner, const CardRef &cardRef, int _cardid, CardZoneLogic *_zone) - : AbstractCardItem(parent, cardRef, _owner, _cardid), state(new CardState(this, _zone)), dragItem(nullptr) + : AbstractCardItem(parent, cardRef, _owner, _cardid), dragItem(nullptr) { + state->setZone(_zone); owner->addCard(this); connect(&SettingsCache::instance().cardCounters(), &CardCounterSettings::colorChanged, this, [this](int counterId) { @@ -128,7 +129,7 @@ void CardItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, painter->save(); transformPainter(painter, translatedSize, tapAngle); - if (!getFaceDown() && state->getPT() == exactCard.getInfo().getPowTough()) { + if (!getFaceDown() && state->getPT() == state->getCardInfo().getPowTough()) { painter->setPen(Qt::white); } else { painter->setPen(QColor(255, 150, 0)); // dark orange @@ -214,7 +215,7 @@ void CardItem::setAttachedTo(CardItem *_attachedTo) state->getAttachedTo()->removeAttachedCard(this); } - gridPoint.setX(-1); + state->setGridPoint(QPoint(-1, state->getGridPoint().y())); state->setAttachedTo(_attachedTo); if (state->getAttachedTo() != nullptr) { // If the zone is being torn down, it might already be null by the time a card tries to un-attach all its @@ -391,7 +392,7 @@ void CardItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event) // Use the buttonDownPos to align the hot spot with the position when // the user originally clicked - createDragItem(id, event->buttonDownPos(Qt::LeftButton), event->scenePos(), forceFaceDown); + createDragItem(state->getId(), event->buttonDownPos(Qt::LeftButton), event->scenePos(), forceFaceDown); dragItem->grabMouse(); int childIndex = 0; @@ -538,16 +539,16 @@ bool CardItem::animationEvent() { int rotation = ROTATION_DEGREES_PER_FRAME; bool animationIncomplete = true; - if (!tapped) { + if (!state->getTapped()) { rotation *= -1; } tapAngle += rotation; - if (tapped && (tapAngle > 90)) { + if (state->getTapped() && (tapAngle > 90)) { tapAngle = 90; animationIncomplete = false; } - if (!tapped && (tapAngle < 0)) { + if (!state->getTapped() && (tapAngle < 0)) { tapAngle = 0; animationIncomplete = false; } diff --git a/cockatrice/src/game_graphics/board/card_item.h b/cockatrice/src/game_graphics/board/card_item.h index 0737d7f9c..841061bef 100644 --- a/cockatrice/src/game_graphics/board/card_item.h +++ b/cockatrice/src/game_graphics/board/card_item.h @@ -33,9 +33,6 @@ class CardItem : public AbstractCardItem { Q_OBJECT private: - CardState *state; - - QPoint gridPoint; CardDragItem *dragItem; QList attachedCards; @@ -60,10 +57,6 @@ public: CardZoneLogic *_zone = nullptr); void retranslateUi(); - [[nodiscard]] CardState *getState() const - { - return state; - } [[nodiscard]] CardZoneLogic *getZone() const { return state->getZone(); @@ -72,15 +65,15 @@ public: void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; [[nodiscard]] QPoint getGridPoint() const { - return gridPoint; + return state->getGridPoint(); } void setGridPoint(const QPoint &_gridPoint) { - gridPoint = _gridPoint; + state->setGridPoint(_gridPoint); } [[nodiscard]] QPoint getGridPos() const { - return gridPoint; + return state->getGridPoint(); } [[nodiscard]] PlayerLogic *getOwner() const { From 4879ddbd0f55f5cdd140c7106586a8d9e4211a7d Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:44:12 +0100 Subject: [PATCH 065/108] [Refactor] Move card selection actions to GameScene and p/t parsing to CardState (#7426) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- cockatrice/src/game/board/card_state.cpp | 31 ++++++++++ cockatrice/src/game/board/card_state.h | 21 +++++++ cockatrice/src/game/player/player_actions.cpp | 60 ++----------------- cockatrice/src/game/player/player_actions.h | 4 -- .../src/game_graphics/board/card_item.cpp | 31 ---------- .../src/game_graphics/board/card_item.h | 20 ------- cockatrice/src/game_graphics/game_scene.cpp | 51 ++++++++++++++++ cockatrice/src/game_graphics/game_scene.h | 9 +++ .../game_graphics/player/menu/card_menu.cpp | 6 +- .../src/game_graphics/tally/stats_tally.cpp | 4 +- 10 files changed, 121 insertions(+), 116 deletions(-) diff --git a/cockatrice/src/game/board/card_state.cpp b/cockatrice/src/game/board/card_state.cpp index 2bbc8429a..1f12253a4 100644 --- a/cockatrice/src/game/board/card_state.cpp +++ b/cockatrice/src/game/board/card_state.cpp @@ -66,6 +66,37 @@ void CardState::setFaceDown(bool _facedown) emit stateChanged(); } +QVariantList CardState::parsePT(const QString &pt) +{ + QVariantList ptList = QVariantList(); + if (!pt.isEmpty()) { + int sep = pt.indexOf('/'); + if (sep == 0) { + ptList.append(QVariant(pt.mid(1))); // cut off starting '/' and take full string + } else { + int start = 0; + for (;;) { + QString item = pt.mid(start, sep - start); + if (item.isEmpty()) { + ptList.append(QVariant(QString())); + } else if (item[0] == '+') { + ptList.append(QVariant(item.mid(1).toInt())); // add as int + } else if (item[0] == '-') { + ptList.append(QVariant(item.toInt())); // add as int + } else { + ptList.append(QVariant(item)); // add as qstring + } + if (sep == -1) { + break; + } + start = sep + 1; + sep = pt.indexOf('/', start); + } + } + } + return ptList; +} + void CardState::setZone(CardZoneLogic *_zone) { if (zone == _zone) { diff --git a/cockatrice/src/game/board/card_state.h b/cockatrice/src/game/board/card_state.h index af85fea10..45f789aea 100644 --- a/cockatrice/src/game/board/card_state.h +++ b/cockatrice/src/game/board/card_state.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -151,6 +152,26 @@ public: void setPT(const QString &_pt); + /** + * @brief Parses a string representing a p/t in order to extract the values from it. + * + * If the string contains '/', the string will be split at the '/' and each side will be parsed separately, + * which means the result list will have two elements. + * + * If '/' is not found, then the entire string is parsed together, which means the result list will + * have a single element. + * + * If either side of the split is empty, there will also only be a single element in the result list. + * + * This function will attempt to parse each substring as an int first, handling plus and minus prefixes. + * If successful, it will put the parsed value into the QVariant as an int. + * If failed, it will just put the substring into the QVariant as a QString. + * + * @param pt The p/t string + * @return A QVariantList that can contain one or two elements, where each QVariant can be either int or QString + */ + static QVariantList parsePT(const QString &pt); + bool getDoesntUntap() const { return doesntUntap; diff --git a/cockatrice/src/game/player/player_actions.cpp b/cockatrice/src/game/player/player_actions.cpp index 994221a98..1a91d28a5 100644 --- a/cockatrice/src/game/player/player_actions.cpp +++ b/cockatrice/src/game/player/player_actions.cpp @@ -795,58 +795,6 @@ void PlayerActions::actMoveBottomCardToTop() sendGameCommand(cmd); } -/** - * Selects all cards in the given zone. - * - * @param zone The zone to select from - * @param filter A predicate to filter which cards are selected. Defaults to always returning true. - */ -static void selectCardsInZone( - const CardZoneLogic *zone, - std::function filter = [](const CardItem *) { return true; }) -{ - if (!zone) { - return; - } - - for (auto &cardItem : zone->getCards()) { - if (cardItem && filter(cardItem)) { - cardItem->setSelected(true); - } - } -} - -void PlayerActions::actSelectAll(const CardItem *card) -{ - if (!card) { - return; - } - - selectCardsInZone(card->getZone()); -} - -void PlayerActions::actSelectRow(const CardItem *card) -{ - if (!card) { - return; - } - - auto isSameRow = [card](const CardItem *cardItem) { - return qAbs(card->scenePos().y() - cardItem->scenePos().y()) < 50; - }; - selectCardsInZone(card->getZone(), isSameRow); -} - -void PlayerActions::actSelectColumn(const CardItem *card) -{ - if (!card) { - return; - } - - auto isSameColumn = [card](const CardItem *cardItem) { return cardItem->x() == card->x(); }; - selectCardsInZone(card->getZone(), isSameColumn); -} - void PlayerActions::actDrawBottomCard() { if (player->getDeckZone()->getCards().empty()) { @@ -1356,7 +1304,7 @@ void PlayerActions::actIncPT(QList selectedCards, int deltaP, int de QList commandList; for (auto card : selectedCards) { QString pt = card->getPT(); - const auto ptList = CardItem::parsePT(pt); + const auto ptList = CardState::parsePT(pt); QString newpt; if (ptList.isEmpty()) { newpt = QString::number(deltaP) + (deltaT ? "/" + QString::number(deltaT) : ""); @@ -1432,7 +1380,7 @@ void PlayerActions::actSetPT(QList selectedCards, const QString &pt) { int playerid = player->getPlayerInfo()->getId(); - const auto ptList = CardItem::parsePT(pt); + const auto ptList = CardState::parsePT(pt); bool empty = ptList.isEmpty(); QList commandList; @@ -1440,7 +1388,7 @@ void PlayerActions::actSetPT(QList selectedCards, const QString &pt) auto *cmd = new Command_SetCardAttr; QString newpt = QString(); if (!empty) { - const auto oldpt = CardItem::parsePT(card->getPT()); + const auto oldpt = CardState::parsePT(card->getPT()); int ptIter = 0; for (const auto &_item : ptList) { if (_item.typeId() == QMetaType::Type::Int) { @@ -1537,7 +1485,7 @@ void PlayerActions::actReduceLifeByPower(QList selectedCards) // calculate total power; int total = 0; for (auto card : selectedCards) { - QVariantList parsed = CardItem::parsePT(card->getPT()); + QVariantList parsed = CardState::parsePT(card->getPT()); if (!parsed.isEmpty()) { int power = parsed.first().toInt(); // toInt will default to 0 if it's not an int total += qMax(power, 0); diff --git a/cockatrice/src/game/player/player_actions.h b/cockatrice/src/game/player/player_actions.h index 6f51650b5..daee72d2f 100644 --- a/cockatrice/src/game/player/player_actions.h +++ b/cockatrice/src/game/player/player_actions.h @@ -211,10 +211,6 @@ public slots: void actRequestMoveBottomCardsToDialog(const QString &targetZone, const QString &zoneDisplayName, bool faceDown); void moveBottomCardsTo(int number, const QString &targetZone, bool faceDown); - void actSelectAll(const CardItem *card); - void actSelectRow(const CardItem *card); - void actSelectColumn(const CardItem *card); - void actViewLibrary(); void actViewHand(); void actRequestViewTopCardsDialog(); diff --git a/cockatrice/src/game_graphics/board/card_item.cpp b/cockatrice/src/game_graphics/board/card_item.cpp index 6c91773fb..cbce017d9 100644 --- a/cockatrice/src/game_graphics/board/card_item.cpp +++ b/cockatrice/src/game_graphics/board/card_item.cpp @@ -440,37 +440,6 @@ void CardItem::playCard(bool faceDown) } } -QVariantList CardItem::parsePT(const QString &pt) -{ - QVariantList ptList = QVariantList(); - if (!pt.isEmpty()) { - int sep = pt.indexOf('/'); - if (sep == 0) { - ptList.append(QVariant(pt.mid(1))); // cut off starting '/' and take full string - } else { - int start = 0; - for (;;) { - QString item = pt.mid(start, sep - start); - if (item.isEmpty()) { - ptList.append(QVariant(QString())); - } else if (item[0] == '+') { - ptList.append(QVariant(item.mid(1).toInt())); // add as int - } else if (item[0] == '-') { - ptList.append(QVariant(item.toInt())); // add as int - } else { - ptList.append(QVariant(item)); // add as qstring - } - if (sep == -1) { - break; - } - start = sep + 1; - sep = pt.indexOf('/', start); - } - } - } - return ptList; -} - /** * @brief returns true if the zone is a unwritable reveal zone view (eg a card reveal window). Will return false if zone * is nullptr. diff --git a/cockatrice/src/game_graphics/board/card_item.h b/cockatrice/src/game_graphics/board/card_item.h index 841061bef..0932f578b 100644 --- a/cockatrice/src/game_graphics/board/card_item.h +++ b/cockatrice/src/game_graphics/board/card_item.h @@ -143,26 +143,6 @@ public: void drawAttachArrow(); void playCard(bool faceDown); - /** - * @brief Parses a string representing a p/t in order to extract the values from it. - * - * If the string contains '/', the string will be split at the '/' and each side will be parsed separately, - * which means the result list will have two elements. - * - * If '/' is not found, then the entire string is parsed together, which means the result list will - * have a single element. - * - * If either side of the split is empty, there will also only be a single element in the result list. - * - * This function will attempt to parse each substring as an int first, handling plus and minus prefixes. - * If successful, it will put the parsed value into the QVariant as an int. - * If failed, it will just put the substring into the QVariant as a QString. - * - * @param pt The p/t string - * @return A QVariantList that can contain one or two elements, where each QVariant can be either int or QString - */ - static QVariantList parsePT(const QString &pt); - protected: void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override; void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override; diff --git a/cockatrice/src/game_graphics/game_scene.cpp b/cockatrice/src/game_graphics/game_scene.cpp index f70ab7e12..31188293d 100644 --- a/cockatrice/src/game_graphics/game_scene.cpp +++ b/cockatrice/src/game_graphics/game_scene.cpp @@ -40,6 +40,7 @@ #include #include #include +#include #include #include #include @@ -124,6 +125,56 @@ QList GameScene::selectedCards() const return selectedCards; } +/** + * @brief Selects every card of @p zone for which @p filter holds. + * @param filter A predicate to filter which cards are selected. + */ +static void selectCardsInZone( + const CardZoneLogic *zone, + std::function filter = [](const CardItem *) { return true; }) +{ + if (!zone) { + return; + } + + for (auto &cardItem : zone->getCards()) { + if (cardItem && filter(cardItem)) { + cardItem->setSelected(true); + } + } +} + +void GameScene::selectAllInZone(const CardItem *card) +{ + if (!card) { + return; + } + + selectCardsInZone(card->getZone()); +} + +void GameScene::selectSameRow(const CardItem *card) +{ + if (!card) { + return; + } + + auto isSameRow = [card](const CardItem *cardItem) { + return qAbs(card->scenePos().y() - cardItem->scenePos().y()) < 50; + }; + selectCardsInZone(card->getZone(), isSameRow); +} + +void GameScene::selectSameColumn(const CardItem *card) +{ + if (!card) { + return; + } + + auto isSameColumn = [card](const CardItem *cardItem) { return cardItem->x() == card->x(); }; + selectCardsInZone(card->getZone(), isSameColumn); +} + CardItem *GameScene::getActiveCard() const { return activeCard; diff --git a/cockatrice/src/game_graphics/game_scene.h b/cockatrice/src/game_graphics/game_scene.h index e7eb7778b..f0e94e14e 100644 --- a/cockatrice/src/game_graphics/game_scene.h +++ b/cockatrice/src/game_graphics/game_scene.h @@ -113,6 +113,15 @@ public: /** @brief Gets all selected CardItems. */ QList selectedCards() const; + /** @brief Selects every card sharing the zone of @p card. */ + void selectAllInZone(const CardItem *card); + + /** @brief Selects the cards laid out in the same visual row as @p card. */ + void selectSameRow(const CardItem *card); + + /** @brief Selects the cards laid out in the same visual column as @p card. */ + void selectSameColumn(const CardItem *card); + /** @brief Card the currently open context menu targets, or null. */ CardItem *getActiveCard() const; diff --git a/cockatrice/src/game_graphics/player/menu/card_menu.cpp b/cockatrice/src/game_graphics/player/menu/card_menu.cpp index d384f5521..2174be3d8 100644 --- a/cockatrice/src/game_graphics/player/menu/card_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/card_menu.cpp @@ -117,9 +117,9 @@ CardMenu::CardMenu(PlayerGraphicsItem *_player, CardItem *_card, bool _shortcuts connect(aAttach, &QAction::triggered, this, &CardMenu::attachCard); connect(aDrawArrow, &QAction::triggered, this, &CardMenu::drawArrow); - connect(aSelectAll, &QAction::triggered, this, [this, actions]() { actions->actSelectAll(card); }); - connect(aSelectRow, &QAction::triggered, this, [this, actions]() { actions->actSelectRow(card); }); - connect(aSelectColumn, &QAction::triggered, this, [this, actions]() { actions->actSelectColumn(card); }); + connect(aSelectAll, &QAction::triggered, this, [this, gameScene]() { gameScene->selectAllInZone(card); }); + connect(aSelectRow, &QAction::triggered, this, [this, gameScene]() { gameScene->selectSameRow(card); }); + connect(aSelectColumn, &QAction::triggered, this, [this, gameScene]() { gameScene->selectSameColumn(card); }); aRevealToAll = new QAction(this); diff --git a/cockatrice/src/game_graphics/tally/stats_tally.cpp b/cockatrice/src/game_graphics/tally/stats_tally.cpp index 28e280c02..2bce9fdfd 100644 --- a/cockatrice/src/game_graphics/tally/stats_tally.cpp +++ b/cockatrice/src/game_graphics/tally/stats_tally.cpp @@ -16,7 +16,7 @@ static int sumPowers(const QList &cards) // calculate total power; int total = 0; for (auto card : cards) { - QVariantList parsed = CardItem::parsePT(card->getPT()); + QVariantList parsed = CardState::parsePT(card->getPT()); if (!parsed.isEmpty()) { int power = parsed.first().toInt(); // toInt will default to 0 if it's not an int total += qMax(power, 0); @@ -44,7 +44,7 @@ static int sumToughness(const QList &cards) { int total = 0; for (auto card : cards) { - QVariantList parsed = CardItem::parsePT(card->getPT()); + QVariantList parsed = CardState::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); From 47c90cda10f7c72190a346ae69ca6781a453d461 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:44:12 +0100 Subject: [PATCH 066/108] [Refactor] Give graphics zones a card item cache and own card placement (#7427) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- cockatrice/src/game/board/card_state.cpp | 3 ++ cockatrice/src/game/board/card_state.h | 1 + .../src/game/zones/card_zone_algorithms.h | 10 +++--- cockatrice/src/game/zones/card_zone_logic.h | 2 ++ cockatrice/src/game/zones/pile_zone_logic.cpp | 4 +-- .../src/game/zones/table_zone_logic.cpp | 1 - cockatrice/src/game/zones/view_zone_logic.cpp | 1 + .../src/game_graphics/board/card_item.cpp | 15 ++++---- .../src/game_graphics/zones/card_zone.cpp | 23 ++++++++++-- .../src/game_graphics/zones/card_zone.h | 14 ++++++++ .../src/game_graphics/zones/hand_zone.cpp | 16 ++++----- .../src/game_graphics/zones/pile_zone.cpp | 27 ++++++++------ .../src/game_graphics/zones/pile_zone.h | 1 + .../src/game_graphics/zones/select_zone.cpp | 16 ++++----- .../src/game_graphics/zones/stack_zone.cpp | 4 +-- .../src/game_graphics/zones/table_zone.cpp | 36 +++++++++---------- .../src/game_graphics/zones/view_zone.cpp | 4 +-- .../card_zone_algorithms_test.cpp | 7 ---- 18 files changed, 111 insertions(+), 74 deletions(-) diff --git a/cockatrice/src/game/board/card_state.cpp b/cockatrice/src/game/board/card_state.cpp index 1f12253a4..cdd645eaf 100644 --- a/cockatrice/src/game/board/card_state.cpp +++ b/cockatrice/src/game/board/card_state.cpp @@ -14,6 +14,9 @@ void CardState::resetState(bool keepAnnotations) annotation.clear(); } attachedTo = nullptr; + setTapped(false, false); + setDoesntUntap(false); + emit stateReset(); } void CardState::setCardRef(const CardRef &_cardRef) diff --git a/cockatrice/src/game/board/card_state.h b/cockatrice/src/game/board/card_state.h index 45f789aea..278993ec2 100644 --- a/cockatrice/src/game/board/card_state.h +++ b/cockatrice/src/game/board/card_state.h @@ -43,6 +43,7 @@ signals: void cardPixmapUpdated(); void tappedChanged(bool newTapped, bool canAnimate); void facedownChanged(bool newFaceDown); + void stateReset(); void attackingChanged(bool newValue); void countersChanged(const QMap &newCounters); diff --git a/cockatrice/src/game/zones/card_zone_algorithms.h b/cockatrice/src/game/zones/card_zone_algorithms.h index 118a7d29d..35e96b86c 100644 --- a/cockatrice/src/game/zones/card_zone_algorithms.h +++ b/cockatrice/src/game/zones/card_zone_algorithms.h @@ -5,9 +5,9 @@ namespace CardZoneAlgorithms { /** - * Shared insertion logic for zones where cards become visible on add and follow - * the standard pattern: clamp index, insert, clear identity if contents unknown, - * reset state, show card. + * Shared insertion logic for zones where cards follow the standard pattern: + * clamp index, insert, clear identity if contents unknown, reset state. + * Visibility and parenting are handled by the graphics zone's onCardAdded(). * * Zones with different post-add behavior (signal connections, positional resets, * hidden cards, or coordinate-based placement) should NOT use this — implement @@ -18,8 +18,7 @@ namespace CardZoneAlgorithms * * @tparam CardList Must provide: size() -> int, insert(int, CardType*), * getContentsKnown() -> bool - * @tparam CardType Must provide: setId(int), setCardRef(CardRefType), - * resetState(bool), setVisible(bool) + * @tparam CardType Must provide: setId(int), setCardRef(CardRefType), resetState(bool) * @param keepAnnotations Forwarded to card->resetState(). Stack-like zones preserve * annotations across zone transitions; hand-like zones clear them. */ @@ -37,7 +36,6 @@ void addCardToList(CardList &cards, CardType *card, int x, bool keepAnnotations) } card->resetState(keepAnnotations); - card->setVisible(true); } } // namespace CardZoneAlgorithms diff --git a/cockatrice/src/game/zones/card_zone_logic.h b/cockatrice/src/game/zones/card_zone_logic.h index 2e232b258..acd290d71 100644 --- a/cockatrice/src/game/zones/card_zone_logic.h +++ b/cockatrice/src/game/zones/card_zone_logic.h @@ -53,6 +53,7 @@ public: void rawInsertCard(CardItem *card, int index) { cards.insert(index, card); + emit cardCountChanged(); } [[nodiscard]] const CardList &getCards() const @@ -63,6 +64,7 @@ public: void sortCards(const QList &options) { cards.sortBy(options); + emit cardCountChanged(); } [[nodiscard]] QString getName() const { diff --git a/cockatrice/src/game/zones/pile_zone_logic.cpp b/cockatrice/src/game/zones/pile_zone_logic.cpp index 40af25cc5..acb9f529b 100644 --- a/cockatrice/src/game/zones/pile_zone_logic.cpp +++ b/cockatrice/src/game/zones/pile_zone_logic.cpp @@ -18,13 +18,12 @@ PileZoneLogic::PileZoneLogic(PlayerLogic *_player, void PileZoneLogic::addCardImpl(CardItem *card, int x, int /*y*/) { - connect(card, &CardItem::sigPixmapUpdated, this, &PileZoneLogic::callUpdate); + connect(card->getState(), &CardState::cardPixmapUpdated, this, &PileZoneLogic::callUpdate); // if x is negative set it to add at end if (x < 0 || x >= cards.size()) { x = cards.size(); } cards.insert(x, card); - card->setPos(0, 0); if (!contentsKnown()) { card->setCardRef({}); card->setId(-1); @@ -33,6 +32,5 @@ void PileZoneLogic::addCardImpl(CardItem *card, int x, int /*y*/) cards.at(x + 1)->setCardRef({}); } } - card->setVisible(false); card->resetState(); } \ No newline at end of file diff --git a/cockatrice/src/game/zones/table_zone_logic.cpp b/cockatrice/src/game/zones/table_zone_logic.cpp index 9343e34b0..83e5e55eb 100644 --- a/cockatrice/src/game/zones/table_zone_logic.cpp +++ b/cockatrice/src/game/zones/table_zone_logic.cpp @@ -29,7 +29,6 @@ void TableZoneLogic::addCardImpl(CardItem *card, int _x, int _y) card->setDoesntUntap(true); } card->setGridPoint(QPoint(_x, _y)); - card->setVisible(true); } CardItem *TableZoneLogic::takeCard(int position, int cardId, bool toNewZone) diff --git a/cockatrice/src/game/zones/view_zone_logic.cpp b/cockatrice/src/game/zones/view_zone_logic.cpp index fbda8eaae..f5a30ec71 100644 --- a/cockatrice/src/game/zones/view_zone_logic.cpp +++ b/cockatrice/src/game/zones/view_zone_logic.cpp @@ -168,6 +168,7 @@ void ZoneViewZoneLogic::clearCards() card->deleteLater(); } cards.clear(); + emit cardCountChanged(); } void ZoneViewZoneLogic::setWriteableRevealZone(bool _writeableRevealZone) diff --git a/cockatrice/src/game_graphics/board/card_item.cpp b/cockatrice/src/game_graphics/board/card_item.cpp index cbce017d9..4f41a3fcc 100644 --- a/cockatrice/src/game_graphics/board/card_item.cpp +++ b/cockatrice/src/game_graphics/board/card_item.cpp @@ -63,6 +63,14 @@ CardItem::CardItem(PlayerLogic *_owner, update(); } }); + + connect(state, &CardState::stateReset, this, [this] { + attachedCards.clear(); + if (scene()) { + static_cast(scene())->unregisterAnimationItem(this); + } + update(); + }); } void CardItem::prepareDelete() @@ -250,13 +258,6 @@ void CardItem::setAttachedTo(CardItem *_attachedTo) void CardItem::resetState(bool keepAnnotations) { state->resetState(keepAnnotations); - attachedCards.clear(); - setTapped(false, false); - setDoesntUntap(false); - if (scene()) { - static_cast(scene())->unregisterAnimationItem(this); - } - update(); } void CardItem::processCardInfo(const ServerInfo_Card &_info) diff --git a/cockatrice/src/game_graphics/zones/card_zone.cpp b/cockatrice/src/game_graphics/zones/card_zone.cpp index d09bb588c..8ab942a67 100644 --- a/cockatrice/src/game_graphics/zones/card_zone.cpp +++ b/cockatrice/src/game_graphics/zones/card_zone.cpp @@ -18,12 +18,31 @@ CardZone::CardZone(CardZoneLogic *_logic, QGraphicsItem *parent) : AbstractGraphicsItem(parent), menu(nullptr), doubleClickAction(0), logic(_logic) { connect(logic, &CardZoneLogic::retranslateUi, this, &CardZone::retranslateUi); + // Invalidation is connected before any consumer so a handler that reads cardItems() + // during the same signal emission always sees a stale flag and rebuilds. + connect(logic, &CardZoneLogic::cardAdded, this, &CardZone::invalidateItems); + connect(logic, &CardZoneLogic::cardCountChanged, this, &CardZone::invalidateItems); + connect(logic, &CardZoneLogic::reorganizeCards, this, &CardZone::invalidateItems); connect(logic, &CardZoneLogic::cardAdded, this, &CardZone::onCardAdded); connect(logic, &CardZoneLogic::setGraphicsVisibility, this, [this](bool v) { this->setVisible(v); }); connect(logic, &CardZoneLogic::updateGraphics, this, [this]() { update(); }); connect(logic, &CardZoneLogic::reorganizeCards, this, &CardZone::reorganizeCards); } +void CardZone::invalidateItems() +{ + itemsDirty = true; +} + +const QList &CardZone::cardItems() const +{ + if (itemsDirty) { + items = getLogic()->getCards(); + itemsDirty = false; + } + return items; +} + void CardZone::onCardAdded(CardItem *addedCard) { addedCard->setParentItem(this); @@ -33,8 +52,8 @@ void CardZone::onCardAdded(CardItem *addedCard) void CardZone::retranslateUi() { - for (int i = 0; i < getLogic()->getCards().size(); ++i) { - getLogic()->getCards()[i]->retranslateUi(); + for (auto *card : cardItems()) { + card->retranslateUi(); } } diff --git a/cockatrice/src/game_graphics/zones/card_zone.h b/cockatrice/src/game_graphics/zones/card_zone.h index def3eee14..950b54446 100644 --- a/cockatrice/src/game_graphics/zones/card_zone.h +++ b/cockatrice/src/game_graphics/zones/card_zone.h @@ -76,6 +76,15 @@ public: return logic; } + /** + * @brief The CardItems backing getLogic()->getCards(), in zone order. + * + * Game logic talks about cards as states; this is the graphics side's view of the same list. + * It is rebuilt on demand after any content change, so views never reach into the logic's + * container themselves and never observe a stale one. + */ + [[nodiscard]] const QList &cardItems() const; + void setMenu(QMenu *_menu, QAction *_doubleClickAction = 0) { menu = _menu; @@ -83,7 +92,12 @@ public: } private: + /** @brief Marks the cached card items stale. Connected to every content-changing signal. */ + void invalidateItems(); + CardZoneLogic *logic; + mutable QList items; + mutable bool itemsDirty = true; }; #endif diff --git a/cockatrice/src/game_graphics/zones/hand_zone.cpp b/cockatrice/src/game_graphics/zones/hand_zone.cpp index 600b7efa9..8cd5e4631 100644 --- a/cockatrice/src/game_graphics/zones/hand_zone.cpp +++ b/cockatrice/src/game_graphics/zones/hand_zone.cpp @@ -47,8 +47,8 @@ void HandZone::handleDropEvent(const QList &dragItems, QPoint point = dropPoint + scenePos().toPoint(); int x = -1; if (SettingsCache::instance().userInterface().getHorizontalHand()) { - for (x = 0; x < getLogic()->getCards().size(); x++) { - if (point.x() < static_cast(getLogic()->getCards().at(x))->scenePos().x()) { + for (x = 0; x < cardItems().size(); x++) { + if (point.x() < cardItems().at(x)->scenePos().x()) { break; } } @@ -83,23 +83,23 @@ void HandZone::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*optio void HandZone::reorganizeCards() { - if (!getLogic()->getCards().isEmpty()) { - const int cardCount = getLogic()->getCards().size(); + if (!cardItems().isEmpty()) { + const int cardCount = cardItems().size(); if (SettingsCache::instance().userInterface().getHorizontalHand()) { bool leftJustified = SettingsCache::instance().userInterface().getLeftJustified(); - qreal cardWidth = getLogic()->getCards().at(0)->boundingRect().width(); + qreal cardWidth = cardItems().at(0)->boundingRect().width(); const int xPadding = leftJustified ? cardWidth * 1.4 : 5; qreal totalWidth = leftJustified ? boundingRect().width() - (1 * xPadding) - 5 : boundingRect().width() - 2 * xPadding; if (cardCount == 1) { - CardItem *c = getLogic()->getCards().at(0); + CardItem *c = cardItems().at(0); qreal xPosition = leftJustified ? xPadding : xPadding + (totalWidth - cardWidth) / 2; c->setPos(xPosition, 5); c->setRealZValue(0); } else { for (int i = 0; i < cardCount; i++) { - CardItem *c = getLogic()->getCards().at(i); + CardItem *c = cardItems().at(i); // If the total width of the cards is smaller than the available width, // the cards do not need to overlap and are displayed in the center of the area. if (cardWidth * cardCount > totalWidth) { @@ -124,7 +124,7 @@ void HandZone::reorganizeCards() void HandZone::sortHand(const QList &options) { - if (getLogic()->getCards().isEmpty()) { + if (cardItems().isEmpty()) { return; } diff --git a/cockatrice/src/game_graphics/zones/pile_zone.cpp b/cockatrice/src/game_graphics/zones/pile_zone.cpp index aa578097a..7696ceafd 100644 --- a/cockatrice/src/game_graphics/zones/pile_zone.cpp +++ b/cockatrice/src/game_graphics/zones/pile_zone.cpp @@ -66,15 +66,14 @@ void PileZone::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*optio { painter->drawPath(shape()); - if (!getLogic()->getCards().isEmpty()) { - getLogic()->getCards().at(0)->paintPicture(painter, getLogic()->getCards().at(0)->getTranslatedSize(painter), - 90); + if (!cardItems().isEmpty()) { + cardItems().at(0)->paintPicture(painter, cardItems().at(0)->getTranslatedSize(painter), 90); } painter->translate(CardDimensions::WIDTH_HALF_F, CardDimensions::HEIGHT_HALF_F); painter->rotate(-90); painter->translate(-CardDimensions::WIDTH_HALF_F, -CardDimensions::HEIGHT_HALF_F); - paintNumberEllipse(getLogic()->getCards().size(), 28, Qt::white, -1, -1, painter); + paintNumberEllipse(cardItems().size(), 28, Qt::white, -1, -1, painter); } void PileZone::handleDropEvent(const QList &dragItems, CardZoneLogic *startZone, const QPoint &) @@ -88,6 +87,15 @@ void PileZone::handleDropEvent(const QList &dragItems, CardZoneL getLogic()->getPlayer()->getPlayerActions()->moveCards(startZone, getLogic(), 0, 0, cards); } +void PileZone::onCardAdded(CardItem *addedCard) +{ + CardZone::onCardAdded(addedCard); + if (addedCard) { + addedCard->setPos(0, 0); + addedCard->setVisible(false); + } +} + void PileZone::reorganizeCards() { update(); @@ -115,15 +123,14 @@ void PileZone::mouseMoveEvent(QGraphicsSceneMouseEvent *event) return; } - if (getLogic()->getCards().isEmpty()) { + if (cardItems().isEmpty()) { return; } bool forceFaceDown = event->modifiers().testFlag(Qt::ShiftModifier); bool bottomCard = event->modifiers().testFlag(Qt::ControlModifier); - CardItem *card = bottomCard ? getLogic()->getCards().last() : getLogic()->getCards().first(); - const int cardid = - getLogic()->contentsKnown() ? card->getId() : (bottomCard ? getLogic()->getCards().size() - 1 : 0); + CardItem *card = bottomCard ? cardItems().last() : cardItems().first(); + const int cardid = getLogic()->contentsKnown() ? card->getId() : (bottomCard ? cardItems().size() - 1 : 0); CardDragItem *drag = card->createDragItem(cardid, event->pos(), event->scenePos(), forceFaceDown); drag->grabMouse(); setCursor(Qt::OpenHandCursor); @@ -136,8 +143,8 @@ void PileZone::mouseReleaseEvent(QGraphicsSceneMouseEvent * /*event*/) void PileZone::hoverEnterEvent(QGraphicsSceneHoverEvent *event) { - if (!getLogic()->getCards().isEmpty()) { - getLogic()->getCards()[0]->processHoverEvent(); + if (!cardItems().isEmpty()) { + cardItems()[0]->processHoverEvent(); } QGraphicsItem::hoverEnterEvent(event); } diff --git a/cockatrice/src/game_graphics/zones/pile_zone.h b/cockatrice/src/game_graphics/zones/pile_zone.h index b2d8d0cd2..5dc02a0e4 100644 --- a/cockatrice/src/game_graphics/zones/pile_zone.h +++ b/cockatrice/src/game_graphics/zones/pile_zone.h @@ -33,6 +33,7 @@ public: [[nodiscard]] QPainterPath shape() const override; void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; void reorganizeCards() override; + void onCardAdded(CardItem *addedCard) override; void handleDropEvent(const QList &dragItems, CardZoneLogic *startZone, const QPoint &dropPoint) override; diff --git a/cockatrice/src/game_graphics/zones/select_zone.cpp b/cockatrice/src/game_graphics/zones/select_zone.cpp index d69ec32f5..9b0c4e60a 100644 --- a/cockatrice/src/game_graphics/zones/select_zone.cpp +++ b/cockatrice/src/game_graphics/zones/select_zone.cpp @@ -85,7 +85,7 @@ SelectZone *SelectZone::findOwningSelectZone(const QGraphicsItem *card) SelectZone::StackLayoutParams SelectZone::buildStackParams(qreal minOffset) const { - const auto &cards = getLogic()->getCards(); + const auto &cards = cardItems(); if (cards.isEmpty()) { return {0, boundingRect().height(), 0.0, 0.0, minOffset}; } @@ -97,7 +97,7 @@ SelectZone::StackLayoutParams SelectZone::buildStackParams(qreal minOffset) cons int SelectZone::calcDropIndexFromY(qreal dropY, bool allowCountExpand, qreal minOffset) const { - const auto &cards = getLogic()->getCards(); + const auto &cards = cardItems(); if (cards.isEmpty()) { return 0; } @@ -115,7 +115,7 @@ void SelectZone::restoreStaleEscapedCards() if (!cardClipContainer) { return; } - for (auto *card : getLogic()->getCards()) { + for (auto *card : cardItems()) { // A card parented to the zone (instead of the clip container) should // only occur while it is actively hovered. If hover cleanup was // missed, reparent it back so clipping resumes. @@ -127,7 +127,7 @@ void SelectZone::restoreStaleEscapedCards() void SelectZone::layoutCardsVertically(const StackLayoutParams ¶ms) { - const auto &cards = getLogic()->getCards(); + const auto &cards = cardItems(); if (cards.isEmpty() || params.cardCount <= 0) { return; } @@ -162,9 +162,9 @@ SelectZone::~SelectZone() if (cardClipContainer) { // Reparent any hover-escaped cards back to the clip container so Qt's // parent-child tree is consistent for destruction. setParentItem() does - // not invalidate getLogic()->getCards() (it modifies the graphics tree, - // not the zone's logical card list). - for (auto *card : getLogic()->getCards()) { + // not invalidate cardItems() — it only moves the item in the graphics + // tree, it never changes the zone's contents. + for (auto *card : cardItems()) { if (card && card->parentItem() == this) { card->setParentItem(cardClipContainer); } @@ -247,7 +247,7 @@ void SelectZone::mouseMoveEvent(QGraphicsSceneMouseEvent *event) } QRectF selectionRect = QRectF(selectionOrigin, pos).normalized(); - for (auto card : getLogic()->getCards()) { + for (auto card : cardItems()) { if (card->getAttachedTo() && card->getAttachedTo()->getZone() != getLogic()) { continue; } diff --git a/cockatrice/src/game_graphics/zones/stack_zone.cpp b/cockatrice/src/game_graphics/zones/stack_zone.cpp index b94cc8e41..4c97a1969 100644 --- a/cockatrice/src/game_graphics/zones/stack_zone.cpp +++ b/cockatrice/src/game_graphics/zones/stack_zone.cpp @@ -72,7 +72,7 @@ void StackZone::handleDropEvent(const QList &dragItems, int index = calcDropIndexFromY(dropPoint.y(), !sameZone, MIN_CARD_VISIBLE); if (sameZone) { // Same-zone no-op: don't move a card onto itself - const auto &cards = getLogic()->getCards(); + const auto &cards = cardItems(); if (!cards.isEmpty() && cards.at(index)->getId() == dragItems.at(0)->getId()) { return; } @@ -102,7 +102,7 @@ void StackZone::setHeight(qreal newHeight) void StackZone::reorganizeCards() { - if (!getLogic()->getCards().isEmpty()) { + if (!cardItems().isEmpty()) { const auto params = buildStackParams(MIN_CARD_VISIBLE); layoutCardsVertically(params); } diff --git a/cockatrice/src/game_graphics/zones/table_zone.cpp b/cockatrice/src/game_graphics/zones/table_zone.cpp index 748434033..c1e3b9f76 100644 --- a/cockatrice/src/game_graphics/zones/table_zone.cpp +++ b/cockatrice/src/game_graphics/zones/table_zone.cpp @@ -228,8 +228,8 @@ void TableZone::reorganizeCards() // Calculate card stack widths so mapping functions work properly computeCardStackWidths(); - for (int i = 0; i < getLogic()->getCards().size(); ++i) { - QPoint gridPoint = getLogic()->getCards()[i]->getGridPos(); + for (int i = 0; i < cardItems().size(); ++i) { + QPoint gridPoint = cardItems()[i]->getGridPos(); if (gridPoint.x() == -1) { continue; } @@ -238,17 +238,17 @@ void TableZone::reorganizeCards() qreal x = mapPoint.x(); qreal y = mapPoint.y(); - int numberAttachedCards = getLogic()->getCards()[i]->getAttachedCards().size(); + int numberAttachedCards = cardItems()[i]->getAttachedCards().size(); qreal actualX = x + numberAttachedCards * STACKED_CARD_OFFSET_X; qreal actualY = y; if (numberAttachedCards) { actualY += 15; } - getLogic()->getCards()[i]->setPos(actualX, actualY); - getLogic()->getCards()[i]->setRealZValue(ZValues::tableCardZValue(actualX, actualY)); + cardItems()[i]->setPos(actualX, actualY); + cardItems()[i]->setRealZValue(ZValues::tableCardZValue(actualX, actualY)); - QListIterator attachedCardIterator(getLogic()->getCards()[i]->getAttachedCards()); + QListIterator attachedCardIterator(cardItems()[i]->getAttachedCards()); int j = 0; while (attachedCardIterator.hasNext()) { ++j; @@ -285,9 +285,9 @@ void TableZone::resizeToContents() int xMax = 0; // Find rightmost card position, which includes the left margin amount. - for (int i = 0; i < getLogic()->getCards().size(); ++i) { - if (getLogic()->getCards()[i]->pos().x() > xMax) { - xMax = (int)getLogic()->getCards()[i]->pos().x(); + for (int i = 0; i < cardItems().size(); ++i) { + if (cardItems()[i]->pos().x() > xMax) { + xMax = (int)cardItems()[i]->pos().x(); } } @@ -308,9 +308,9 @@ void TableZone::resizeToContents() CardItem *TableZone::getCardFromGrid(const QPoint &gridPoint) const { - for (int i = 0; i < getLogic()->getCards().size(); i++) { - if (getLogic()->getCards().at(i)->getGridPoint() == gridPoint) { - return getLogic()->getCards().at(i); + for (int i = 0; i < cardItems().size(); i++) { + if (cardItems().at(i)->getGridPoint() == gridPoint) { + return cardItems().at(i); } } return 0; @@ -327,8 +327,8 @@ void TableZone::computeCardStackWidths() // Each card stack is three grid points worth of card locations. // First pass: compute the number of cards at each card stack. QMap cardStackCount; - for (int i = 0; i < getLogic()->getCards().size(); ++i) { - const QPoint &gridPoint = getLogic()->getCards()[i]->getGridPos(); + for (int i = 0; i < cardItems().size(); ++i) { + const QPoint &gridPoint = cardItems()[i]->getGridPos(); if (gridPoint.x() == -1) { continue; } @@ -339,8 +339,8 @@ void TableZone::computeCardStackWidths() // Second pass: compute the width at each card stack. cardStackWidth.clear(); - for (int i = 0; i < getLogic()->getCards().size(); ++i) { - const QPoint &gridPoint = getLogic()->getCards()[i]->getGridPos(); + for (int i = 0; i < cardItems().size(); ++i) { + const QPoint &gridPoint = cardItems()[i]->getGridPos(); if (gridPoint.x() == -1) { continue; } @@ -348,8 +348,8 @@ void TableZone::computeCardStackWidths() const int key = getCardStackMapKey(gridPoint.x() / 3, gridPoint.y()); const int stackCount = cardStackCount.value(key, 0); if (stackCount == 1) { - cardStackWidth.insert(key, CardDimensions::WIDTH + getLogic()->getCards()[i]->getAttachedCards().size() * - STACKED_CARD_OFFSET_X); + cardStackWidth.insert(key, CardDimensions::WIDTH + + cardItems()[i]->getAttachedCards().size() * STACKED_CARD_OFFSET_X); } else { cardStackWidth.insert(key, CardDimensions::WIDTH + (stackCount - 1) * STACKED_CARD_OFFSET_X); } diff --git a/cockatrice/src/game_graphics/zones/view_zone.cpp b/cockatrice/src/game_graphics/zones/view_zone.cpp index 716d2b963..78fc662b6 100644 --- a/cockatrice/src/game_graphics/zones/view_zone.cpp +++ b/cockatrice/src/game_graphics/zones/view_zone.cpp @@ -139,8 +139,8 @@ void ZoneViewZone::zoneDumpReceived(const Response &r) void ZoneViewZone::reorganizeCards() { // filter cards - CardList cardsToDisplay = CardList(getLogic()->getCards().getContentsKnown()); - for (auto card : getLogic()->getCards()) { + CardList cardsToDisplay = CardList(getLogic()->contentsKnown()); + for (auto card : cardItems()) { if (filterString.check(card->getCard().getCardPtr())) { card->show(); cardsToDisplay.append(card); diff --git a/tests/card_zone_algorithms/card_zone_algorithms_test.cpp b/tests/card_zone_algorithms/card_zone_algorithms_test.cpp index 4f5282235..3d4baa430 100644 --- a/tests/card_zone_algorithms/card_zone_algorithms_test.cpp +++ b/tests/card_zone_algorithms/card_zone_algorithms_test.cpp @@ -16,7 +16,6 @@ struct MockCard bool cardRefWasCalled = false; bool resetStateCalled = false; bool resetStateKeepAnnotations = false; - bool visibleSet = false; void setId(int id) { @@ -35,11 +34,6 @@ struct MockCard resetStateCalled = true; resetStateKeepAnnotations = keepAnnotations; } - - void setVisible(bool visible) - { - visibleSet = visible; - } }; class MockCardList @@ -109,7 +103,6 @@ TEST_F(AddCardAlgorithmTest, ContentsKnownPreservesIdentity) EXPECT_FALSE(card.idWasCalled); EXPECT_FALSE(card.cardRefWasCalled); - EXPECT_TRUE(card.visibleSet); } TEST_F(AddCardAlgorithmTest, ContentsUnknownClearsIdentity) From 2335e064ac4b3cbd194c0b43b7ccd0804464b95b Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:44:13 +0100 Subject: [PATCH 067/108] [Refactor] Move the attachment graph and card view teardown onto CardState (#7428) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- cockatrice/src/game/board/card_state.cpp | 8 ++++- cockatrice/src/game/board/card_state.h | 36 ++++++++++++++++--- .../src/game/player/player_event_handler.cpp | 6 ++-- cockatrice/src/game/player/player_logic.cpp | 4 +-- cockatrice/src/game/zones/view_zone_logic.cpp | 4 +-- .../src/game_graphics/board/card_item.cpp | 6 ++-- .../src/game_graphics/board/card_item.h | 9 +++-- 7 files changed, 54 insertions(+), 19 deletions(-) diff --git a/cockatrice/src/game/board/card_state.cpp b/cockatrice/src/game/board/card_state.cpp index cdd645eaf..95776c237 100644 --- a/cockatrice/src/game/board/card_state.cpp +++ b/cockatrice/src/game/board/card_state.cpp @@ -14,11 +14,17 @@ void CardState::resetState(bool keepAnnotations) annotation.clear(); } attachedTo = nullptr; + attachedCards.clear(); setTapped(false, false); setDoesntUntap(false); emit stateReset(); } +void CardState::deleteView() +{ + emit viewDeleteRequested(); +} + void CardState::setCardRef(const CardRef &_cardRef) { if (cardRef == _cardRef) { @@ -189,7 +195,7 @@ void CardState::setDestroyOnZoneChange(bool _destroyOnZoneChange) emit stateChanged(); } -void CardState::setAttachedTo(CardItem *_attachedTo) +void CardState::setAttachedTo(CardState *_attachedTo) { if (attachedTo == _attachedTo) { return; diff --git a/cockatrice/src/game/board/card_state.h b/cockatrice/src/game/board/card_state.h index 278993ec2..2f9d5917d 100644 --- a/cockatrice/src/game/board/card_state.h +++ b/cockatrice/src/game/board/card_state.h @@ -1,6 +1,7 @@ #ifndef COCKATRICE_CARD_STATE_H #define COCKATRICE_CARD_STATE_H +#include #include #include #include @@ -32,7 +33,8 @@ private: bool doesntUntap = false; bool destroyOnZoneChange = false; - CardItem *attachedTo = nullptr; + CardState *attachedTo = nullptr; + QList attachedCards; CardZoneLogic *zone = nullptr; signals: @@ -51,9 +53,11 @@ signals: void ptChanged(const QString &newPt); void doesntUntapChanged(bool newValue); void destroyOnZoneChangeChanged(bool newValue); - void attachedToChanged(CardItem *newAttachedTo); + void attachedToChanged(CardState *newAttachedTo); void zoneChanged(CardState *changedCard, CardZoneLogic *newZone); + void viewDeleteRequested(); + public: explicit CardState(QObject *parent, CardZoneLogic *_zone = nullptr, const CardRef &_cardRef = {}, int _id = -1) : QObject(parent), id(_id), cardRef(_cardRef), zone(_zone) @@ -62,6 +66,15 @@ public: void resetState(bool keepAnnotations); + /** + * @brief Asks the card view owning this state to tear itself down. + * + * The state never holds a card item directly, so it cannot delete one. It asks instead and lets the view run + * its own teardown, which detaches attachments, drops it from the active card slot and unregisters its + * animations before the graphics object goes away. + */ + void deleteView(); + int getId() const { return id; @@ -187,12 +200,27 @@ public: void setDestroyOnZoneChange(bool _destroyOnZoneChange); - CardItem *getAttachedTo() const + CardState *getAttachedTo() const { return attachedTo; } - void setAttachedTo(CardItem *_attachedTo); + void setAttachedTo(CardState *_attachedTo); + + void addAttachedCard(CardItem *card) + { + attachedCards.append(card); + } + + void removeAttachedCard(CardItem *card) + { + attachedCards.removeOne(card); + } + + const QList &getAttachedCards() const + { + return attachedCards; + } }; #endif // COCKATRICE_CARD_STATE_H diff --git a/cockatrice/src/game/player/player_event_handler.cpp b/cockatrice/src/game/player/player_event_handler.cpp index 18f6573df..55e5d9d8f 100644 --- a/cockatrice/src/game/player/player_event_handler.cpp +++ b/cockatrice/src/game/player/player_event_handler.cpp @@ -356,7 +356,7 @@ void PlayerEventHandler::eventMoveCard(const Event_MoveCard &event, const GameEv } if (card->getAttachedTo() && (startZone != targetZone)) { - CardItem *parentCard = card->getAttachedTo(); + CardState *parentCard = card->getAttachedTo(); card->setAttachedTo(nullptr); parentCard->getZone()->reorganizeCards(); } @@ -440,7 +440,7 @@ void PlayerEventHandler::eventDestroyCard(const Event_DestroyCard &event) emit logDestroyCard(player, card->getName()); zone->takeCard(-1, event.card_id(), true); - card->deleteLater(); + card->getState()->deleteView(); } void PlayerEventHandler::eventAttachCard(const Event_AttachCard &event) @@ -469,7 +469,7 @@ void PlayerEventHandler::eventAttachCard(const Event_AttachCard &event) return; } - CardItem *oldParent = startCard->getAttachedTo(); + CardState *oldParent = startCard->getAttachedTo(); startCard->setAttachedTo(targetCard); diff --git a/cockatrice/src/game/player/player_logic.cpp b/cockatrice/src/game/player/player_logic.cpp index b545b7088..552e9f44c 100644 --- a/cockatrice/src/game/player/player_logic.cpp +++ b/cockatrice/src/game/player/player_logic.cpp @@ -251,7 +251,7 @@ void PlayerLogic::deleteCard(CardItem *card) } else if (dialogSemaphore) { cardsToDelete.append(card); } else { - card->deleteLater(); + card->getState()->deleteView(); } } @@ -332,7 +332,7 @@ bool PlayerLogic::clearCardsToDelete() for (auto &i : cardsToDelete) { if (i != nullptr) { - i->deleteLater(); + i->getState()->deleteView(); } } cardsToDelete.clear(); diff --git a/cockatrice/src/game/zones/view_zone_logic.cpp b/cockatrice/src/game/zones/view_zone_logic.cpp index f5a30ec71..b7d953549 100644 --- a/cockatrice/src/game/zones/view_zone_logic.cpp +++ b/cockatrice/src/game/zones/view_zone_logic.cpp @@ -147,7 +147,7 @@ void ZoneViewZoneLogic::removeCard(int position, bool toNewZone) } CardItem *card = cards.takeAt(position); - card->deleteLater(); + card->getState()->deleteView(); // The toNewZone check is to prevent the view from auto-closing if the view contains only a single card and that // card gets dragged within the view. @@ -165,7 +165,7 @@ void ZoneViewZoneLogic::removeCard(int position, bool toNewZone) void ZoneViewZoneLogic::clearCards() { for (auto card : cards) { - card->deleteLater(); + card->getState()->deleteView(); } cards.clear(); emit cardCountChanged(); diff --git a/cockatrice/src/game_graphics/board/card_item.cpp b/cockatrice/src/game_graphics/board/card_item.cpp index 4f41a3fcc..0b2b3cf82 100644 --- a/cockatrice/src/game_graphics/board/card_item.cpp +++ b/cockatrice/src/game_graphics/board/card_item.cpp @@ -65,12 +65,13 @@ CardItem::CardItem(PlayerLogic *_owner, }); connect(state, &CardState::stateReset, this, [this] { - attachedCards.clear(); if (scene()) { static_cast(scene())->unregisterAnimationItem(this); } update(); }); + + connect(state, &CardState::viewDeleteRequested, this, &CardItem::deleteLater); } void CardItem::prepareDelete() @@ -84,6 +85,7 @@ void CardItem::prepareDelete() owner = nullptr; } + const QList &attachedCards = state->getAttachedCards(); while (!attachedCards.isEmpty()) { attachedCards.first()->setZone(nullptr); // so that it won't try to call reorganizeCards() attachedCards.first()->setAttachedTo(nullptr); @@ -224,7 +226,7 @@ void CardItem::setAttachedTo(CardItem *_attachedTo) } state->setGridPoint(QPoint(-1, state->getGridPoint().y())); - state->setAttachedTo(_attachedTo); + state->setAttachedTo(_attachedTo == nullptr ? nullptr : _attachedTo->getState()); if (state->getAttachedTo() != nullptr) { // If the zone is being torn down, it might already be null by the time a card tries to un-attach all its // attached cards diff --git a/cockatrice/src/game_graphics/board/card_item.h b/cockatrice/src/game_graphics/board/card_item.h index 0932f578b..79c3dcdbd 100644 --- a/cockatrice/src/game_graphics/board/card_item.h +++ b/cockatrice/src/game_graphics/board/card_item.h @@ -34,7 +34,6 @@ class CardItem : public AbstractCardItem Q_OBJECT private: CardDragItem *dragItem; - QList attachedCards; void prepareDelete(); void handleClickedToPlay(bool shiftHeld); @@ -116,22 +115,22 @@ public: { state->setDestroyOnZoneChange(_destroy); } - [[nodiscard]] CardItem *getAttachedTo() const + [[nodiscard]] CardState *getAttachedTo() const { return state->getAttachedTo(); } void setAttachedTo(CardItem *_attachedTo); void addAttachedCard(CardItem *card) { - attachedCards.append(card); + state->addAttachedCard(card); } void removeAttachedCard(CardItem *card) { - attachedCards.removeOne(card); + state->removeAttachedCard(card); } [[nodiscard]] const QList &getAttachedCards() const { - return attachedCards; + return state->getAttachedCards(); } void resetState(bool keepAnnotations = false); void processCardInfo(const ServerInfo_Card &_info); From edb5caec8a77a9f2dfe111264abf1aa60aa5bf8e Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:44:13 +0100 Subject: [PATCH 068/108] [Refactor] Hold zone views and card view teardown outside game graphics (#7429) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- .../src/game/player/player_event_handler.cpp | 25 ++++++------------- .../src/game/player/player_event_handler.h | 9 +++++++ cockatrice/src/game/zones/card_zone_logic.cpp | 19 ++++++-------- cockatrice/src/game/zones/card_zone_logic.h | 10 +++++--- cockatrice/src/game/zones/view_zone_logic.cpp | 5 ++++ cockatrice/src/game/zones/view_zone_logic.h | 7 ++++++ cockatrice/src/game_graphics/game_scene.cpp | 21 ++++++++++++++++ cockatrice/src/game_graphics/game_scene.h | 9 +++++++ .../src/game_graphics/zones/view_zone.cpp | 18 +++++++------ 9 files changed, 84 insertions(+), 39 deletions(-) diff --git a/cockatrice/src/game/player/player_event_handler.cpp b/cockatrice/src/game/player/player_event_handler.cpp index 55e5d9d8f..ff81498ac 100644 --- a/cockatrice/src/game/player/player_event_handler.cpp +++ b/cockatrice/src/game/player/player_event_handler.cpp @@ -1,7 +1,6 @@ #include "player_event_handler.h" #include "../../game_graphics/board/card_item.h" -#include "../../game_graphics/zones/view_zone.h" #include "../abstract_game.h" #include "../board/arrow_data.h" #include "../board/card_list.h" @@ -9,6 +8,7 @@ #include "../zones/card_zone_logic.h" #include "../zones/hand_zone_logic.h" #include "../zones/pile_zone_logic.h" +#include "../zones/view_zone_logic.h" #include "libcockatrice/card/card_info.h" #include "libcockatrice/card/printing/exact_card.h" #include "libcockatrice/protocol/pb/card_attributes.pb.h" @@ -76,12 +76,12 @@ void PlayerEventHandler::eventShuffle(const Event_Shuffle &event) } // close all views that contain shuffled cards - for (ZoneViewZone *view : zone->getViews()) { - if (view != nullptr) { - int length = view->getLogic()->getCards().length(); + for (auto *viewLogic : zone->getViews()) { + if (viewLogic != nullptr) { + int length = viewLogic->getCards().length(); // we want to close empty views as well if (length == 0 || length > absStart) { // note this assumes views always start at the top of the library - view->close(); + viewLogic->requestClose(); } } else { qWarning() << zone->getName() << "of" << player->getPlayerInfo()->getName() << "holds empty zoneview!"; @@ -345,9 +345,9 @@ void PlayerEventHandler::eventMoveCard(const Event_MoveCard &event, const GameEv if (card == nullptr) { return; } - if (startZone != targetZone) { - card->deleteCardInfoPopup(); - } + const bool zoneChanged = startZone != targetZone; + const bool ownerChanged = zoneChanged && startZone->getPlayer() != targetZone->getPlayer(); + emit cardViewRefreshRequested(card->getState(), zoneChanged, ownerChanged ? targetZone->getPlayer() : nullptr); if (event.has_card_name()) { QString name = QString::fromStdString(event.card_name()); QString providerId = @@ -361,22 +361,13 @@ void PlayerEventHandler::eventMoveCard(const Event_MoveCard &event, const GameEv parentCard->getZone()->reorganizeCards(); } - card->deleteDragItem(); - card->setId(event.new_card_id()); card->setFaceDown(event.face_down()); if (startZone != targetZone) { - card->setBeingPointedAt(false); - card->setHovered(false); - const QList &attachedCards = card->getAttachedCards(); for (auto attachedCard : attachedCards) { emit targetZone->cardAdded(attachedCard); } - - if (startZone->getPlayer() != targetZone->getPlayer()) { - card->setOwner(targetZone->getPlayer()); - } } // The log event has to be sent before the card is added to the target zone diff --git a/cockatrice/src/game/player/player_event_handler.h b/cockatrice/src/game/player/player_event_handler.h index 44b43c3f1..37215d61d 100644 --- a/cockatrice/src/game/player/player_event_handler.h +++ b/cockatrice/src/game/player/player_event_handler.h @@ -28,6 +28,7 @@ #include class CardItem; +class CardState; class CardZoneLogic; class PlayerLogic; class Event_AttachCard; @@ -268,6 +269,14 @@ signals: void cardZoneChanged(CardItem *card, bool sameZone); void requestCardMenuUpdate(CardItem *card); + /** + * @brief A card was just moved by the server and its view needs refreshing. + * + * @param card The card that moved. + * @param zoneChanged Whether the card crossed a zone boundary. + * @param newOwner The player the card now belongs to, or nullptr to leave the owner alone. + */ + void cardViewRefreshRequested(CardState *card, bool zoneChanged, PlayerLogic *newOwner); private: /** Owning player instance. */ diff --git a/cockatrice/src/game/zones/card_zone_logic.cpp b/cockatrice/src/game/zones/card_zone_logic.cpp index d997ab9ad..c4431f20a 100644 --- a/cockatrice/src/game/zones/card_zone_logic.cpp +++ b/cockatrice/src/game/zones/card_zone_logic.cpp @@ -2,9 +2,6 @@ #include "../../client/translation.h" #include "../../game_graphics/board/card_item.h" -#include "../../game_graphics/zones/card_zone.h" -#include "../../game_graphics/zones/view_zone.h" -#include "../board/card_list.h" #include "../player/player_actions.h" #include "../player/player_info.h" #include "../player/player_logic.h" @@ -47,16 +44,16 @@ CardZoneLogic::CardZoneLogic(PlayerLogic *_player, void CardZoneLogic::addCard(CardItem *card, const bool reorganize, const int x, const int y) { if (!card) { - qCWarning(CardZoneLog) << "CardZoneLogic::addCard() card is null; this shouldn't normally happen"; + qCWarning(CardZoneLogicLog) << "CardZoneLogic::addCard() card is null; this shouldn't normally happen"; return; } - for (auto *view : views) { - if (qobject_cast(view->getLogic())->prepareAddCard(x)) { + for (auto *viewLogic : views) { + if (viewLogic->prepareAddCard(x)) { auto copy = new CardItem(player, nullptr, card->getCardRef(), card->getId()); copy->setFaceDown(card->getFaceDown()); - view->getLogic()->addCard(copy, reorganize, x, y); + viewLogic->addCard(copy, reorganize, x, y); } } @@ -90,8 +87,8 @@ CardItem *CardZoneLogic::takeCard(int position, int cardId, bool toNewZone) return nullptr; } - for (auto *view : views) { - qobject_cast(view->getLogic())->removeCard(position, toNewZone); + for (auto *viewLogic : views) { + viewLogic->removeCard(position, toNewZone); } CardItem *c = cards.takeAt(position); @@ -107,7 +104,7 @@ CardItem *CardZoneLogic::getCard(int cardId) { CardItem *c = cards.findCard(cardId); if (!c) { - qCWarning(CardZoneLog) << "CardZoneLogic::getCard: card id=" << cardId << "not found"; + qCWarning(CardZoneLogicLog) << "CardZoneLogic::getCard: card id=" << cardId << "not found"; return nullptr; } // If the card's id is -1, this zone is invisible, @@ -122,7 +119,7 @@ CardItem *CardZoneLogic::getCard(int cardId) void CardZoneLogic::removeCard(CardItem *card) { if (!card) { - qCWarning(CardZoneLog) << "CardZoneLogic::removeCard: card is null, this shouldn't normally happen"; + qCWarning(CardZoneLogicLog) << "CardZoneLogic::removeCard: card is null, this shouldn't normally happen"; return; } diff --git a/cockatrice/src/game/zones/card_zone_logic.h b/cockatrice/src/game/zones/card_zone_logic.h index acd290d71..e01eec74c 100644 --- a/cockatrice/src/game/zones/card_zone_logic.h +++ b/cockatrice/src/game/zones/card_zone_logic.h @@ -21,7 +21,11 @@ class CardItem; inline Q_LOGGING_CATEGORY(CardZoneLogicLog, "card_zone_logic"); class PlayerLogic; -class ZoneViewZone; +class ZoneViewZoneLogic; +class QMenu; +class QAction; +class QPainter; +class CardDragItem; class CardZoneLogic : public QObject { @@ -79,7 +83,7 @@ public: { return cards.getContentsKnown(); } - QList &getViews() + QList &getViews() { return views; } @@ -111,7 +115,7 @@ protected: PlayerLogic *player; QString name; CardList cards; - QList views; + QList views; bool hasCardAttr; bool isShufflable; bool alwaysRevealTopCard; diff --git a/cockatrice/src/game/zones/view_zone_logic.cpp b/cockatrice/src/game/zones/view_zone_logic.cpp index b7d953549..f5e597abe 100644 --- a/cockatrice/src/game/zones/view_zone_logic.cpp +++ b/cockatrice/src/game/zones/view_zone_logic.cpp @@ -162,6 +162,11 @@ void ZoneViewZoneLogic::removeCard(int position, bool toNewZone) reorganizeCards(); } +void ZoneViewZoneLogic::requestClose() +{ + emit closeView(); +} + void ZoneViewZoneLogic::clearCards() { for (auto card : cards) { diff --git a/cockatrice/src/game/zones/view_zone_logic.h b/cockatrice/src/game/zones/view_zone_logic.h index 1abcd0516..71261e204 100644 --- a/cockatrice/src/game/zones/view_zone_logic.h +++ b/cockatrice/src/game/zones/view_zone_logic.h @@ -48,6 +48,13 @@ public: void removeCard(int position, bool toNewZone); void updateCardIds(CardAction action); + /** + * @brief Asks the graphics view showing this zone to close itself. + * + * Logic code has no handle on the view, so it goes through the logic object the view was built from. + */ + void requestClose(); + /** @brief Removes all cards from the view, without touching anything else. Used in replay rewind */ void clearCards(); diff --git a/cockatrice/src/game_graphics/game_scene.cpp b/cockatrice/src/game_graphics/game_scene.cpp index 31188293d..f54e3ae28 100644 --- a/cockatrice/src/game_graphics/game_scene.cpp +++ b/cockatrice/src/game_graphics/game_scene.cpp @@ -291,6 +291,8 @@ void GameScene::addPlayer(PlayerLogic *player) [this, id = player->getPlayerInfo()->getId()]() { clearArrowsForPlayerLocally(id); }); connect(player->getPlayerEventHandler(), &PlayerEventHandler::cardZoneChanged, this, &GameScene::onCardZoneChanged); + connect(player->getPlayerEventHandler(), &PlayerEventHandler::cardViewRefreshRequested, this, + &GameScene::onCardViewRefreshRequested); rearrange(); } @@ -650,6 +652,25 @@ void GameScene::onCardZoneChanged(CardItem *card, bool sameZone) } } +void GameScene::onCardViewRefreshRequested(CardState *card, bool zoneChanged, PlayerLogic *newOwner) +{ + auto *item = qobject_cast(card->parent()); + if (!item) { + return; + } + if (zoneChanged) { + item->deleteCardInfoPopup(); + } + item->deleteDragItem(); + if (zoneChanged) { + item->setBeingPointedAt(false); + item->setHovered(false); + } + if (newOwner) { + item->setOwner(newOwner); + } +} + void GameScene::clearArrowsForPlayer(int playerId) { for (int arrowId : arrowRegistry.idsForPlayer(playerId)) { diff --git a/cockatrice/src/game_graphics/game_scene.h b/cockatrice/src/game_graphics/game_scene.h index f0e94e14e..ca6441e91 100644 --- a/cockatrice/src/game_graphics/game_scene.h +++ b/cockatrice/src/game_graphics/game_scene.h @@ -43,6 +43,7 @@ class ZoneViewWidget; class CardZone; class AbstractCardItem; class CardItem; +class CardState; class ServerInfo_Card; class PhasesToolbar; class QBasicTimer; @@ -282,6 +283,14 @@ public slots: void requestArrowDeletion(int playerId, int arrowId); void onCardZoneChanged(CardItem *card, bool sameZone); + /** + * @brief Refreshes the view of a card that the server just moved. + * + * @param card The card that moved. + * @param zoneChanged Whether the card crossed a zone boundary. + * @param newOwner The player the card now belongs to, or nullptr to leave the owner alone. + */ + void onCardViewRefreshRequested(CardState *card, bool zoneChanged, PlayerLogic *newOwner); protected: /** @brief Handles hover updates. */ diff --git a/cockatrice/src/game_graphics/zones/view_zone.cpp b/cockatrice/src/game_graphics/zones/view_zone.cpp index 78fc662b6..7d47b3259 100644 --- a/cockatrice/src/game_graphics/zones/view_zone.cpp +++ b/cockatrice/src/game_graphics/zones/view_zone.cpp @@ -40,21 +40,23 @@ class QGraphicsItem; ZoneViewZone::ZoneViewZone(ZoneViewZoneLogic *_logic, QGraphicsItem *parent) : SelectZone(_logic, parent), bRect(QRectF()), minRows(0), groupBy(CardList::NoSort), sortBy(CardList::NoSort) { - if (!(qobject_cast(getLogic())->getRevealZone() && - !qobject_cast(getLogic())->getWriteableRevealZone())) { - qobject_cast(getLogic())->getOriginalZone()->getViews().append(this); + auto *viewLogic = qobject_cast(getLogic()); + if (!(viewLogic->getRevealZone() && !viewLogic->getWriteableRevealZone())) { + viewLogic->getOriginalZone()->getViews().append(viewLogic); } connect(_logic, &ZoneViewZoneLogic::closeView, this, &ZoneViewZone::close); } void ZoneViewZone::addToViews() { - qobject_cast(getLogic())->getOriginalZone()->getViews().append(this); + auto *viewLogic = qobject_cast(getLogic()); + viewLogic->getOriginalZone()->getViews().append(viewLogic); } void ZoneViewZone::removeFromViews() { - qobject_cast(getLogic())->getOriginalZone()->getViews().removeOne(this); + auto *viewLogic = qobject_cast(getLogic()); + viewLogic->getOriginalZone()->getViews().removeOne(viewLogic); } /** @@ -64,9 +66,9 @@ void ZoneViewZone::removeFromViews() void ZoneViewZone::close() { emit closed(); - if (!(qobject_cast(getLogic())->getRevealZone() && - !qobject_cast(getLogic())->getWriteableRevealZone())) { - qobject_cast(getLogic())->getOriginalZone()->getViews().removeOne(this); + auto *viewLogic = qobject_cast(getLogic()); + if (!(viewLogic->getRevealZone() && !viewLogic->getWriteableRevealZone())) { + viewLogic->getOriginalZone()->getViews().removeOne(viewLogic); } deleteLater(); } From 5d0d466930a3bb3eb99b9fc8d8014031e5d7a173 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:44:13 +0100 Subject: [PATCH 069/108] [Refactor] Add card name, printing id, and color to CardState (#7433) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- cockatrice/src/game/board/card_state.cpp | 6 ++++++ cockatrice/src/game/board/card_state.h | 19 +++++++++++++++++++ .../board/abstract_card_item.cpp | 13 ++++++++----- .../game_graphics/board/abstract_card_item.h | 5 ++--- 4 files changed, 35 insertions(+), 8 deletions(-) diff --git a/cockatrice/src/game/board/card_state.cpp b/cockatrice/src/game/board/card_state.cpp index 95776c237..df7963e00 100644 --- a/cockatrice/src/game/board/card_state.cpp +++ b/cockatrice/src/game/board/card_state.cpp @@ -20,6 +20,12 @@ void CardState::resetState(bool keepAnnotations) emit stateReset(); } +void CardState::setColor(const QString &_color) +{ + color = _color; + emit colorChanged(); +} + void CardState::deleteView() { emit viewDeleteRequested(); diff --git a/cockatrice/src/game/board/card_state.h b/cockatrice/src/game/board/card_state.h index 2f9d5917d..bce5a298d 100644 --- a/cockatrice/src/game/board/card_state.h +++ b/cockatrice/src/game/board/card_state.h @@ -32,6 +32,7 @@ private: QString pt; bool doesntUntap = false; bool destroyOnZoneChange = false; + QString color; CardState *attachedTo = nullptr; QList attachedCards; @@ -46,6 +47,7 @@ signals: void tappedChanged(bool newTapped, bool canAnimate); void facedownChanged(bool newFaceDown); void stateReset(); + void colorChanged(); void attackingChanged(bool newValue); void countersChanged(const QMap &newCounters); @@ -92,6 +94,16 @@ public: void setCardRef(const CardRef &_cardRef); + QString getName() const + { + return cardRef.name; + } + + QString getProviderId() const + { + return cardRef.providerId; + } + ExactCard getCard() const { return exactCard; @@ -186,6 +198,13 @@ public: */ static QVariantList parsePT(const QString &pt); + [[nodiscard]] QString getColor() const + { + return color; + } + + void setColor(const QString &_color); + bool getDoesntUntap() const { return doesntUntap; diff --git a/cockatrice/src/game_graphics/board/abstract_card_item.cpp b/cockatrice/src/game_graphics/board/abstract_card_item.cpp index 3bc0e1156..0e8b6ced3 100644 --- a/cockatrice/src/game_graphics/board/abstract_card_item.cpp +++ b/cockatrice/src/game_graphics/board/abstract_card_item.cpp @@ -54,6 +54,10 @@ AbstractCardItem::AbstractCardItem(QGraphicsItem *parent, const CardRef &cardRef connect(state, &CardState::cardRefChanged, this, &AbstractCardItem::onCardRefChanged); connect(state, &CardState::tappedChanged, this, &AbstractCardItem::onTappedChanged); connect(state, &CardState::facedownChanged, this, [this] { update(); }); + connect(state, &CardState::colorChanged, this, [this] { + cacheBgColor(); + update(); + }); connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::displayCardNamesChanged, this, [this] { update(); }); @@ -265,18 +269,17 @@ void AbstractCardItem::setHovered(bool _hovered) void AbstractCardItem::setColor(const QString &_color) { - color = _color; - cacheBgColor(); - update(); + state->setColor(_color); } void AbstractCardItem::cacheBgColor() { QChar colorChar; - if (color.isEmpty()) { + const QString cardColor = state->getColor(); + if (cardColor.isEmpty()) { colorChar = state->getCardInfo().getColorChar(); } else { - colorChar = color.at(0); + colorChar = cardColor.at(0); } switch (colorChar.toLower().toLatin1()) { diff --git a/cockatrice/src/game_graphics/board/abstract_card_item.h b/cockatrice/src/game_graphics/board/abstract_card_item.h index 709265a2a..01fc4bb0a 100644 --- a/cockatrice/src/game_graphics/board/abstract_card_item.h +++ b/cockatrice/src/game_graphics/board/abstract_card_item.h @@ -23,7 +23,6 @@ class AbstractCardItem : public ArrowTarget, public IAnimatedItem protected: CardState *state; int tapAngle; - QString color; QColor bgColor; private: @@ -114,9 +113,9 @@ public: { return isHovered; } - QString getColor() const + [[nodiscard]] QString getColor() const { - return color; + return state->getColor(); } void setColor(const QString &_color); bool getTapped() const From 65e9f1b370ea327904ecc15c10e768888931c34d Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:44:14 +0100 Subject: [PATCH 070/108] [Refactor] Retype card containers and zone logic onto CardState (#7434) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Refactor] Retype card containers and zone logic onto CardState * [Game] Restore attach bookkeeping via CardItem and repaint on state change * [Refactor] Give custom zones a hidden graphics owner Custom PileZoneLogic zones had no CardZone, so moving a card into one left the CardItem drawn at its old spot. Create a hidden PileZone in onCustomZoneAdded so cards are reparented and hidden. --------- Co-authored-by: Lukas Brübach --- cockatrice/src/game/abstract_game.cpp | 2 +- cockatrice/src/game/abstract_game.h | 3 +- cockatrice/src/game/board/card_list.cpp | 49 +++++++-------- cockatrice/src/game/board/card_list.h | 8 +-- cockatrice/src/game/player/player_actions.cpp | 6 +- .../src/game/player/player_event_handler.cpp | 62 ++++++++++++------- .../src/game/player/player_event_handler.h | 12 ++-- cockatrice/src/game/player/player_logic.cpp | 29 ++++++--- cockatrice/src/game/player/player_logic.h | 4 +- cockatrice/src/game/zones/card_zone_logic.cpp | 24 +++---- cockatrice/src/game/zones/card_zone_logic.h | 14 ++--- cockatrice/src/game/zones/hand_zone_logic.cpp | 4 +- cockatrice/src/game/zones/hand_zone_logic.h | 2 +- cockatrice/src/game/zones/pile_zone_logic.cpp | 8 +-- cockatrice/src/game/zones/pile_zone_logic.h | 2 +- .../src/game/zones/stack_zone_logic.cpp | 4 +- cockatrice/src/game/zones/stack_zone_logic.h | 2 +- .../src/game/zones/table_zone_logic.cpp | 8 +-- cockatrice/src/game/zones/table_zone_logic.h | 6 +- cockatrice/src/game/zones/view_zone_logic.cpp | 10 +-- cockatrice/src/game/zones/view_zone_logic.h | 2 +- .../src/game_graphics/board/card_item.cpp | 10 ++- cockatrice/src/game_graphics/game_scene.cpp | 12 +++- .../game_graphics/log/message_log_widget.cpp | 10 +-- .../game_graphics/log/message_log_widget.h | 11 ++-- .../player/player_graphics_item.cpp | 16 +++++ .../src/game_graphics/zones/card_zone.cpp | 19 ++++-- .../src/game_graphics/zones/card_zone.h | 5 +- .../src/game_graphics/zones/pile_zone.cpp | 9 +-- .../src/game_graphics/zones/pile_zone.h | 2 +- .../src/game_graphics/zones/select_zone.cpp | 11 ++-- .../src/game_graphics/zones/select_zone.h | 2 +- .../src/game_graphics/zones/view_zone.cpp | 23 +++---- 33 files changed, 232 insertions(+), 159 deletions(-) diff --git a/cockatrice/src/game/abstract_game.cpp b/cockatrice/src/game/abstract_game.cpp index 7c342d1a9..a93f44018 100644 --- a/cockatrice/src/game/abstract_game.cpp +++ b/cockatrice/src/game/abstract_game.cpp @@ -46,7 +46,7 @@ void AbstractGame::loadReplay(const GameReplay *replay) gameMetaInfo->setSpectatorsOmniscient(true); } -CardItem *AbstractGame::getCard(int playerId, const QString &zoneName, int cardId) const +CardState *AbstractGame::getCard(int playerId, const QString &zoneName, int cardId) const { PlayerLogic *player = playerManager->getPlayer(playerId); if (!player) { diff --git a/cockatrice/src/game/abstract_game.h b/cockatrice/src/game/abstract_game.h index 093916aa9..505bfc1b5 100644 --- a/cockatrice/src/game/abstract_game.h +++ b/cockatrice/src/game/abstract_game.h @@ -13,6 +13,7 @@ class CardItem; class AbstractClient; +class CardState; class GameEventHandler; class GameMetaInfo; class GameReplay; @@ -57,7 +58,7 @@ public: void loadReplay(const GameReplay *replay); - CardItem *getCard(int playerId, const QString &zoneName, int cardId) const; + CardState *getCard(int playerId, const QString &zoneName, int cardId) const; }; #endif // COCKATRICE_ABSTRACT_GAME_H diff --git a/cockatrice/src/game/board/card_list.cpp b/cockatrice/src/game/board/card_list.cpp index 94bae67cd..361c51502 100644 --- a/cockatrice/src/game/board/card_list.cpp +++ b/cockatrice/src/game/board/card_list.cpp @@ -1,7 +1,6 @@ #include "card_list.h" -#include "../../game_graphics/board/card_item.h" -#include "libcockatrice/card/printing/exact_card.h" +#include "card_state.h" #include #include @@ -11,26 +10,26 @@ #include #include -CardList::CardList(bool _contentsKnown) : QList(), contentsKnown(_contentsKnown) +CardList::CardList(bool _contentsKnown) : QList(), contentsKnown(_contentsKnown) { } /** - * @brief Finds the CardItem with the given id in the list. + * @brief Finds the CardState with the given id in the list. * If contentsKnown is false, then this just returns the first element of the list. * * @param cardId The id of the card to find. * - * @returns A pointer to the CardItem, or a nullptr if not found. + * @returns A pointer to the CardState, or a nullptr if not found. */ -CardItem *CardList::findCard(const int cardId) const +CardState *CardList::findCard(const int cardId) const { if (!contentsKnown && !empty()) { return at(0); } else { - for (auto *cardItem : *this) { - if (cardItem->getId() == cardId) { - return cardItem; + for (auto *cardState : *this) { + if (cardState->getId() == cardId) { + return cardState; } } } @@ -38,7 +37,7 @@ CardItem *CardList::findCard(const int cardId) const } /** - * @brief sorts the list by using string comparison on properties extracted from the CardItem + * @brief sorts the list by using string comparison on properties extracted from the CardState * The cards are compared using each property in order. * If two cards have the same value for a property, then the next property in the list is used. * @@ -51,7 +50,7 @@ void CardList::sortBy(const QList &option) return; } - auto comparator = [&option](CardItem *a, CardItem *b) { + auto comparator = [&option](CardState *a, CardState *b) { for (auto prop : option) { auto extractor = getExtractorFor(prop); QString t1 = extractor(a); @@ -116,26 +115,26 @@ static QString getColorSortString(const CardInfo &c, bool appendAtEnd) } /** - * @brief returns the function that extracts the given property from the CardItem. + * @brief returns the function that extracts the given property from the CardState. */ -std::function CardList::getExtractorFor(SortOption option) +std::function CardList::getExtractorFor(SortOption option) { switch (option) { case NoSort: - return [](CardItem *) { return ""; }; + return [](CardState *) { return ""; }; case SortByMainType: - return [](CardItem *c) { return c->getCardInfo().getMainCardType(); }; + return [](CardState *c) { return c->getCardInfo().getMainCardType(); }; case SortByManaValue: // getCmc returns the int as a string. We pad with 0's so that string comp also works on it - return [](CardItem *c) { return c->getCard() ? c->getCardInfo().getCmc().rightJustified(4, '0') : ""; }; + return [](CardState *c) { return c->getCard() ? c->getCardInfo().getCmc().rightJustified(4, '0') : ""; }; case SortByColorGrouping: - return [](CardItem *c) { return c->getCard() ? getColorSortString(c->getCardInfo(), false) : ""; }; + return [](CardState *c) { return c->getCard() ? getColorSortString(c->getCardInfo(), false) : ""; }; case SortByName: - return [](CardItem *c) { return c->getName(); }; + return [](CardState *c) { return c->getName(); }; case SortByType: - return [](CardItem *c) { return c->getCardInfo().getCardType(); }; + return [](CardState *c) { return c->getCardInfo().getCardType(); }; case SortByManaCost: - return [](CardItem *c) { + return [](CardState *c) { if (!c->getCard()) { return QString(); } @@ -147,18 +146,18 @@ std::function CardList::getExtractorFor(SortOption option) return QString("%1%2").arg(info.getCmc(), 4, QChar('0')).arg(info.getManaCost()); }; case SortByColors: - return [](CardItem *c) { return c->getCard() ? getColorSortString(c->getCardInfo(), true) : ""; }; + return [](CardState *c) { return c->getCard() ? getColorSortString(c->getCardInfo(), true) : ""; }; case SortByPt: // do the same padding trick as above return - [](CardItem *c) { return c->getCard() ? c->getCardInfo().getPowTough().rightJustified(10, '0') : ""; }; + [](CardState *c) { return c->getCard() ? c->getCardInfo().getPowTough().rightJustified(10, '0') : ""; }; case SortBySet: - return [](CardItem *c) { return c->getCardInfo().getSetsNames(); }; + return [](CardState *c) { return c->getCardInfo().getSetsNames(); }; case SortByPrinting: - return [](CardItem *c) { return c->getProviderId(); }; + return [](CardState *c) { return c->getProviderId(); }; } // this line should never be reached qCWarning(CardListLog) << "cardlist.cpp: Could not find extractor for SortOption" << option; - return [](CardItem *) { return ""; }; + return [](CardState *) { return ""; }; } \ No newline at end of file diff --git a/cockatrice/src/game/board/card_list.h b/cockatrice/src/game/board/card_list.h index b5427df0d..b3037d0f6 100644 --- a/cockatrice/src/game/board/card_list.h +++ b/cockatrice/src/game/board/card_list.h @@ -14,9 +14,9 @@ inline Q_LOGGING_CATEGORY(CardListLog, "card_list"); -class CardItem; +class CardState; -class CardList : public QList +class CardList : public QList { protected: bool contentsKnown; @@ -43,7 +43,7 @@ public: SortByPrinting }; explicit CardList(bool _contentsKnown); - CardItem *findCard(const int cardId) const; + CardState *findCard(const int cardId) const; bool getContentsKnown() const { return contentsKnown; @@ -51,7 +51,7 @@ public: void sortBy(const QList &options); - static std::function getExtractorFor(SortOption option); + static std::function getExtractorFor(SortOption option); }; #endif diff --git a/cockatrice/src/game/player/player_actions.cpp b/cockatrice/src/game/player/player_actions.cpp index 1a91d28a5..c0f4ef11d 100644 --- a/cockatrice/src/game/player/player_actions.cpp +++ b/cockatrice/src/game/player/player_actions.cpp @@ -1614,7 +1614,9 @@ void PlayerActions::actIncrementAllCardCounters(QList cardsToUpdate) { if (cardsToUpdate.isEmpty()) { // If no cards selected, update all cards on table - cardsToUpdate = static_cast>(player->getTableZone()->getCards()); + for (auto *state : player->getTableZone()->getCards()) { + cardsToUpdate.append(qobject_cast(state->parent())); + } } QList commandList; @@ -1687,7 +1689,7 @@ void PlayerActions::actHide(QList selectedCards) for (const auto &item : selectedCards) { auto *card = static_cast(item); if (card && isUnwritableRevealZone(card->getZone())) { - card->getZone()->removeCard(card); + card->getZone()->removeCard(card->getState()); } } } diff --git a/cockatrice/src/game/player/player_event_handler.cpp b/cockatrice/src/game/player/player_event_handler.cpp index ff81498ac..4698aeced 100644 --- a/cockatrice/src/game/player/player_event_handler.cpp +++ b/cockatrice/src/game/player/player_event_handler.cpp @@ -53,6 +53,11 @@ #include #include +static CardItem *toCardItem(CardState *card) +{ + return card == nullptr ? nullptr : qobject_cast(card->parent()); +} + PlayerEventHandler::PlayerEventHandler(PlayerLogic *_player) : QObject(_player), player(_player) { connect(this, &PlayerEventHandler::requestCardMenuUpdate, player, &PlayerLogic::requestCardMenuUpdate); @@ -171,7 +176,7 @@ void PlayerEventHandler::eventCreateToken(const Event_CreateToken &event) card->setFaceDown(event.face_down()); emit logCreateToken(player, card->getName(), card->getPT(), card->getFaceDown()); - zone->addCard(card, true, event.x(), event.y()); + zone->addCard(card->getState(), true, event.x(), event.y()); } void PlayerEventHandler::eventSetCardAttr(const Event_SetCardAttr &event, @@ -193,7 +198,7 @@ void PlayerEventHandler::eventSetCardAttr(const Event_SetCardAttr &event, emit logSetTapped(player, nullptr, event.attr_value() == "1"); } } else { - CardItem *card = zone->getCard(event.card_id()); + CardState *card = zone->getCard(event.card_id()); if (!card) { qWarning() << "PlayerEventHandler::eventSetCardAttr: card id=" << event.card_id() << "not found"; return; @@ -203,7 +208,7 @@ void PlayerEventHandler::eventSetCardAttr(const Event_SetCardAttr &event, } void PlayerEventHandler::setCardAttrHelper(const GameEventContext &context, - CardItem *card, + CardState *card, CardAttribute attribute, const QString &avalue, bool allCards, @@ -264,14 +269,14 @@ void PlayerEventHandler::eventSetCardCounter(const Event_SetCardCounter &event) return; } - CardItem *card = zone->getCard(event.card_id()); + CardState *card = zone->getCard(event.card_id()); if (!card) { return; } int oldValue = card->getCounters().value(event.counter_id(), 0); card->setCounter(event.counter_id(), event.counter_value()); - emit requestCardMenuUpdate(card); + emit requestCardMenuUpdate(toCardItem(card)); emit logSetCardCounter(player, card->getName(), event.counter_id(), event.counter_value(), oldValue); } @@ -341,13 +346,13 @@ void PlayerEventHandler::eventMoveCard(const Event_MoveCard &event, const GameEv if (x == -1) { x = 0; } - CardItem *card = startZone->takeCard(position, event.card_id(), startZone != targetZone); + CardState *card = startZone->takeCard(position, event.card_id(), startZone != targetZone); if (card == nullptr) { return; } const bool zoneChanged = startZone != targetZone; const bool ownerChanged = zoneChanged && startZone->getPlayer() != targetZone->getPlayer(); - emit cardViewRefreshRequested(card->getState(), zoneChanged, ownerChanged ? targetZone->getPlayer() : nullptr); + emit cardViewRefreshRequested(card, zoneChanged, ownerChanged ? targetZone->getPlayer() : nullptr); if (event.has_card_name()) { QString name = QString::fromStdString(event.card_name()); QString providerId = @@ -357,7 +362,13 @@ void PlayerEventHandler::eventMoveCard(const Event_MoveCard &event, const GameEv if (card->getAttachedTo() && (startZone != targetZone)) { CardState *parentCard = card->getAttachedTo(); - card->setAttachedTo(nullptr); + // Route through CardItem so the old parent's attachedCards list is cleaned up: CardState::setAttachedTo + // only repoints the pointer, leaving a dangling entry that reorganizeCards/prepareDelete would dereference. + if (CardItem *item = toCardItem(card)) { + item->setAttachedTo(nullptr); + } else { + card->setAttachedTo(nullptr); + } parentCard->getZone()->reorganizeCards(); } @@ -366,7 +377,7 @@ void PlayerEventHandler::eventMoveCard(const Event_MoveCard &event, const GameEv if (startZone != targetZone) { const QList &attachedCards = card->getAttachedCards(); for (auto attachedCard : attachedCards) { - emit targetZone->cardAdded(attachedCard); + emit targetZone->cardAdded(attachedCard->getState()); } } @@ -380,12 +391,12 @@ void PlayerEventHandler::eventMoveCard(const Event_MoveCard &event, const GameEv targetZone->addCard(card, true, x, y); - emit cardZoneChanged(card, startZone == targetZone); - emit requestCardMenuUpdate(card); + emit cardZoneChanged(toCardItem(card), startZone == targetZone); + emit requestCardMenuUpdate(toCardItem(card)); if (player->getPlayerActions()->isMovingCardsUntil() && startZoneString == ZoneNames::DECK && targetZone->getName() == ZoneNames::STACK) { - player->getPlayerActions()->moveOneCardUntil(card); + player->getPlayerActions()->moveOneCardUntil(toCardItem(card)); } } @@ -395,7 +406,7 @@ void PlayerEventHandler::eventFlipCard(const Event_FlipCard &event) if (!zone) { return; } - CardItem *card = zone->getCard(event.card_id()); + CardState *card = zone->getCard(event.card_id()); if (!card) { return; } @@ -408,7 +419,7 @@ void PlayerEventHandler::eventFlipCard(const Event_FlipCard &event) emit logFlipCard(player, card->getName(), event.face_down()); card->setFaceDown(event.face_down()); - emit requestCardMenuUpdate(card); + emit requestCardMenuUpdate(toCardItem(card)); } void PlayerEventHandler::eventDestroyCard(const Event_DestroyCard &event) @@ -418,7 +429,7 @@ void PlayerEventHandler::eventDestroyCard(const Event_DestroyCard &event) return; } - CardItem *card = zone->getCard(event.card_id()); + CardState *card = zone->getCard(event.card_id()); if (!card) { return; } @@ -431,7 +442,7 @@ void PlayerEventHandler::eventDestroyCard(const Event_DestroyCard &event) emit logDestroyCard(player, card->getName()); zone->takeCard(-1, event.card_id(), true); - card->getState()->deleteView(); + card->deleteView(); } void PlayerEventHandler::eventAttachCard(const Event_AttachCard &event) @@ -439,7 +450,7 @@ void PlayerEventHandler::eventAttachCard(const Event_AttachCard &event) const QMap &playerList = player->getGame()->getPlayerManager()->getPlayers(); PlayerLogic *targetPlayer = nullptr; CardZoneLogic *targetZone = nullptr; - CardItem *targetCard = nullptr; + CardState *targetCard = nullptr; if (event.has_target_player_id()) { targetPlayer = playerList.value(event.target_player_id(), 0); if (targetPlayer) { @@ -455,14 +466,21 @@ void PlayerEventHandler::eventAttachCard(const Event_AttachCard &event) return; } - CardItem *startCard = startZone->getCard(event.card_id()); + CardState *startCard = startZone->getCard(event.card_id()); if (!startCard) { return; } CardState *oldParent = startCard->getAttachedTo(); - startCard->setAttachedTo(targetCard); + // Route through CardItem::setAttachedTo to keep the attachedCards graph, grid-x reset and zone emits intact: + // CardState::setAttachedTo alone only repoints the pointer, so the target would never list the card and the old + // parent would keep a dangling pointer. + if (CardItem *startItem = toCardItem(startCard)) { + startItem->setAttachedTo(toCardItem(targetCard)); + } else { + startCard->setAttachedTo(targetCard); + } startZone->reorganizeCards(); if ((startZone != targetZone) && targetZone) { @@ -477,7 +495,7 @@ void PlayerEventHandler::eventAttachCard(const Event_AttachCard &event) } else { emit logUnattachCard(player, startCard->getName()); } - emit requestCardMenuUpdate(startCard); + emit requestCardMenuUpdate(toCardItem(startCard)); } void PlayerEventHandler::eventDrawCards(const Event_DrawCards &event) @@ -489,7 +507,7 @@ void PlayerEventHandler::eventDrawCards(const Event_DrawCards &event) if (listSize) { for (int i = 0; i < listSize; ++i) { const ServerInfo_Card &cardInfo = event.cards(i); - CardItem *card = _deck->takeCard(0, cardInfo.id()); + CardState *card = _deck->takeCard(0, cardInfo.id()); QString cardName = QString::fromStdString(cardInfo.name()); QString providerId = QString::fromStdString(cardInfo.provider_id()); card->setCardRef({cardName, providerId}); @@ -537,7 +555,7 @@ void PlayerEventHandler::eventRevealCards(const Event_RevealCards &event, EventP for (const auto &card : cardList) { QString cardName = QString::fromStdString(card->name()); QString providerId = QString::fromStdString(card->provider_id()); - CardItem *cardItem = zone->getCard(card->id()); + CardState *cardItem = zone->getCard(card->id()); if (!cardItem) { continue; } diff --git a/cockatrice/src/game/player/player_event_handler.h b/cockatrice/src/game/player/player_event_handler.h index 37215d61d..6b721bf61 100644 --- a/cockatrice/src/game/player/player_event_handler.h +++ b/cockatrice/src/game/player/player_event_handler.h @@ -239,7 +239,7 @@ signals: void logUndoDraw(PlayerLogic *player, QString cardName); void logUndoDrawFailed(PlayerLogic *player); void logMoveCard(PlayerLogic *player, - CardItem *card, + CardState *card, CardZoneLogic *startZone, int oldX, CardZoneLogic *targetZone, @@ -249,11 +249,11 @@ signals: void logAttachCard(PlayerLogic *player, QString cardName, PlayerLogic *targetPlayer, QString targetCardName); void logUnattachCard(PlayerLogic *player, QString cardName); void logSetCardCounter(PlayerLogic *player, QString cardName, int counterId, int value, int oldValue); - void logSetTapped(PlayerLogic *player, CardItem *card, bool tapped); + void logSetTapped(PlayerLogic *player, CardState *card, bool tapped); void logSetCounter(PlayerLogic *player, QString counterName, int value, int oldValue); - void logSetDoesntUntap(PlayerLogic *player, CardItem *card, bool doesntUntap); - void logSetPT(PlayerLogic *player, CardItem *card, QString newPT); - void logSetAnnotation(PlayerLogic *player, CardItem *card, QString newAnnotation); + void logSetDoesntUntap(PlayerLogic *player, CardState *card, bool doesntUntap); + void logSetPT(PlayerLogic *player, CardState *card, QString newPT); + void logSetAnnotation(PlayerLogic *player, CardState *card, QString newAnnotation); void logDumpZone(PlayerLogic *player, CardZoneLogic *zone, int numberCards, bool isReversed = false); void logRevealCards(PlayerLogic *player, CardZoneLogic *zone, @@ -283,7 +283,7 @@ private: PlayerLogic *player; void setCardAttrHelper(const GameEventContext &context, - CardItem *card, + CardState *card, CardAttribute attribute, const QString &avalue, bool allCards, diff --git a/cockatrice/src/game/player/player_logic.cpp b/cockatrice/src/game/player/player_logic.cpp index 552e9f44c..f6a3a50e7 100644 --- a/cockatrice/src/game/player/player_logic.cpp +++ b/cockatrice/src/game/player/player_logic.cpp @@ -33,6 +33,11 @@ #include #include +static CardItem *toCardItem(CardState *card) +{ + return card == nullptr ? nullptr : qobject_cast(card->parent()); +} + PlayerLogic::PlayerLogic(const ServerInfo_User &info, int _id, bool _local, bool _judge, AbstractGame *_parent) : QObject(_parent), game(_parent), playerInfo(new PlayerInfo(info, _id, _local, _judge)), playerEventHandler(new PlayerEventHandler(this)), playerActions(new PlayerActions(this)), active(false), @@ -172,7 +177,7 @@ void PlayerLogic::processPlayerInfo(const ServerInfo_Player &info) const int cardListSize = zoneInfo.card_list_size(); if (!cardListSize) { for (int j = 0; j < zoneInfo.card_count(); ++j) { - zone->addCard(new CardItem(this), false, -1); + zone->addCard((new CardItem(this))->getState(), false, -1); } } else { for (int j = 0; j < cardListSize; ++j) { @@ -184,9 +189,9 @@ void PlayerLogic::processPlayerInfo(const ServerInfo_Player &info) // The x coordinate of such cards is always 0, so inserting at it // would reverse the list on reconnect. Append instead. if (zoneInfo.with_coords()) { - zone->addCard(card, false, cardInfo.x(), cardInfo.y()); + zone->addCard(card->getState(), false, cardInfo.x(), cardInfo.y()); } else { - zone->addCard(card, false, -1); + zone->addCard(card->getState(), false, -1); } } } @@ -219,15 +224,21 @@ void PlayerLogic::processCardAttachment(const ServerInfo_Player &info) for (int j = 0; j < cardListSize; ++j) { const ServerInfo_Card &cardInfo = zoneInfo.card_list(j); if (cardInfo.has_attach_player_id()) { - CardItem *startCard = zone->getCard(cardInfo.id()); - CardItem *targetCard = + CardState *startCard = zone->getCard(cardInfo.id()); + CardState *targetCard = game->getCard(cardInfo.attach_player_id(), QString::fromStdString(cardInfo.attach_zone()), cardInfo.attach_card_id()); if (!targetCard) { continue; } - startCard->setAttachedTo(targetCard); + // Route through CardItem::setAttachedTo to maintain the attachedCards graph and reparenting; + // CardState::setAttachedTo alone only repoints the pointer. + if (CardItem *startItem = toCardItem(startCard)) { + startItem->setAttachedTo(toCardItem(targetCard)); + } else { + startCard->setAttachedTo(targetCard); + } } } } @@ -244,14 +255,14 @@ void PlayerLogic::addCard(CardItem *card) emit newCardAdded(card); } -void PlayerLogic::deleteCard(CardItem *card) +void PlayerLogic::deleteCard(CardState *card) { if (card == nullptr) { return; } else if (dialogSemaphore) { cardsToDelete.append(card); } else { - card->getState()->deleteView(); + card->deleteView(); } } @@ -332,7 +343,7 @@ bool PlayerLogic::clearCardsToDelete() for (auto &i : cardsToDelete) { if (i != nullptr) { - i->getState()->deleteView(); + i->deleteView(); } } cardsToDelete.clear(); diff --git a/cockatrice/src/game/player/player_logic.h b/cockatrice/src/game/player/player_logic.h index d516fede0..ceaeff10b 100644 --- a/cockatrice/src/game/player/player_logic.h +++ b/cockatrice/src/game/player/player_logic.h @@ -104,7 +104,7 @@ public: void processCardAttachment(const ServerInfo_Player &info); void addCard(CardItem *c); - void deleteCard(CardItem *c); + void deleteCard(CardState *c); bool clearCardsToDelete(); @@ -256,7 +256,7 @@ private: QMap counters; bool dialogSemaphore; - QList cardsToDelete; + QList cardsToDelete; // Playmat from player properties (for opponent display) CardRef remotePlaymatCard; diff --git a/cockatrice/src/game/zones/card_zone_logic.cpp b/cockatrice/src/game/zones/card_zone_logic.cpp index c4431f20a..8cce9f957 100644 --- a/cockatrice/src/game/zones/card_zone_logic.cpp +++ b/cockatrice/src/game/zones/card_zone_logic.cpp @@ -41,7 +41,7 @@ CardZoneLogic::CardZoneLogic(PlayerLogic *_player, &CardZoneLogic::refreshCardInfos); } -void CardZoneLogic::addCard(CardItem *card, const bool reorganize, const int x, const int y) +void CardZoneLogic::addCard(CardState *card, const bool reorganize, const int x, const int y) { if (!card) { qCWarning(CardZoneLogicLog) << "CardZoneLogic::addCard() card is null; this shouldn't normally happen"; @@ -53,7 +53,7 @@ void CardZoneLogic::addCard(CardItem *card, const bool reorganize, const int x, auto copy = new CardItem(player, nullptr, card->getCardRef(), card->getId()); copy->setFaceDown(card->getFaceDown()); - viewLogic->addCard(copy, reorganize, x, y); + viewLogic->addCard(copy->getState(), reorganize, x, y); } } @@ -68,7 +68,7 @@ void CardZoneLogic::addCard(CardItem *card, const bool reorganize, const int x, emit cardCountChanged(); } -CardItem *CardZoneLogic::takeCard(int position, int cardId, bool toNewZone) +CardState *CardZoneLogic::takeCard(int position, int cardId, bool toNewZone) { if (position == -1) { // position == -1 means either that the zone is indexed by card id @@ -91,7 +91,7 @@ CardItem *CardZoneLogic::takeCard(int position, int cardId, bool toNewZone) viewLogic->removeCard(position, toNewZone); } - CardItem *c = cards.takeAt(position); + CardState *c = cards.takeAt(position); c->setId(cardId); @@ -100,9 +100,9 @@ CardItem *CardZoneLogic::takeCard(int position, int cardId, bool toNewZone) return c; } -CardItem *CardZoneLogic::getCard(int cardId) +CardState *CardZoneLogic::getCard(int cardId) { - CardItem *c = cards.findCard(cardId); + CardState *c = cards.findCard(cardId); if (!c) { qCWarning(CardZoneLogicLog) << "CardZoneLogic::getCard: card id=" << cardId << "not found"; return nullptr; @@ -116,7 +116,7 @@ CardItem *CardZoneLogic::getCard(int cardId) return c; } -void CardZoneLogic::removeCard(CardItem *card) +void CardZoneLogic::removeCard(CardState *card) { if (!card) { qCWarning(CardZoneLogicLog) << "CardZoneLogic::removeCard: card is null, this shouldn't normally happen"; @@ -132,8 +132,8 @@ void CardZoneLogic::removeCard(CardItem *card) void CardZoneLogic::refreshCardInfos() { - for (const auto &cardItem : cards) { - cardItem->refreshCardInfo(); + for (const auto &cardState : cards) { + cardState->refreshCardInfo(); } } @@ -165,17 +165,17 @@ void CardZoneLogic::clearContents() const CardList toClear = cards; // Detach and notify attached cards and zones *before* deleting anything. - for (CardItem *card : toClear) { + for (CardState *card : toClear) { // If an incorrectly implemented server doesn't return attached cards to whom they belong before dropping a // player, we have to return them to avoid a crash. const QList &attachedCards = card->getAttachedCards(); for (CardItem *attachedCard : attachedCards) { - emit attachedCard->getZone()->cardAdded(attachedCard); + emit attachedCard->getZone()->cardAdded(attachedCard->getState()); } } // Now request deletions after all manipulations are done. - for (CardItem *card : toClear) { + for (CardState *card : toClear) { player->deleteCard(card); } diff --git a/cockatrice/src/game/zones/card_zone_logic.h b/cockatrice/src/game/zones/card_zone_logic.h index e01eec74c..2a6b50c5c 100644 --- a/cockatrice/src/game/zones/card_zone_logic.h +++ b/cockatrice/src/game/zones/card_zone_logic.h @@ -32,7 +32,7 @@ class CardZoneLogic : public QObject Q_OBJECT signals: - void cardAdded(CardItem *addedCard); + void cardAdded(CardState *addedCard); void cardCountChanged(); void reorganizeCards(); void updateGraphics(); @@ -47,14 +47,14 @@ public: bool _contentsKnown, QObject *parent = nullptr); - void addCard(CardItem *card, bool reorganize, int x, int y = -1); + void addCard(CardState *card, bool reorganize, int x, int y = -1); // getCard() finds a card by id. - CardItem *getCard(int cardId); - void removeCard(CardItem *card); + CardState *getCard(int cardId); + void removeCard(CardState *card); // takeCard() finds a card by position and removes it from the zone and from all of its views. - virtual CardItem *takeCard(int position, int cardId, bool canResize = true); + virtual CardState *takeCard(int position, int cardId, bool canResize = true); - void rawInsertCard(CardItem *card, int index) + void rawInsertCard(CardState *card, int index) { cards.insert(index, card); emit cardCountChanged(); @@ -120,7 +120,7 @@ protected: bool isShufflable; bool alwaysRevealTopCard; - virtual void addCardImpl(CardItem *card, int x, int y) = 0; + virtual void addCardImpl(CardState *card, int x, int y) = 0; }; #endif // COCKATRICE_CARD_ZONE_LOGIC_H diff --git a/cockatrice/src/game/zones/hand_zone_logic.cpp b/cockatrice/src/game/zones/hand_zone_logic.cpp index cea17d031..1d3f54929 100644 --- a/cockatrice/src/game/zones/hand_zone_logic.cpp +++ b/cockatrice/src/game/zones/hand_zone_logic.cpp @@ -1,6 +1,6 @@ #include "hand_zone_logic.h" -#include "../../game_graphics/board/card_item.h" +#include "../board/card_state.h" #include "card_zone_algorithms.h" class PlayerLogic; @@ -16,7 +16,7 @@ HandZoneLogic::HandZoneLogic(PlayerLogic *_player, { } -void HandZoneLogic::addCardImpl(CardItem *card, int x, int /*y*/) +void HandZoneLogic::addCardImpl(CardState *card, int x, int /*y*/) { CardZoneAlgorithms::addCardToList(cards, card, x, false); } diff --git a/cockatrice/src/game/zones/hand_zone_logic.h b/cockatrice/src/game/zones/hand_zone_logic.h index c7d63bc0d..f8756eafd 100644 --- a/cockatrice/src/game/zones/hand_zone_logic.h +++ b/cockatrice/src/game/zones/hand_zone_logic.h @@ -26,7 +26,7 @@ public: QObject *parent = nullptr); protected: - void addCardImpl(CardItem *card, int x, int y) override; + void addCardImpl(CardState *card, int x, int y) override; }; #endif // COCKATRICE_HAND_ZONE_LOGIC_H diff --git a/cockatrice/src/game/zones/pile_zone_logic.cpp b/cockatrice/src/game/zones/pile_zone_logic.cpp index acb9f529b..42ef4f0b2 100644 --- a/cockatrice/src/game/zones/pile_zone_logic.cpp +++ b/cockatrice/src/game/zones/pile_zone_logic.cpp @@ -1,7 +1,7 @@ #include "pile_zone_logic.h" -#include "../../game_graphics/board/card_item.h" #include "../board/card_list.h" +#include "../board/card_state.h" class PlayerLogic; class QObject; @@ -16,9 +16,9 @@ PileZoneLogic::PileZoneLogic(PlayerLogic *_player, { } -void PileZoneLogic::addCardImpl(CardItem *card, int x, int /*y*/) +void PileZoneLogic::addCardImpl(CardState *card, int x, int /*y*/) { - connect(card->getState(), &CardState::cardPixmapUpdated, this, &PileZoneLogic::callUpdate); + connect(card, &CardState::cardPixmapUpdated, this, &PileZoneLogic::callUpdate); // if x is negative set it to add at end if (x < 0 || x >= cards.size()) { x = cards.size(); @@ -32,5 +32,5 @@ void PileZoneLogic::addCardImpl(CardItem *card, int x, int /*y*/) cards.at(x + 1)->setCardRef({}); } } - card->resetState(); + card->resetState(false); } \ No newline at end of file diff --git a/cockatrice/src/game/zones/pile_zone_logic.h b/cockatrice/src/game/zones/pile_zone_logic.h index ed6841b75..662b0377f 100644 --- a/cockatrice/src/game/zones/pile_zone_logic.h +++ b/cockatrice/src/game/zones/pile_zone_logic.h @@ -31,7 +31,7 @@ public: QObject *parent = nullptr); protected: - void addCardImpl(CardItem *card, int x, int y) override; + void addCardImpl(CardState *card, int x, int y) override; }; #endif // COCKATRICE_PILE_ZONE_LOGIC_H diff --git a/cockatrice/src/game/zones/stack_zone_logic.cpp b/cockatrice/src/game/zones/stack_zone_logic.cpp index 454274971..ccf0d32f3 100644 --- a/cockatrice/src/game/zones/stack_zone_logic.cpp +++ b/cockatrice/src/game/zones/stack_zone_logic.cpp @@ -1,6 +1,6 @@ #include "stack_zone_logic.h" -#include "../../game_graphics/board/card_item.h" +#include "../board/card_state.h" #include "card_zone_algorithms.h" class PlayerLogic; @@ -16,7 +16,7 @@ StackZoneLogic::StackZoneLogic(PlayerLogic *_player, { } -void StackZoneLogic::addCardImpl(CardItem *card, int x, int /*y*/) +void StackZoneLogic::addCardImpl(CardState *card, int x, int /*y*/) { CardZoneAlgorithms::addCardToList(cards, card, x, true); } diff --git a/cockatrice/src/game/zones/stack_zone_logic.h b/cockatrice/src/game/zones/stack_zone_logic.h index 657d44ecf..ac6c4617c 100644 --- a/cockatrice/src/game/zones/stack_zone_logic.h +++ b/cockatrice/src/game/zones/stack_zone_logic.h @@ -26,7 +26,7 @@ public: QObject *parent = nullptr); protected: - void addCardImpl(CardItem *card, int x, int y) override; + void addCardImpl(CardState *card, int x, int y) override; }; #endif // COCKATRICE_STACK_ZONE_LOGIC_H diff --git a/cockatrice/src/game/zones/table_zone_logic.cpp b/cockatrice/src/game/zones/table_zone_logic.cpp index 83e5e55eb..7a6e74adf 100644 --- a/cockatrice/src/game/zones/table_zone_logic.cpp +++ b/cockatrice/src/game/zones/table_zone_logic.cpp @@ -1,7 +1,7 @@ #include "table_zone_logic.h" -#include "../../game_graphics/board/card_item.h" #include "../board/card_list.h" +#include "../board/card_state.h" #include "libcockatrice/card/card_info.h" #include @@ -19,7 +19,7 @@ TableZoneLogic::TableZoneLogic(PlayerLogic *_player, { } -void TableZoneLogic::addCardImpl(CardItem *card, int _x, int _y) +void TableZoneLogic::addCardImpl(CardState *card, int _x, int _y) { cards.append(card); if (!card->getFaceDown() && card->getPT().isEmpty()) { @@ -31,9 +31,9 @@ void TableZoneLogic::addCardImpl(CardItem *card, int _x, int _y) card->setGridPoint(QPoint(_x, _y)); } -CardItem *TableZoneLogic::takeCard(int position, int cardId, bool toNewZone) +CardState *TableZoneLogic::takeCard(int position, int cardId, bool toNewZone) { - CardItem *result = CardZoneLogic::takeCard(position, cardId); + CardState *result = CardZoneLogic::takeCard(position, cardId); if (toNewZone) { emit contentSizeChanged(); diff --git a/cockatrice/src/game/zones/table_zone_logic.h b/cockatrice/src/game/zones/table_zone_logic.h index 353a497e9..c828d8b91 100644 --- a/cockatrice/src/game/zones/table_zone_logic.h +++ b/cockatrice/src/game/zones/table_zone_logic.h @@ -45,7 +45,7 @@ public: static int tableRowToGridY(int tableRow); protected: - void addCardImpl(CardItem *card, int x, int y) override; + void addCardImpl(CardState *card, int x, int y) override; /** * @brief Removes a card from view. @@ -53,9 +53,9 @@ protected: * @param position card position * @param cardId id of card to take * @param toNewZone Whether the destination of the card is not the same as the starting zone. Defaults to true - * @return CardItem that has been removed + * @return CardState that has been removed */ - CardItem *takeCard(int position, int cardId, bool toNewZone = true) override; + CardState *takeCard(int position, int cardId, bool toNewZone = true) override; }; #endif // COCKATRICE_TABLE_ZONE_LOGIC_H diff --git a/cockatrice/src/game/zones/view_zone_logic.cpp b/cockatrice/src/game/zones/view_zone_logic.cpp index f5e597abe..5e5e0b5fe 100644 --- a/cockatrice/src/game/zones/view_zone_logic.cpp +++ b/cockatrice/src/game/zones/view_zone_logic.cpp @@ -1,8 +1,8 @@ #include "view_zone_logic.h" #include "../../client/settings/cache_settings.h" -#include "../../game_graphics/board/card_item.h" #include "../board/card_list.h" +#include "../board/card_state.h" #include "../player/player_actions.h" #include "../player/player_logic.h" @@ -77,7 +77,7 @@ bool ZoneViewZoneLogic::prepareAddCard(int x) * Make sure prepareAddCard() was called before calling addCard(). * This method assumes we already checked that the card is being inserted into the visible portion */ -void ZoneViewZoneLogic::addCardImpl(CardItem *card, int x, int /*y*/) +void ZoneViewZoneLogic::addCardImpl(CardState *card, int x, int /*y*/) { if (!isReversed) { // if x is negative set it to add at end @@ -146,8 +146,8 @@ void ZoneViewZoneLogic::removeCard(int position, bool toNewZone) return; } - CardItem *card = cards.takeAt(position); - card->getState()->deleteView(); + CardState *card = cards.takeAt(position); + card->deleteView(); // The toNewZone check is to prevent the view from auto-closing if the view contains only a single card and that // card gets dragged within the view. @@ -170,7 +170,7 @@ void ZoneViewZoneLogic::requestClose() void ZoneViewZoneLogic::clearCards() { for (auto card : cards) { - card->getState()->deleteView(); + card->deleteView(); } cards.clear(); emit cardCountChanged(); diff --git a/cockatrice/src/game/zones/view_zone_logic.h b/cockatrice/src/game/zones/view_zone_logic.h index 71261e204..66d4d4791 100644 --- a/cockatrice/src/game/zones/view_zone_logic.h +++ b/cockatrice/src/game/zones/view_zone_logic.h @@ -92,7 +92,7 @@ public: PendingCommand *requestZoneDump(); protected: - void addCardImpl(CardItem *card, int x, int y) override; + void addCardImpl(CardState *card, int x, int y) override; }; #endif // COCKATRICE_VIEW_ZONE_LOGIC_H diff --git a/cockatrice/src/game_graphics/board/card_item.cpp b/cockatrice/src/game_graphics/board/card_item.cpp index 0b2b3cf82..9a308ba51 100644 --- a/cockatrice/src/game_graphics/board/card_item.cpp +++ b/cockatrice/src/game_graphics/board/card_item.cpp @@ -71,6 +71,10 @@ CardItem::CardItem(PlayerLogic *_owner, update(); }); + // The retyped logic layer drives CardState setters directly (bypassing the CardItem wrappers that called + // update()), so repaint on every state change: attacking, annotation, doesntUntap, P/T and counters. + connect(state, &CardState::stateChanged, this, [this] { update(); }); + connect(state, &CardState::viewDeleteRequested, this, &CardItem::deleteLater); } @@ -233,7 +237,7 @@ void CardItem::setAttachedTo(CardItem *_attachedTo) if (state->getAttachedTo()->getZone() == nullptr) { deleteLater(); } else { - emit state->getAttachedTo()->getZone()->cardAdded(this); + emit state->getAttachedTo()->getZone()->cardAdded(state); state->getAttachedTo()->addAttachedCard(this); if (state->getZone() != state->getAttachedTo()->getZone()) { state->getAttachedTo()->getZone()->reorganizeCards(); @@ -245,7 +249,7 @@ void CardItem::setAttachedTo(CardItem *_attachedTo) if (state->getZone() == nullptr) { deleteLater(); } else { - emit state->getZone()->cardAdded(this); + emit state->getZone()->cardAdded(state); } } @@ -467,7 +471,7 @@ void CardItem::handleClickedToPlay(bool shiftHeld) if (SettingsCache::instance().userInterface().getClickPlaysAllSelected()) { emit hideSelected(this); } else { - state->getZone()->removeCard(this); + state->getZone()->removeCard(state); } } else { playCard(shiftHeld); diff --git a/cockatrice/src/game_graphics/game_scene.cpp b/cockatrice/src/game_graphics/game_scene.cpp index f54e3ae28..3422ce28f 100644 --- a/cockatrice/src/game_graphics/game_scene.cpp +++ b/cockatrice/src/game_graphics/game_scene.cpp @@ -137,7 +137,8 @@ static void selectCardsInZone( return; } - for (auto &cardItem : zone->getCards()) { + for (auto *cardState : zone->getCards()) { + auto *cardItem = cardState == nullptr ? nullptr : qobject_cast(cardState->parent()); if (cardItem && filter(cardItem)) { cardItem->setSelected(true); } @@ -596,7 +597,11 @@ void GameScene::addArrow(QSharedPointer data) return; } - CardItem *startCard = startZone->getCard(data->startCardId); + CardState *startCardState = startZone->getCard(data->startCardId); + if (!startCardState) { + return; + } + CardItem *startCard = qobject_cast(startCardState->parent()); if (!startCard) { return; } @@ -607,7 +612,8 @@ void GameScene::addArrow(QSharedPointer data) } else { auto *zone = targetView->getLogic()->getZones().value(data->targetZone); if (zone) { - targetItem = zone->getCard(data->targetCardId); + CardState *targetCardState = zone->getCard(data->targetCardId); + targetItem = targetCardState == nullptr ? nullptr : qobject_cast(targetCardState->parent()); } } if (!targetItem) { diff --git a/cockatrice/src/game_graphics/log/message_log_widget.cpp b/cockatrice/src/game_graphics/log/message_log_widget.cpp index 4a9750fca..ab244dc0b 100644 --- a/cockatrice/src/game_graphics/log/message_log_widget.cpp +++ b/cockatrice/src/game_graphics/log/message_log_widget.cpp @@ -283,7 +283,7 @@ void MessageLogWidget::logDestroyCard(PlayerLogic *player, QString cardName) } void MessageLogWidget::logMoveCard(PlayerLogic *player, - CardItem *card, + CardState *card, CardZoneLogic *startZone, int oldX, CardZoneLogic *targetZone, @@ -662,7 +662,7 @@ void MessageLogWidget::logSetActivePlayer(PlayerLogic *player) QString(tr("%1's turn.")).arg(player->getPlayerInfo()->getName()) + "
"); } -void MessageLogWidget::logSetAnnotation(PlayerLogic *player, CardItem *card, QString newAnnotation) +void MessageLogWidget::logSetAnnotation(PlayerLogic *player, CardState *card, QString newAnnotation) { appendHtmlServerMessage( QString(tr("%1 sets annotation of %2 to %3.")) @@ -706,7 +706,7 @@ void MessageLogWidget::logSetCounter(PlayerLogic *player, QString counterName, i .arg(value - oldValue)); } -void MessageLogWidget::logSetDoesntUntap(PlayerLogic *player, CardItem *card, bool doesntUntap) +void MessageLogWidget::logSetDoesntUntap(PlayerLogic *player, CardState *card, bool doesntUntap) { QString str; if (doesntUntap) { @@ -717,7 +717,7 @@ void MessageLogWidget::logSetDoesntUntap(PlayerLogic *player, CardItem *card, bo appendHtmlServerMessage(str.arg(sanitizeHtml(player->getPlayerInfo()->getName())).arg(cardLink(card->getName()))); } -void MessageLogWidget::logSetPT(PlayerLogic *player, CardItem *card, QString newPT) +void MessageLogWidget::logSetPT(PlayerLogic *player, CardState *card, QString newPT) { if (currentContext == MessageContext_MoveCard) { return; @@ -755,7 +755,7 @@ void MessageLogWidget::logSetSideboardLock(PlayerLogic *player, bool locked) } } -void MessageLogWidget::logSetTapped(PlayerLogic *player, CardItem *card, bool tapped) +void MessageLogWidget::logSetTapped(PlayerLogic *player, CardState *card, bool tapped) { if (currentContext == MessageContext_MoveCard) { return; diff --git a/cockatrice/src/game_graphics/log/message_log_widget.h b/cockatrice/src/game_graphics/log/message_log_widget.h index 6800ba8ed..5642a76e9 100644 --- a/cockatrice/src/game_graphics/log/message_log_widget.h +++ b/cockatrice/src/game_graphics/log/message_log_widget.h @@ -14,6 +14,7 @@ #include #include +class CardState; class AbstractGame; class CardItem; class GameEventContext; @@ -76,7 +77,7 @@ public slots: void logLeaveSpectator(QString name, QString reason); void logNotReadyStart(PlayerLogic *player); void logMoveCard(PlayerLogic *player, - CardItem *card, + CardState *card, CardZoneLogic *startZone, int oldX, CardZoneLogic *targetZone, @@ -97,13 +98,13 @@ public slots: void logSay(PlayerLogic *player, QString message); void logSetActivePhase(int phase); void logSetActivePlayer(PlayerLogic *player); - void logSetAnnotation(PlayerLogic *player, CardItem *card, QString newAnnotation); + void logSetAnnotation(PlayerLogic *player, CardState *card, QString newAnnotation); void logSetCardCounter(PlayerLogic *player, QString cardName, int counterId, int value, int oldValue); void logSetCounter(PlayerLogic *player, QString counterName, int value, int oldValue); - void logSetDoesntUntap(PlayerLogic *player, CardItem *card, bool doesntUntap); - void logSetPT(PlayerLogic *player, CardItem *card, QString newPT); + void logSetDoesntUntap(PlayerLogic *player, CardState *card, bool doesntUntap); + void logSetPT(PlayerLogic *player, CardState *card, QString newPT); void logSetSideboardLock(PlayerLogic *player, bool locked); - void logSetTapped(PlayerLogic *player, CardItem *card, bool tapped); + void logSetTapped(PlayerLogic *player, CardState *card, bool tapped); void logShuffle(PlayerLogic *player, CardZoneLogic *zone, int start, int end); void logSpectatorSay(const ServerInfo_User &spectator, QString message); void logUnattachCard(PlayerLogic *player, QString cardName); diff --git a/cockatrice/src/game_graphics/player/player_graphics_item.cpp b/cockatrice/src/game_graphics/player/player_graphics_item.cpp index 419816087..14c0dba1c 100644 --- a/cockatrice/src/game_graphics/player/player_graphics_item.cpp +++ b/cockatrice/src/game_graphics/player/player_graphics_item.cpp @@ -177,6 +177,22 @@ void PlayerGraphicsItem::initializeZones() void PlayerGraphicsItem::onCustomZoneAdded(QString customZoneName) { + // Custom zones have no board view, but their cards still need a graphics owner. + // Without one, PileZone::onCardAdded never runs and cards moved from the table + // or hand into a custom zone stay drawn at their old spot. Give them a hidden + // pile zone so added cards are reparented to (0, 0) and hidden. + CardZoneLogic *logic = player->getZone(customZoneName); + if (auto *pileLogic = qobject_cast(logic)) { + auto *hiddenZone = new PileZone(pileLogic, this); + hiddenZone->setVisible(false); + hiddenZone->setPos(0, 0); + zoneGraphicsItems.insert(customZoneName, hiddenZone); + // Hide any cards that arrived before the graphics item existed. + for (CardState *state : pileLogic->getCards()) { + hiddenZone->onCardAdded(state); + } + return; + } zoneGraphicsItems.insert(customZoneName, nullptr); // Custom zone view goes here, if we ever implement it. } diff --git a/cockatrice/src/game_graphics/zones/card_zone.cpp b/cockatrice/src/game_graphics/zones/card_zone.cpp index 8ab942a67..b29ad3666 100644 --- a/cockatrice/src/game_graphics/zones/card_zone.cpp +++ b/cockatrice/src/game_graphics/zones/card_zone.cpp @@ -37,17 +37,26 @@ void CardZone::invalidateItems() const QList &CardZone::cardItems() const { if (itemsDirty) { - items = getLogic()->getCards(); + items.clear(); + for (auto *cardState : getLogic()->getCards()) { + if (auto *cardItem = qobject_cast(cardState->parent())) { + items.append(cardItem); + } + } itemsDirty = false; } return items; } -void CardZone::onCardAdded(CardItem *addedCard) +void CardZone::onCardAdded(CardState *addedCard) { - addedCard->setParentItem(this); - addedCard->setVisible(true); - addedCard->update(); + auto *addedItem = addedCard == nullptr ? nullptr : qobject_cast(addedCard->parent()); + if (addedItem == nullptr) { + return; + } + addedItem->setParentItem(this); + addedItem->setVisible(true); + addedItem->update(); } void CardZone::retranslateUi() diff --git a/cockatrice/src/game_graphics/zones/card_zone.h b/cockatrice/src/game_graphics/zones/card_zone.h index 950b54446..7568cf25f 100644 --- a/cockatrice/src/game_graphics/zones/card_zone.h +++ b/cockatrice/src/game_graphics/zones/card_zone.h @@ -16,6 +16,7 @@ class CardDragItem; class CardItem; +class CardState; class CardZoneLogic; class QAction; class QGraphicsItem; @@ -23,6 +24,8 @@ class QMenu; class QPoint; template class QList; +class CardItem; + inline Q_LOGGING_CATEGORY(CardZoneLog, "card_zone"); /** @@ -55,7 +58,7 @@ public slots: * Virtual so subclasses (e.g. SelectZone) can override parenting behavior — the Qt signal * connection in CardZone's constructor dispatches through the vtable. */ - virtual void onCardAdded(CardItem *addedCard); + virtual void onCardAdded(CardState *addedCard); public: enum diff --git a/cockatrice/src/game_graphics/zones/pile_zone.cpp b/cockatrice/src/game_graphics/zones/pile_zone.cpp index 7696ceafd..61946bea1 100644 --- a/cockatrice/src/game_graphics/zones/pile_zone.cpp +++ b/cockatrice/src/game_graphics/zones/pile_zone.cpp @@ -87,12 +87,13 @@ void PileZone::handleDropEvent(const QList &dragItems, CardZoneL getLogic()->getPlayer()->getPlayerActions()->moveCards(startZone, getLogic(), 0, 0, cards); } -void PileZone::onCardAdded(CardItem *addedCard) +void PileZone::onCardAdded(CardState *addedCard) { CardZone::onCardAdded(addedCard); - if (addedCard) { - addedCard->setPos(0, 0); - addedCard->setVisible(false); + auto *addedItem = addedCard == nullptr ? nullptr : qobject_cast(addedCard->parent()); + if (addedItem) { + addedItem->setPos(0, 0); + addedItem->setVisible(false); } } diff --git a/cockatrice/src/game_graphics/zones/pile_zone.h b/cockatrice/src/game_graphics/zones/pile_zone.h index 5dc02a0e4..fa510ad52 100644 --- a/cockatrice/src/game_graphics/zones/pile_zone.h +++ b/cockatrice/src/game_graphics/zones/pile_zone.h @@ -33,7 +33,7 @@ public: [[nodiscard]] QPainterPath shape() const override; void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; void reorganizeCards() override; - void onCardAdded(CardItem *addedCard) override; + void onCardAdded(CardState *addedCard) override; void handleDropEvent(const QList &dragItems, CardZoneLogic *startZone, const QPoint &dropPoint) override; diff --git a/cockatrice/src/game_graphics/zones/select_zone.cpp b/cockatrice/src/game_graphics/zones/select_zone.cpp index 9b0c4e60a..7a74c5342 100644 --- a/cockatrice/src/game_graphics/zones/select_zone.cpp +++ b/cockatrice/src/game_graphics/zones/select_zone.cpp @@ -172,12 +172,13 @@ SelectZone::~SelectZone() } } -void SelectZone::onCardAdded(CardItem *addedCard) +void SelectZone::onCardAdded(CardState *addedCard) { - if (cardClipContainer && addedCard) { - addedCard->setParentItem(cardClipContainer); - addedCard->setVisible(true); - addedCard->update(); + auto *addedItem = addedCard == nullptr ? nullptr : qobject_cast(addedCard->parent()); + if (cardClipContainer && addedItem) { + addedItem->setParentItem(cardClipContainer); + addedItem->setVisible(true); + addedItem->update(); } else { CardZone::onCardAdded(addedCard); } diff --git a/cockatrice/src/game_graphics/zones/select_zone.h b/cockatrice/src/game_graphics/zones/select_zone.h index 8a56949e3..8c81f0dbf 100644 --- a/cockatrice/src/game_graphics/zones/select_zone.h +++ b/cockatrice/src/game_graphics/zones/select_zone.h @@ -35,7 +35,7 @@ public: SelectZone(CardZoneLogic *logic, QGraphicsItem *parent = nullptr); ~SelectZone() override; - void onCardAdded(CardItem *addedCard) override; + void onCardAdded(CardState *addedCard) override; /** * @brief Temporarily reparents a card from the clip container to this zone so hover scaling is visible beyond clip diff --git a/cockatrice/src/game_graphics/zones/view_zone.cpp b/cockatrice/src/game_graphics/zones/view_zone.cpp index 7d47b3259..27041e04c 100644 --- a/cockatrice/src/game_graphics/zones/view_zone.cpp +++ b/cockatrice/src/game_graphics/zones/view_zone.cpp @@ -95,7 +95,7 @@ void ZoneViewZone::initializeCards(const QList &cardLis auto copy = new CardItem(getLogic()->getPlayer(), this, cardRef, card->id()); copy->setFaceDown(card->face_down()); - getLogic()->addCard(copy, false, i); + getLogic()->addCard(copy->getState(), false, i); } reorganizeCards(); } else if (!qobject_cast(getLogic())->getOriginalZone()->contentsKnown()) { @@ -107,11 +107,11 @@ void ZoneViewZone::initializeCards(const QList &cardLis const CardList &c = qobject_cast(getLogic())->getOriginalZone()->getCards(); int number = numberCards == -1 ? c.size() : (numberCards < c.size() ? numberCards : c.size()); for (int i = 0; i < number; i++) { - CardItem *card = c.at(i); + CardState *card = c.at(i); auto copy = new CardItem(getLogic()->getPlayer(), this, card->getCardRef(), card->getId()); copy->setFaceDown(card->getFaceDown()); - getLogic()->addCard(copy, false, i); + getLogic()->addCard(copy->getState(), false, i); } reorganizeCards(); } @@ -127,7 +127,7 @@ void ZoneViewZone::zoneDumpReceived(const Response &r) auto cardProviderId = QString::fromStdString(cardInfo.provider_id()); auto card = new CardItem(getLogic()->getPlayer(), this, {cardName, cardProviderId}, cardInfo.id(), getLogic()); card->setFaceDown(cardInfo.face_down()); - getLogic()->rawInsertCard(card, i); + getLogic()->rawInsertCard(card->getState(), i); } qobject_cast(getLogic())->updateCardIds(ZoneViewZoneLogic::INITIALIZE); @@ -145,7 +145,7 @@ void ZoneViewZone::reorganizeCards() for (auto card : cardItems()) { if (filterString.check(card->getCard().getCardPtr())) { card->show(); - cardsToDisplay.append(card); + cardsToDisplay.append(card->getState()); } else { card->hide(); } @@ -213,7 +213,8 @@ ZoneViewZone::GridSize ZoneViewZone::positionCardsForDisplay(CardList &cards, Ca const auto extractor = CardList::getExtractorFor(pileOption); for (int i = 0; i < cardCount; i++) { - CardItem *c = cards.at(i); + CardState *c = cards.at(i); + CardItem *cItem = qobject_cast(c->parent()); QString columnProp = extractor(c); if (i) { // if not the first card @@ -228,8 +229,8 @@ ZoneViewZone::GridSize ZoneViewZone::positionCardsForDisplay(CardList &cards, Ca lastColumnProp = columnProp; qreal x = col * CardDimensions::WIDTH_F; qreal y = row * CardDimensions::HEIGHT_F / 3; - c->setPos(HORIZONTAL_PADDING + x, VERTICAL_PADDING + y); - c->setRealZValue(i); + cItem->setPos(HORIZONTAL_PADDING + x, VERTICAL_PADDING + y); + cItem->setRealZValue(i); longestRow = qMax(row, longestRow); } @@ -254,11 +255,11 @@ ZoneViewZone::GridSize ZoneViewZone::positionCardsForDisplay(CardList &cards, Ca qCDebug(ViewZoneLog) << "reorganizeCards: rows=" << rows << "cols=" << cols; for (int i = 0; i < cardCount; i++) { - CardItem *c = cards.at(i); + auto *cItem = qobject_cast(cards.at(i)->parent()); qreal x = (i / rows) * CardDimensions::WIDTH_F; qreal y = (i % rows) * CardDimensions::HEIGHT_F / 3; - c->setPos(HORIZONTAL_PADDING + x, VERTICAL_PADDING + y); - c->setRealZValue(i); + cItem->setPos(HORIZONTAL_PADDING + x, VERTICAL_PADDING + y); + cItem->setRealZValue(i); } return GridSize{rows, qMax(cols, 1)}; From 7ca23430cdedc6b4fea9ea6dfca579f3269bc327 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:44:14 +0100 Subject: [PATCH 071/108] [Refactor] Retype PlayerActions onto CardState (#7435) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Refactor] Retype card containers and zone logic onto CardState * [Refactor] Retype PlayerActions onto CardState --------- Co-authored-by: Lukas Brübach --- cockatrice/src/game/abstract_game.h | 2 +- cockatrice/src/game/player/player_actions.cpp | 96 +++++++++---------- cockatrice/src/game/player/player_actions.h | 86 ++++++++--------- .../src/game/player/player_event_handler.cpp | 2 +- cockatrice/src/game/zones/pile_zone_logic.cpp | 4 - .../src/game/zones/table_zone_logic.cpp | 5 - cockatrice/src/game/zones/view_zone_logic.cpp | 1 - .../src/game_graphics/board/arrow_item.cpp | 4 +- .../src/game_graphics/board/card_item.cpp | 2 +- cockatrice/src/game_graphics/game_scene.cpp | 15 ++- cockatrice/src/game_graphics/game_scene.h | 1 + .../game_graphics/player/menu/card_menu.cpp | 11 ++- .../game_graphics/player/menu/move_menu.cpp | 2 +- .../src/game_graphics/player/menu/pt_menu.cpp | 23 ++--- .../player/menu/utility_menu.cpp | 2 +- .../game_graphics/player/player_dialogs.cpp | 10 +- .../src/game_graphics/player/player_dialogs.h | 2 +- .../src/game_graphics/zones/table_zone.cpp | 6 +- 18 files changed, 138 insertions(+), 136 deletions(-) diff --git a/cockatrice/src/game/abstract_game.h b/cockatrice/src/game/abstract_game.h index 505bfc1b5..4bf8c120e 100644 --- a/cockatrice/src/game/abstract_game.h +++ b/cockatrice/src/game/abstract_game.h @@ -19,7 +19,7 @@ class GameMetaInfo; class GameReplay; class GameState; class PlayerManager; - +class CardState; class AbstractGame : public QObject { Q_OBJECT diff --git a/cockatrice/src/game/player/player_actions.cpp b/cockatrice/src/game/player/player_actions.cpp index c0f4ef11d..4ccf25ca4 100644 --- a/cockatrice/src/game/player/player_actions.cpp +++ b/cockatrice/src/game/player/player_actions.cpp @@ -5,6 +5,7 @@ #include "../../game_graphics/dialogs/dlg_create_token.h" #include "../abstract_game.h" #include "../board/arrow_data.h" +#include "../board/card_state.h" #include "../board/counter_state.h" #include "../game_event_handler.h" #include "../zones/card_zone_logic.h" @@ -91,7 +92,7 @@ PlayerActions::PlayerActions(PlayerLogic *_player) connect(moveTopCardTimer, &QTimer::timeout, [this]() { actMoveTopCardToPlay(); }); } -void PlayerActions::playCard(CardItem *card, bool faceDown) +void PlayerActions::playCard(CardState *card, bool faceDown) { if (card == nullptr) { return; @@ -144,7 +145,7 @@ void PlayerActions::playCard(CardItem *card, bool faceDown) * Like {@link PlayerActions::playCard}, but forces the card to be played to the table zone. * Cards with tablerow 3 (the stack) will be played to tablerow 1 (the noncreatures row). */ -void PlayerActions::playCardToTable(const CardItem *card, bool faceDown) +void PlayerActions::playCardToTable(const CardState *card, bool faceDown) { if (card == nullptr) { return; @@ -177,7 +178,7 @@ void PlayerActions::playCardToTable(const CardItem *card, bool faceDown) sendGameCommand(cmd); } -void PlayerActions::attachCards(CardItem *startCard, const CardItem *targetCard) +void PlayerActions::attachCards(CardState *startCard, const CardState *targetCard) { if (targetCard->getAttachedTo() || targetCard->getZone()->getName() != ZoneNames::TABLE) { return; @@ -197,7 +198,7 @@ void PlayerActions::attachCards(CardItem *startCard, const CardItem *targetCard) sendGameCommand(cmd); } -void PlayerActions::createArrow(CardItem *startCard, const ArrowData &arrow, int deleteInPhase) +void PlayerActions::createArrow(CardState *startCard, const ArrowData &arrow, int deleteInPhase) { Command_CreateArrow cmd; cmd.mutable_arrow_color()->CopyFrom(convertQColorToColor(arrow.color)); @@ -603,7 +604,7 @@ void PlayerActions::moveTopCardsUntil(const QString &expr, MoveTopCardsUntilOpti } } -void PlayerActions::moveOneCardUntil(CardItem *card) +void PlayerActions::moveOneCardUntil(CardState *card) { moveTopCardTimer->stop(); @@ -1000,7 +1001,7 @@ void PlayerActions::actCreatePredefinedToken() actCreateAnotherToken(); } -void PlayerActions::actCreateRelatedCard(const CardItem *sourceCard, int index) +void PlayerActions::actCreateRelatedCard(const CardState *sourceCard, int index) { if (!sourceCard) { return; @@ -1012,7 +1013,7 @@ void PlayerActions::actCreateRelatedCard(const CardItem *sourceCard, int index) actRequestCreateRelatedFromRelationDialog(sourceCard, cardRelation); } -void PlayerActions::actCreateAllRelatedCards(const CardItem *sourceCard) +void PlayerActions::actCreateAllRelatedCards(const CardState *sourceCard) { if (!sourceCard) { return; @@ -1099,13 +1100,13 @@ void PlayerActions::actCreateAllRelatedCards(const CardItem *sourceCard) } } -void PlayerActions::actRequestCreateRelatedFromRelationDialog(const CardItem *sourceCard, +void PlayerActions::actRequestCreateRelatedFromRelationDialog(const CardState *sourceCard, const CardRelation *cardRelation) { emit requestCreateRelatedFromRelationDialog(sourceCard, cardRelation); } -bool PlayerActions::createRelatedFromRelation(const CardItem *sourceCard, +bool PlayerActions::createRelatedFromRelation(const CardState *sourceCard, const CardRelation *cardRelation, int variableCount) { @@ -1157,7 +1158,7 @@ bool PlayerActions::createRelatedFromRelation(const CardItem *sourceCard, return true; } -void PlayerActions::onRelatedCardCreated(const CardItem *sourceCard, const CardRelation *cardRelation) +void PlayerActions::onRelatedCardCreated(const CardState *sourceCard, const CardRelation *cardRelation) { if (sourceCard == nullptr || cardRelation == nullptr) { return; @@ -1177,7 +1178,7 @@ void PlayerActions::onRelatedCardCreated(const CardItem *sourceCard, const CardR setLastToken(relatedCard.getCardPtr()); } -void PlayerActions::createCard(const CardItem *sourceCard, +void PlayerActions::createCard(const CardState *sourceCard, const QString &dbCardName, CardRelationType attachType, bool persistent, @@ -1263,7 +1264,7 @@ void PlayerActions::actRequestMoveCardXCardsFromTopDialog() emit requestMoveCardXCardsFromTopDialog(defaultNumberTopCardsToPlaceBelow, deckSize); } -void PlayerActions::actMoveCardXCardsFromTop(QList selectedCards, int number) +void PlayerActions::actMoveCardXCardsFromTop(QList selectedCards, int number) { defaultNumberTopCardsToPlaceBelow = number; @@ -1297,7 +1298,7 @@ void PlayerActions::actMoveCardXCardsFromTop(QList selectedCards, in } } -void PlayerActions::actIncPT(QList selectedCards, int deltaP, int deltaT) +void PlayerActions::actIncPT(QList selectedCards, int deltaP, int deltaT) { int playerid = player->getPlayerInfo()->getId(); @@ -1330,7 +1331,7 @@ void PlayerActions::actIncPT(QList selectedCards, int deltaP, int de player->getGame()->getGameEventHandler()->sendGameCommand(prepareGameCommand(commandList), playerid); } -void PlayerActions::actResetPT(QList selectedCards) +void PlayerActions::actResetPT(QList selectedCards) { int playerid = player->getPlayerInfo()->getId(); QList commandList; @@ -1363,7 +1364,7 @@ void PlayerActions::actResetPT(QList selectedCards) } } -void PlayerActions::actRequestSetPTDialog(QList selectedCards) +void PlayerActions::actRequestSetPTDialog(QList selectedCards) { QString oldPT; @@ -1376,7 +1377,7 @@ void PlayerActions::actRequestSetPTDialog(QList selectedCards) emit requestSetPTDialog(oldPT); } -void PlayerActions::actSetPT(QList selectedCards, const QString &pt) +void PlayerActions::actSetPT(QList selectedCards, const QString &pt) { int playerid = player->getPlayerInfo()->getId(); @@ -1416,42 +1417,42 @@ void PlayerActions::actSetPT(QList selectedCards, const QString &pt) player->getGame()->getGameEventHandler()->sendGameCommand(prepareGameCommand(commandList), playerid); } -void PlayerActions::actIncP(QList selectedCards) +void PlayerActions::actIncP(QList selectedCards) { actIncPT(selectedCards, 1, 0); } -void PlayerActions::actDecP(QList selectedCards) +void PlayerActions::actDecP(QList selectedCards) { actIncPT(selectedCards, -1, 0); } -void PlayerActions::actIncT(QList selectedCards) +void PlayerActions::actIncT(QList selectedCards) { actIncPT(selectedCards, 0, 1); } -void PlayerActions::actDecT(QList selectedCards) +void PlayerActions::actDecT(QList selectedCards) { actIncPT(selectedCards, 0, -1); } -void PlayerActions::actIncPT(QList selectedCards) +void PlayerActions::actIncPT(QList selectedCards) { actIncPT(selectedCards, 1, 1); } -void PlayerActions::actDecPT(QList selectedCards) +void PlayerActions::actDecPT(QList selectedCards) { actIncPT(selectedCards, -1, -1); } -void PlayerActions::actFlowP(QList selectedCards) +void PlayerActions::actFlowP(QList selectedCards) { actIncPT(selectedCards, 1, -1); } -void PlayerActions::actFlowT(QList selectedCards) +void PlayerActions::actFlowT(QList selectedCards) { actIncPT(selectedCards, -1, 1); } @@ -1474,7 +1475,7 @@ void PlayerActions::actSetCounter(int counterId, int value) sendGameCommand(cmd); } -void PlayerActions::actReduceLifeByPower(QList selectedCards) +void PlayerActions::actReduceLifeByPower(QList selectedCards) { // find life counter auto lifeCounter = player->getLifeCounter(); @@ -1499,7 +1500,7 @@ void PlayerActions::actReduceLifeByPower(QList selectedCards) sendGameCommand(prepareGameCommand(cmd)); } -void PlayerActions::actRequestSetAnnotationDialog(QList selectedCards) +void PlayerActions::actRequestSetAnnotationDialog(QList selectedCards) { QString oldAnnotation; for (auto card : selectedCards) { @@ -1511,7 +1512,7 @@ void PlayerActions::actRequestSetAnnotationDialog(QList selectedCard emit requestSetAnnotationDialog(oldAnnotation); } -void PlayerActions::actSetAnnotation(QList selectedCards, const QString &annotation) +void PlayerActions::actSetAnnotation(QList selectedCards, const QString &annotation) { QList commandList; for (auto card : selectedCards) { @@ -1525,7 +1526,7 @@ void PlayerActions::actSetAnnotation(QList selectedCards, const QStr sendGameCommand(prepareGameCommand(commandList)); } -void PlayerActions::actUnattach(QList selectedCards) +void PlayerActions::actUnattach(QList selectedCards) { QList commandList; for (auto card : selectedCards) { @@ -1541,17 +1542,17 @@ void PlayerActions::actUnattach(QList selectedCards) sendGameCommand(prepareGameCommand(commandList)); } -void PlayerActions::actAddCardCounter(QList selectedCards, int counterId) +void PlayerActions::actAddCardCounter(QList selectedCards, int counterId) { offsetCardCounter(selectedCards, counterId, 1); } -void PlayerActions::actRemoveCardCounter(QList selectedCards, int counterId) +void PlayerActions::actRemoveCardCounter(QList selectedCards, int counterId) { offsetCardCounter(selectedCards, counterId, -1); } -void PlayerActions::offsetCardCounter(QList selectedCards, int counterId, int offset) +void PlayerActions::offsetCardCounter(QList selectedCards, int counterId, int offset) { QList commandList; for (auto card : selectedCards) { @@ -1577,7 +1578,7 @@ void PlayerActions::offsetCardCounter(QList selectedCards, int count sendGameCommand(prepareGameCommand(commandList)); } -void PlayerActions::actRequestSetCardCounterDialog(QList selectedCards, int counterId) +void PlayerActions::actRequestSetCardCounterDialog(QList selectedCards, int counterId) { // If a single card is selected, we show the old value in the dialog. Otherwise, we show "x" QString oldValueForDlg = "x"; @@ -1589,7 +1590,7 @@ void PlayerActions::actRequestSetCardCounterDialog(QList selectedCar emit requestSetCardCounterDialog(counterId, oldValueForDlg); } -void PlayerActions::actSetCardCounter(QList selectedCards, int counterId, const QString &counterValue) +void PlayerActions::actSetCardCounter(QList selectedCards, int counterId, const QString &counterValue) { QList commandList; for (auto card : selectedCards) { @@ -1610,12 +1611,12 @@ void PlayerActions::actSetCardCounter(QList selectedCards, int count sendGameCommand(prepareGameCommand(commandList)); } -void PlayerActions::actIncrementAllCardCounters(QList cardsToUpdate) +void PlayerActions::actIncrementAllCardCounters(QList cardsToUpdate) { if (cardsToUpdate.isEmpty()) { // If no cards selected, update all cards on table for (auto *state : player->getTableZone()->getCards()) { - cardsToUpdate.append(qobject_cast(state->parent())); + cardsToUpdate.append(state); } } @@ -1659,7 +1660,7 @@ static bool isUnwritableRevealZone(CardZoneLogic *zone) return false; } -void PlayerActions::playSelectedCards(QList selectedCards, const bool faceDown) +void PlayerActions::playSelectedCards(QList selectedCards, const bool faceDown) { // CardIds will get shuffled downwards when cards leave the deck. // We need to iterate through the cards in reverse order so cardIds don't get changed out from under us as we play @@ -1674,27 +1675,26 @@ void PlayerActions::playSelectedCards(QList selectedCards, const boo } } -void PlayerActions::actPlay(QList selectedCards) +void PlayerActions::actPlay(QList selectedCards) { playSelectedCards(selectedCards, false); } -void PlayerActions::actPlayFacedown(QList selectedCards) +void PlayerActions::actPlayFacedown(QList selectedCards) { playSelectedCards(selectedCards, true); } -void PlayerActions::actHide(QList selectedCards) +void PlayerActions::actHide(QList selectedCards) { - for (const auto &item : selectedCards) { - auto *card = static_cast(item); - if (card && isUnwritableRevealZone(card->getZone())) { - card->getZone()->removeCard(card->getState()); + for (const auto &card : selectedCards) { + if (isUnwritableRevealZone(card->getZone())) { + card->getZone()->removeCard(card); } } } -void PlayerActions::actReveal(QList selectedCards, QAction *action) +void PlayerActions::actReveal(QList selectedCards, QAction *action) { const int otherPlayerId = action->data().toInt(); @@ -1785,9 +1785,9 @@ void PlayerActions::actRevealRandomGraveyardCard(int revealToPlayerId) sendGameCommand(cmd); } -void PlayerActions::cardMenuAction(QList selectedCards, CardMenuActionType type) +void PlayerActions::cardMenuAction(QList selectedCards, CardMenuActionType type) { - QList cardList = selectedCards; + QList cardList = selectedCards; QList commandList; if (type <= cmClone) { @@ -2079,10 +2079,10 @@ PendingCommand *PlayerActions::prepareZoneDump(CardZoneLogic *zone, int numberCa return prepareGameCommand(cmd); } -void PlayerActions::actToggleTapped(const QString &zoneName, const QList &cards, bool tapped) +void PlayerActions::actToggleTapped(const QString &zoneName, const QList &cards, bool tapped) { QList cmdList; - for (CardItem *card : cards) { + for (CardState *card : cards) { if (card->getTapped() != tapped) { auto *cmd = new Command_SetCardAttr; cmd->set_zone(zoneName.toStdString()); diff --git a/cockatrice/src/game/player/player_actions.h b/cockatrice/src/game/player/player_actions.h index daee72d2f..6875f865c 100644 --- a/cockatrice/src/game/player/player_actions.h +++ b/cockatrice/src/game/player/player_actions.h @@ -8,7 +8,6 @@ #ifndef COCKATRICE_PLAYER_ACTIONS_H #define COCKATRICE_PLAYER_ACTIONS_H -#include "../../game_graphics/board/card_item.h" // IWYU pragma: keep - moc instantiates the metatype for QList, which Qt 6.4 needs complete #include "../board/card_list.h" #include "card_menu_action_type.h" #include "card_move_request.h" @@ -37,6 +36,7 @@ class Message; } } // namespace google +class CardState; class CardZoneLogic; class Command_MoveCard; class PendingCommand; @@ -84,14 +84,14 @@ public: * @param cards Cards the view wants toggled. * @param tapped Desired tapped state. */ - void actToggleTapped(const QString &zoneName, const QList &cards, bool tapped); + void actToggleTapped(const QString &zoneName, const QList &cards, bool tapped); /** * @brief Attaches one card to another on the table on behalf of a view. * @param startCard Card being attached. * @param targetCard Card being attached to. */ - void attachCards(CardItem *startCard, const CardItem *targetCard); + void attachCards(CardState *startCard, const CardState *targetCard); /** * @brief Draws an arrow from a card to another card or a player on behalf of a view. @@ -103,9 +103,9 @@ public: * @param arrow Arrow endpoints and color, as resolved by the view. * @param deleteInPhase Phase the arrow expires in, 0 to keep it until removed. */ - void createArrow(CardItem *startCard, const ArrowData &arrow, int deleteInPhase); + void createArrow(CardState *startCard, const ArrowData &arrow, int deleteInPhase); - void moveOneCardUntil(CardItem *card); + void moveOneCardUntil(CardState *card); void stopMoveTopCardsUntil(); [[nodiscard]] bool isMovingCardsUntil() const @@ -134,7 +134,7 @@ signals: void requestDrawBottomCardsDialog(int defaultNumberBottomCards, int maxCards); void requestRollDieDialog(); void requestCreateTokenDialog(const QStringList &predefinedTokens); - void requestCreateRelatedFromRelationDialog(const CardItem *sourceCard, const CardRelation *cardRelation); + void requestCreateRelatedFromRelationDialog(const CardState *sourceCard, const CardRelation *cardRelation); void requestMoveCardXCardsFromTopDialog(int defaultNumberTopCardsToPlaceBelow, int deckSize); void requestSetPTDialog(const QString &oldPT); void requestSetAnnotationDialog(const QString &oldAnnotation); @@ -147,8 +147,8 @@ signals: public slots: void setLastToken(CardInfoPtr cardInfo); void setLastTokenInfo(CardInfoPtr cardInfo); - void playCard(CardItem *c, bool faceDown); - void playCardToTable(const CardItem *c, bool faceDown); + void playCard(CardState *c, bool faceDown); + void playCardToTable(const CardState *c, bool faceDown); void actUntapAll(); void actRequestRollDieDialog(); @@ -157,9 +157,9 @@ public slots: void actRequestCreateTokenDialog(const QStringList &predefinedTokens); void actCreateToken(TokenInfo tokenToCreate); void actCreateAnotherToken(); - void actRequestCreateRelatedFromRelationDialog(const CardItem *sourceCard, const CardRelation *cardRelation); - bool createRelatedFromRelation(const CardItem *sourceCard, const CardRelation *cardRelation, int variableCount); - void onRelatedCardCreated(const CardItem *sourceCard, const CardRelation *cardRelation); + void actRequestCreateRelatedFromRelationDialog(const CardState *sourceCard, const CardRelation *cardRelation); + bool createRelatedFromRelation(const CardState *sourceCard, const CardRelation *cardRelation, int variableCount); + void onRelatedCardCreated(const CardState *sourceCard, const CardRelation *cardRelation); void setLastRelatedCreationSucceeded(bool succeeded) { lastRelatedCreationSucceeded = succeeded; @@ -179,9 +179,9 @@ public slots: void actMulliganMinusOne(); void doMulligan(int number); - void actPlay(QList selectedCards); - void actPlayFacedown(QList selectedCards); - void actHide(QList selectedCards); + void actPlay(QList selectedCards); + void actPlayFacedown(QList selectedCards); + void actHide(QList selectedCards); void actMoveTopCardToPlay(); void actMoveTopCardToPlayFaceDown(); @@ -230,29 +230,29 @@ public slots: void actOpenDeckInDeckEditor(); void actCreatePredefinedToken(); - void actCreateRelatedCard(const CardItem *sourceCard, int index); - void actCreateAllRelatedCards(const CardItem *sourceCard); + void actCreateRelatedCard(const CardState *sourceCard, int index); + void actCreateAllRelatedCards(const CardState *sourceCard); void actRequestMoveCardXCardsFromTopDialog(); - void actMoveCardXCardsFromTop(QList selectedCards, int number); - void actRemoveCardCounter(QList selectedCards, int counterId); - void actAddCardCounter(QList selectedCards, int counterId); - void actRequestSetCardCounterDialog(QList selectedCards, int counterId); - void actSetCardCounter(QList selectedCards, int counterId, const QString &counterValue); - void actIncrementAllCardCounters(QList cardsToUpdate); - void actUnattach(QList selectedCards); - void actIncPT(QList selectedCards, int deltaP, int deltaT); - void actResetPT(QList selectedCards); - void actRequestSetPTDialog(QList selectedCards); - void actSetPT(QList selectedCards, const QString &pt); - void actIncP(QList selectedCards); - void actDecP(QList selectedCards); - void actIncT(QList selectedCards); - void actDecT(QList selectedCards); - void actIncPT(QList selectedCards); - void actDecPT(QList selectedCards); - void actFlowP(QList selectedCards); - void actFlowT(QList selectedCards); + void actMoveCardXCardsFromTop(QList selectedCards, int number); + void actRemoveCardCounter(QList selectedCards, int counterId); + void actAddCardCounter(QList selectedCards, int counterId); + void actRequestSetCardCounterDialog(QList selectedCards, int counterId); + void actSetCardCounter(QList selectedCards, int counterId, const QString &counterValue); + void actIncrementAllCardCounters(QList cardsToUpdate); + void actUnattach(QList selectedCards); + void actIncPT(QList selectedCards, int deltaP, int deltaT); + void actResetPT(QList selectedCards); + void actRequestSetPTDialog(QList selectedCards); + void actSetPT(QList selectedCards, const QString &pt); + void actIncP(QList selectedCards); + void actDecP(QList selectedCards); + void actIncT(QList selectedCards); + void actDecT(QList selectedCards); + void actIncPT(QList selectedCards); + void actDecPT(QList selectedCards); + void actFlowP(QList selectedCards); + void actFlowT(QList selectedCards); /** @brief Adjusts a player counter by a signed delta. */ void actIncrementCounter(int counterId, int delta); @@ -260,18 +260,18 @@ public slots: /** @brief Sets a player counter to an absolute value. */ void actSetCounter(int counterId, int value); - void actReduceLifeByPower(QList selectedCards); + void actReduceLifeByPower(QList selectedCards); - void actRequestSetAnnotationDialog(QList selectedCards); - void actSetAnnotation(QList selectedCards, const QString &annotation); - void actReveal(QList selectedCards, QAction *action); + void actRequestSetAnnotationDialog(QList selectedCards); + void actSetAnnotation(QList selectedCards, const QString &annotation); + void actReveal(QList selectedCards, QAction *action); void actRevealHand(int revealToPlayerId); void actRevealRandomHandCard(int revealToPlayerId); void actRevealLibrary(int revealToPlayerId); void actSortHand(); - void cardMenuAction(QList selectedCards, CardMenuActionType type); + void cardMenuAction(QList selectedCards, CardMenuActionType type); private: PlayerLogic *player; @@ -292,18 +292,18 @@ private: bool lastRelatedCreationSucceeded = false; - void createCard(const CardItem *sourceCard, + void createCard(const CardState *sourceCard, const QString &dbCardName, CardRelationType attach = CardRelationType::DoesNotAttach, bool persistent = false, bool faceDown = false); - void playSelectedCards(QList selectedCards, bool faceDown = false); + void playSelectedCards(QList selectedCards, bool faceDown = false); void cmdSetTopCard(Command_MoveCard &cmd); void cmdSetBottomCard(Command_MoveCard &cmd); - void offsetCardCounter(QList selectedCards, int counterId, int offset); + void offsetCardCounter(QList selectedCards, int counterId, int offset); }; #endif // COCKATRICE_PLAYER_ACTIONS_H diff --git a/cockatrice/src/game/player/player_event_handler.cpp b/cockatrice/src/game/player/player_event_handler.cpp index 4698aeced..8b2ea0024 100644 --- a/cockatrice/src/game/player/player_event_handler.cpp +++ b/cockatrice/src/game/player/player_event_handler.cpp @@ -396,7 +396,7 @@ void PlayerEventHandler::eventMoveCard(const Event_MoveCard &event, const GameEv if (player->getPlayerActions()->isMovingCardsUntil() && startZoneString == ZoneNames::DECK && targetZone->getName() == ZoneNames::STACK) { - player->getPlayerActions()->moveOneCardUntil(toCardItem(card)); + player->getPlayerActions()->moveOneCardUntil(card); } } diff --git a/cockatrice/src/game/zones/pile_zone_logic.cpp b/cockatrice/src/game/zones/pile_zone_logic.cpp index 42ef4f0b2..e33d8fe79 100644 --- a/cockatrice/src/game/zones/pile_zone_logic.cpp +++ b/cockatrice/src/game/zones/pile_zone_logic.cpp @@ -1,11 +1,7 @@ #include "pile_zone_logic.h" -#include "../board/card_list.h" #include "../board/card_state.h" -class PlayerLogic; -class QObject; - PileZoneLogic::PileZoneLogic(PlayerLogic *_player, const QString &_name, bool _hasCardAttr, diff --git a/cockatrice/src/game/zones/table_zone_logic.cpp b/cockatrice/src/game/zones/table_zone_logic.cpp index 7a6e74adf..834794268 100644 --- a/cockatrice/src/game/zones/table_zone_logic.cpp +++ b/cockatrice/src/game/zones/table_zone_logic.cpp @@ -1,14 +1,9 @@ #include "table_zone_logic.h" -#include "../board/card_list.h" #include "../board/card_state.h" -#include "libcockatrice/card/card_info.h" #include -class PlayerLogic; -class QObject; - TableZoneLogic::TableZoneLogic(PlayerLogic *_player, const QString &_name, bool _hasCardAttr, diff --git a/cockatrice/src/game/zones/view_zone_logic.cpp b/cockatrice/src/game/zones/view_zone_logic.cpp index 5e5e0b5fe..a70b8e3d1 100644 --- a/cockatrice/src/game/zones/view_zone_logic.cpp +++ b/cockatrice/src/game/zones/view_zone_logic.cpp @@ -1,7 +1,6 @@ #include "view_zone_logic.h" #include "../../client/settings/cache_settings.h" -#include "../board/card_list.h" #include "../board/card_state.h" #include "../player/player_actions.h" #include "../player/player_logic.h" diff --git a/cockatrice/src/game_graphics/board/arrow_item.cpp b/cockatrice/src/game_graphics/board/arrow_item.cpp index 90a49819a..cd02a6b1d 100644 --- a/cockatrice/src/game_graphics/board/arrow_item.cpp +++ b/cockatrice/src/game_graphics/board/arrow_item.cpp @@ -444,7 +444,7 @@ void ArrowDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) startCard->playCard(false); } - player->getPlayerActions()->createArrow(startCard, arrow, deleteInPhase); + player->getPlayerActions()->createArrow(startCard->getState(), arrow, deleteInPhase); } delArrow(); @@ -536,7 +536,7 @@ void ArrowAttachItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) auto *startCard = qgraphicsitem_cast(startItem); auto *targetCard = qgraphicsitem_cast(targetItem); if (startCard && targetCard) { - player->getPlayerActions()->attachCards(startCard, targetCard); + player->getPlayerActions()->attachCards(startCard->getState(), targetCard->getState()); } } diff --git a/cockatrice/src/game_graphics/board/card_item.cpp b/cockatrice/src/game_graphics/board/card_item.cpp index 9a308ba51..e56b5848c 100644 --- a/cockatrice/src/game_graphics/board/card_item.cpp +++ b/cockatrice/src/game_graphics/board/card_item.cpp @@ -442,7 +442,7 @@ void CardItem::playCard(bool faceDown) emit playSelected(this); } } else { - state->getZone()->getPlayer()->getPlayerActions()->playCard(this, faceDown); + state->getZone()->getPlayer()->getPlayerActions()->playCard(state, faceDown); } } } diff --git a/cockatrice/src/game_graphics/game_scene.cpp b/cockatrice/src/game_graphics/game_scene.cpp index 3422ce28f..3da4ba7cb 100644 --- a/cockatrice/src/game_graphics/game_scene.cpp +++ b/cockatrice/src/game_graphics/game_scene.cpp @@ -113,6 +113,15 @@ void GameScene::retranslateUi() } } +QList GameScene::selectedCardStates() const +{ + QList selectedStates; + for (auto *card : selectedCards()) { + selectedStates.append(card->getState()); + } + return selectedStates; +} + QList GameScene::selectedCards() const { QList selectedCards; @@ -235,7 +244,7 @@ void GameScene::playSelected(AbstractCardItem *card) if (!card->getOwner()) { return; } - card->getOwner()->getPlayerActions()->actPlay(selectedCards()); + card->getOwner()->getPlayerActions()->actPlay(selectedCardStates()); } void GameScene::playSelectedFaceDown(AbstractCardItem *card) @@ -246,7 +255,7 @@ void GameScene::playSelectedFaceDown(AbstractCardItem *card) if (!card->getOwner()) { return; } - card->getOwner()->getPlayerActions()->actPlayFacedown(selectedCards()); + card->getOwner()->getPlayerActions()->actPlayFacedown(selectedCardStates()); } void GameScene::hideSelected(AbstractCardItem *card) @@ -257,7 +266,7 @@ void GameScene::hideSelected(AbstractCardItem *card) if (!card->getOwner()) { return; } - card->getOwner()->getPlayerActions()->actHide(selectedCards()); + card->getOwner()->getPlayerActions()->actHide(selectedCardStates()); } /** diff --git a/cockatrice/src/game_graphics/game_scene.h b/cockatrice/src/game_graphics/game_scene.h index ca6441e91..5856d718d 100644 --- a/cockatrice/src/game_graphics/game_scene.h +++ b/cockatrice/src/game_graphics/game_scene.h @@ -113,6 +113,7 @@ public: /** @brief Gets all selected CardItems. */ QList selectedCards() const; + QList selectedCardStates() const; /** @brief Selects every card sharing the zone of @p card. */ void selectAllInZone(const CardItem *card); diff --git a/cockatrice/src/game_graphics/player/menu/card_menu.cpp b/cockatrice/src/game_graphics/player/menu/card_menu.cpp index 2174be3d8..2dcb82bb6 100644 --- a/cockatrice/src/game_graphics/player/menu/card_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/card_menu.cpp @@ -86,7 +86,7 @@ CardMenu::CardMenu(PlayerGraphicsItem *_player, CardItem *_card, bool _shortcuts auto *gameScene = player->getGameScene(); // Single selection resolver used by all lambdas — called at trigger time - auto sel = [gameScene]() { return gameScene->selectedCards(); }; + auto sel = [gameScene]() { return gameScene->selectedCardStates(); }; // Unified dispatcher for card menu actions auto invoke = [actions, sel](CardMenuActionType type) { @@ -337,7 +337,7 @@ void CardMenu::createHandOrCustomZoneMenu(bool canModifyCard) initContextualPlayersMenu(revealMenu, aRevealToAll); connect(revealMenu, &QMenu::triggered, this, [this](QAction *action) { - player->getLogic()->getPlayerActions()->actReveal(player->getGameScene()->selectedCards(), action); + player->getLogic()->getPlayerActions()->actReveal(player->getGameScene()->selectedCardStates(), action); }); addSeparator(); @@ -486,8 +486,9 @@ void CardMenu::addRelatedCardActions() auto *createRelated = new QAction(text, this); const int relatedIndex = index++; - connect(createRelated, &QAction::triggered, this, - [this, playerActions, relatedIndex] { playerActions->actCreateRelatedCard(card, relatedIndex); }); + connect(createRelated, &QAction::triggered, this, [this, playerActions, relatedIndex] { + playerActions->actCreateRelatedCard(card->getState(), relatedIndex); + }); addAction(createRelated); } @@ -497,7 +498,7 @@ void CardMenu::addRelatedCardActions() SettingsCache::instance().shortcuts().getShortcut("Player/aCreateRelatedTokens")); } connect(createRelatedCards, &QAction::triggered, this, - [this, playerActions] { playerActions->actCreateAllRelatedCards(card); }); + [this, playerActions] { playerActions->actCreateAllRelatedCards(card->getState()); }); addAction(createRelatedCards); } } diff --git a/cockatrice/src/game_graphics/player/menu/move_menu.cpp b/cockatrice/src/game_graphics/player/menu/move_menu.cpp index 80d748e19..9802c5c3a 100644 --- a/cockatrice/src/game_graphics/player/menu/move_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/move_menu.cpp @@ -30,7 +30,7 @@ MoveMenu::MoveMenu(PlayerGraphicsItem *player) : QMenu(tr("Move to")) auto invoke = [player](CardMenuActionType type) { return [type, player]() { - player->getLogic()->getPlayerActions()->cardMenuAction(player->getGameScene()->selectedCards(), type); + player->getLogic()->getPlayerActions()->cardMenuAction(player->getGameScene()->selectedCardStates(), type); }; }; diff --git a/cockatrice/src/game_graphics/player/menu/pt_menu.cpp b/cockatrice/src/game_graphics/player/menu/pt_menu.cpp index b4f852b43..f07d73aef 100644 --- a/cockatrice/src/game_graphics/player/menu/pt_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/pt_menu.cpp @@ -15,34 +15,35 @@ PtMenu::PtMenu(PlayerGraphicsItem *player) : QMenu(tr("Power / toughness")) aIncP = new QAction(this); connect(aIncP, &QAction::triggered, playerActions, - [player, playerActions] { playerActions->actIncP(player->getGameScene()->selectedCards()); }); + [player, playerActions] { playerActions->actIncP(player->getGameScene()->selectedCardStates()); }); aDecP = new QAction(this); connect(aDecP, &QAction::triggered, playerActions, - [player, playerActions] { playerActions->actDecP(player->getGameScene()->selectedCards()); }); + [player, playerActions] { playerActions->actDecP(player->getGameScene()->selectedCardStates()); }); aIncT = new QAction(this); connect(aIncT, &QAction::triggered, playerActions, - [player, playerActions] { playerActions->actIncT(player->getGameScene()->selectedCards()); }); + [player, playerActions] { playerActions->actIncT(player->getGameScene()->selectedCardStates()); }); aDecT = new QAction(this); connect(aDecT, &QAction::triggered, playerActions, - [player, playerActions] { playerActions->actDecT(player->getGameScene()->selectedCards()); }); + [player, playerActions] { playerActions->actDecT(player->getGameScene()->selectedCardStates()); }); aIncPT = new QAction(this); connect(aIncPT, &QAction::triggered, playerActions, - [player, playerActions] { playerActions->actIncPT(player->getGameScene()->selectedCards()); }); + [player, playerActions] { playerActions->actIncPT(player->getGameScene()->selectedCardStates()); }); aDecPT = new QAction(this); connect(aDecPT, &QAction::triggered, playerActions, - [player, playerActions] { playerActions->actDecPT(player->getGameScene()->selectedCards()); }); + [player, playerActions] { playerActions->actDecPT(player->getGameScene()->selectedCardStates()); }); aFlowP = new QAction(this); connect(aFlowP, &QAction::triggered, playerActions, - [player, playerActions] { playerActions->actFlowP(player->getGameScene()->selectedCards()); }); + [player, playerActions] { playerActions->actFlowP(player->getGameScene()->selectedCardStates()); }); aFlowT = new QAction(this); connect(aFlowT, &QAction::triggered, playerActions, - [player, playerActions] { playerActions->actFlowT(player->getGameScene()->selectedCards()); }); + [player, playerActions] { playerActions->actFlowT(player->getGameScene()->selectedCardStates()); }); aSetPT = new QAction(this); - connect(aSetPT, &QAction::triggered, playerActions, - [player, playerActions] { playerActions->actRequestSetPTDialog(player->getGameScene()->selectedCards()); }); + connect(aSetPT, &QAction::triggered, playerActions, [player, playerActions] { + playerActions->actRequestSetPTDialog(player->getGameScene()->selectedCardStates()); + }); aResetPT = new QAction(this); connect(aResetPT, &QAction::triggered, playerActions, - [player, playerActions] { playerActions->actResetPT(player->getGameScene()->selectedCards()); }); + [player, playerActions] { playerActions->actResetPT(player->getGameScene()->selectedCardStates()); }); addAction(aIncP); addAction(aDecP); diff --git a/cockatrice/src/game_graphics/player/menu/utility_menu.cpp b/cockatrice/src/game_graphics/player/menu/utility_menu.cpp index 7c18b3f4c..7332c7807 100644 --- a/cockatrice/src/game_graphics/player/menu/utility_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/utility_menu.cpp @@ -44,7 +44,7 @@ UtilityMenu::UtilityMenu(PlayerGraphicsItem *_player, QMenu *playerMenu) : QMenu aIncrementAllCardCounters = new QAction(this); connect(aIncrementAllCardCounters, &QAction::triggered, playerActions, [this]() { player->getLogic()->getPlayerActions()->actIncrementAllCardCounters( - player->getGameScene()->selectedCards()); + player->getGameScene()->selectedCardStates()); }); createPredefinedTokenMenu = new QMenu(QString()); diff --git a/cockatrice/src/game_graphics/player/player_dialogs.cpp b/cockatrice/src/game_graphics/player/player_dialogs.cpp index 674b31ffa..e60a53a80 100644 --- a/cockatrice/src/game_graphics/player/player_dialogs.cpp +++ b/cockatrice/src/game_graphics/player/player_dialogs.cpp @@ -197,7 +197,7 @@ void PlayerDialogs::onRollDieDialogRequested() playerActions->actRollDie(dlg.getDieSideCount(), dlg.getDiceToRollCount()); } -void PlayerDialogs::onCreateRelatedFromRelationDialogRequested(const CardItem *sourceCard, +void PlayerDialogs::onCreateRelatedFromRelationDialogRequested(const CardState *sourceCard, const CardRelation *cardRelation) { if (sourceCard == nullptr || cardRelation == nullptr) { @@ -252,14 +252,14 @@ void PlayerDialogs::onMoveCardXCardsFromTopDialogRequested(int defaultNumberTopC number -= 1; // indexes start at 0 if (ok) { - playerActions->actMoveCardXCardsFromTop(player->getGameScene()->selectedCards(), number); + playerActions->actMoveCardXCardsFromTop(player->getGameScene()->selectedCardStates(), number); } } void PlayerDialogs::onSetPTDialogRequested(const QString &oldPT) { bool ok; - auto cards = player->getGameScene()->selectedCards(); + auto cards = player->getGameScene()->selectedCardStates(); emit requestDialogSemaphore(true); QString pt = getTextWithMax(dialogParent(), tr("Change power/toughness"), tr("Change stats to:"), QLineEdit::Normal, oldPT, &ok); @@ -274,7 +274,7 @@ void PlayerDialogs::onSetPTDialogRequested(const QString &oldPT) void PlayerDialogs::onSetAnnotationDialogRequested(const QString &oldAnnotation) { - auto cards = player->getGameScene()->selectedCards(); + auto cards = player->getGameScene()->selectedCardStates(); emit requestDialogSemaphore(true); AnnotationDialog *dialog = new AnnotationDialog(dialogParent()); dialog->setOptions(QInputDialog::UsePlainTextEditForTextInput); @@ -292,7 +292,7 @@ void PlayerDialogs::onSetAnnotationDialogRequested(const QString &oldAnnotation) void PlayerDialogs::onSetCardCounterDialogRequested(int counterId, const QString &oldValueForDlg) { - auto cards = player->getGameScene()->selectedCards(); + auto cards = player->getGameScene()->selectedCardStates(); emit requestDialogSemaphore(true); auto &cardCounterSettings = SettingsCache::instance().cardCounters(); diff --git a/cockatrice/src/game_graphics/player/player_dialogs.h b/cockatrice/src/game_graphics/player/player_dialogs.h index 86d7282a2..9dc960a1c 100644 --- a/cockatrice/src/game_graphics/player/player_dialogs.h +++ b/cockatrice/src/game_graphics/player/player_dialogs.h @@ -52,7 +52,7 @@ public slots: bool faceDown); void onDrawBottomCardsDialogRequested(int defaultNumberBottomCards, int maxCards); void onRollDieDialogRequested(); - void onCreateRelatedFromRelationDialogRequested(const CardItem *sourceCard, const CardRelation *cardRelation); + void onCreateRelatedFromRelationDialogRequested(const CardState *sourceCard, const CardRelation *cardRelation); void onCreateTokenDialogRequested(const QStringList &predefinedTokens); void onMoveCardXCardsFromTopDialogRequested(int defaultNumberTopCardsToPlaceBelow, int deckSize); void onSetPTDialogRequested(const QString &oldPT); diff --git a/cockatrice/src/game_graphics/zones/table_zone.cpp b/cockatrice/src/game_graphics/zones/table_zone.cpp index c1e3b9f76..2fcff1d8a 100644 --- a/cockatrice/src/game_graphics/zones/table_zone.cpp +++ b/cockatrice/src/game_graphics/zones/table_zone.cpp @@ -266,16 +266,16 @@ void TableZone::reorganizeCards() void TableZone::toggleTapped() { - QList selectedCards; + QList selectedCards; for (auto *item : scene()->selectedItems()) { auto *card = qgraphicsitem_cast(item); if (card && card->getZone()->getName() == ZoneNames::TABLE) { - selectedCards.append(card); + selectedCards.append(card->getState()); } } const bool tapAll = std::any_of(selectedCards.begin(), selectedCards.end(), - [](const CardItem *card) { return !card->getTapped(); }); + [](const CardState *card) { return !card->getTapped(); }); getLogic()->getPlayer()->getPlayerActions()->actToggleTapped(getLogic()->getName(), selectedCards, tapAll); } From 78964779c62893563da678a666adeac7ce0779ae Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:44:15 +0100 Subject: [PATCH 072/108] [Refactor] Move card item creation out of game logic (#7436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Refactor] Move card item creation out of game logic * [Game] Rename signal to createCardRequested per review * [Game] Move attachment bookkeeping onto CardState CardState::setAttachedTo now maintains the attachedCards graph, resets the grid point and emits the zone events itself, so game logic no longer needs to reach into the CardItem graphics item. CardItem::setAttachedTo delegates to the state and keeps only the view teardown (deleteLater). --------- Co-authored-by: Lukas Brübach --- cockatrice/src/game/abstract_game.h | 1 - cockatrice/src/game/board/card_state.cpp | 28 +++++++ cockatrice/src/game/board/card_state.h | 10 +-- cockatrice/src/game/player/player_actions.cpp | 1 - .../src/game/player/player_event_handler.cpp | 83 ++++++++++--------- .../src/game/player/player_event_handler.h | 7 +- cockatrice/src/game/player/player_logic.cpp | 32 +++---- cockatrice/src/game/player/player_logic.h | 4 +- cockatrice/src/game/zones/card_zone_logic.cpp | 23 +++-- cockatrice/src/game/zones/card_zone_logic.h | 5 +- cockatrice/src/game/zones/view_zone_logic.cpp | 2 +- .../src/game_graphics/board/card_item.cpp | 49 ++++------- .../src/game_graphics/board/card_item.h | 6 +- cockatrice/src/game_graphics/game_scene.cpp | 12 ++- cockatrice/src/game_graphics/game_scene.h | 2 +- .../game_graphics/player/menu/player_menu.cpp | 8 +- .../game_graphics/player/menu/player_menu.h | 4 +- .../src/game_graphics/zones/card_zone.cpp | 9 ++ .../src/game_graphics/zones/card_zone.h | 3 + .../src/game_graphics/zones/table_zone.cpp | 7 +- 20 files changed, 159 insertions(+), 137 deletions(-) diff --git a/cockatrice/src/game/abstract_game.h b/cockatrice/src/game/abstract_game.h index 4bf8c120e..00a95a3b8 100644 --- a/cockatrice/src/game/abstract_game.h +++ b/cockatrice/src/game/abstract_game.h @@ -11,7 +11,6 @@ #include #include -class CardItem; class AbstractClient; class CardState; class GameEventHandler; diff --git a/cockatrice/src/game/board/card_state.cpp b/cockatrice/src/game/board/card_state.cpp index df7963e00..7e8c079a4 100644 --- a/cockatrice/src/game/board/card_state.cpp +++ b/cockatrice/src/game/board/card_state.cpp @@ -1,5 +1,7 @@ #include "card_state.h" +#include "../zones/card_zone_logic.h" + #include #include #include @@ -206,7 +208,33 @@ void CardState::setAttachedTo(CardState *_attachedTo) if (attachedTo == _attachedTo) { return; } + + if (attachedTo != nullptr) { + attachedTo->removeAttachedCard(this); + } + + setGridPoint(QPoint(-1, getGridPoint().y())); attachedTo = _attachedTo; emit attachedToChanged(_attachedTo); emit stateChanged(); + + if (attachedTo != nullptr) { + // If the zone is being torn down, it might already be null by the time a card tries to un-attach all its + // attached cards. The graphics item that owns this state handles the teardown in that case. + CardZoneLogic *parentZone = attachedTo->getZone(); + if (parentZone == nullptr) { + return; + } + emit parentZone->cardAdded(this); + attachedTo->addAttachedCard(this); + if (getZone() != parentZone) { + parentZone->reorganizeCards(); + } + } else if (getZone() != nullptr) { + emit getZone() -> cardAdded(this); + } + + if (getZone() != nullptr) { + getZone()->reorganizeCards(); + } } diff --git a/cockatrice/src/game/board/card_state.h b/cockatrice/src/game/board/card_state.h index bce5a298d..fa1e81393 100644 --- a/cockatrice/src/game/board/card_state.h +++ b/cockatrice/src/game/board/card_state.h @@ -12,8 +12,6 @@ #include class CardZoneLogic; -class CardItem; - class CardState : public QObject { Q_OBJECT @@ -35,7 +33,7 @@ private: QString color; CardState *attachedTo = nullptr; - QList attachedCards; + QList attachedCards; CardZoneLogic *zone = nullptr; signals: @@ -226,17 +224,17 @@ public: void setAttachedTo(CardState *_attachedTo); - void addAttachedCard(CardItem *card) + void addAttachedCard(CardState *card) { attachedCards.append(card); } - void removeAttachedCard(CardItem *card) + void removeAttachedCard(CardState *card) { attachedCards.removeOne(card); } - const QList &getAttachedCards() const + const QList &getAttachedCards() const { return attachedCards; } diff --git a/cockatrice/src/game/player/player_actions.cpp b/cockatrice/src/game/player/player_actions.cpp index 4ccf25ca4..b7e59b4e3 100644 --- a/cockatrice/src/game/player/player_actions.cpp +++ b/cockatrice/src/game/player/player_actions.cpp @@ -1,7 +1,6 @@ #include "player_actions.h" #include "../../client/settings/cache_settings.h" -#include "../../game_graphics/board/card_item.h" #include "../../game_graphics/dialogs/dlg_create_token.h" #include "../abstract_game.h" #include "../board/arrow_data.h" diff --git a/cockatrice/src/game/player/player_event_handler.cpp b/cockatrice/src/game/player/player_event_handler.cpp index 8b2ea0024..6588547da 100644 --- a/cockatrice/src/game/player/player_event_handler.cpp +++ b/cockatrice/src/game/player/player_event_handler.cpp @@ -1,9 +1,9 @@ #include "player_event_handler.h" -#include "../../game_graphics/board/card_item.h" #include "../abstract_game.h" #include "../board/arrow_data.h" #include "../board/card_list.h" +#include "../board/card_state.h" #include "../board/counter_state.h" #include "../zones/card_zone_logic.h" #include "../zones/hand_zone_logic.h" @@ -27,6 +27,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -49,15 +52,11 @@ #include #include #include +#include #include #include #include -static CardItem *toCardItem(CardState *card) -{ - return card == nullptr ? nullptr : qobject_cast(card->parent()); -} - PlayerEventHandler::PlayerEventHandler(PlayerLogic *_player) : QObject(_player), player(_player) { connect(this, &PlayerEventHandler::requestCardMenuUpdate, player, &PlayerLogic::requestCardMenuUpdate); @@ -160,23 +159,29 @@ void PlayerEventHandler::eventCreateToken(const Event_CreateToken &event) } CardRef cardRef = {QString::fromStdString(event.card_name()), QString::fromStdString(event.card_provider_id())}; - CardItem *card = new CardItem(player, nullptr, cardRef, event.card_id()); // use db PT if not provided in event and not face-down - if (!QString::fromStdString(event.pt()).isEmpty()) { - card->setPT(QString::fromStdString(event.pt())); - } else if (!event.face_down()) { - ExactCard dbCard = card->getCard(); + QString pt = QString::fromStdString(event.pt()); + if (pt.isEmpty() && !event.face_down()) { + ExactCard dbCard = CardDatabaseManager::query()->getCard(cardRef); if (dbCard) { - card->setPT(dbCard.getInfo().getPowTough()); + pt = dbCard.getInfo().getPowTough(); } } - card->setColor(QString::fromStdString(event.color())); - card->setAnnotation(QString::fromStdString(event.annotation())); - card->setDestroyOnZoneChange(event.destroy_on_zone_change()); - card->setFaceDown(event.face_down()); - emit logCreateToken(player, card->getName(), card->getPT(), card->getFaceDown()); - zone->addCard(card->getState(), true, event.x(), event.y()); + emit logCreateToken(player, cardRef.name, pt, event.face_down()); + + ServerInfo_Card cardInfo; + cardInfo.set_id(event.card_id()); + cardInfo.set_name(event.card_name()); + cardInfo.set_provider_id(event.card_provider_id()); + cardInfo.set_pt(pt.toStdString()); + cardInfo.set_color(event.color()); + cardInfo.set_annotation(event.annotation()); + cardInfo.set_destroy_on_zone_change(event.destroy_on_zone_change()); + cardInfo.set_face_down(event.face_down()); + cardInfo.set_x(event.x()); + cardInfo.set_y(event.y()); + emit zone->createCardRequested(cardInfo, true); } void PlayerEventHandler::eventSetCardAttr(const Event_SetCardAttr &event, @@ -276,7 +281,7 @@ void PlayerEventHandler::eventSetCardCounter(const Event_SetCardCounter &event) int oldValue = card->getCounters().value(event.counter_id(), 0); card->setCounter(event.counter_id(), event.counter_value()); - emit requestCardMenuUpdate(toCardItem(card)); + emit requestCardMenuUpdate(card); emit logSetCardCounter(player, card->getName(), event.counter_id(), event.counter_value(), oldValue); } @@ -362,22 +367,17 @@ void PlayerEventHandler::eventMoveCard(const Event_MoveCard &event, const GameEv if (card->getAttachedTo() && (startZone != targetZone)) { CardState *parentCard = card->getAttachedTo(); - // Route through CardItem so the old parent's attachedCards list is cleaned up: CardState::setAttachedTo - // only repoints the pointer, leaving a dangling entry that reorganizeCards/prepareDelete would dereference. - if (CardItem *item = toCardItem(card)) { - item->setAttachedTo(nullptr); - } else { - card->setAttachedTo(nullptr); - } + // CardState::setAttachedTo removes the card from the old parent's attachedCards list and emits the zone events. + card->setAttachedTo(nullptr); parentCard->getZone()->reorganizeCards(); } card->setId(event.new_card_id()); card->setFaceDown(event.face_down()); if (startZone != targetZone) { - const QList &attachedCards = card->getAttachedCards(); + const QList &attachedCards = card->getAttachedCards(); for (auto attachedCard : attachedCards) { - emit targetZone->cardAdded(attachedCard->getState()); + emit targetZone->cardAdded(attachedCard); } } @@ -391,8 +391,8 @@ void PlayerEventHandler::eventMoveCard(const Event_MoveCard &event, const GameEv targetZone->addCard(card, true, x, y); - emit cardZoneChanged(toCardItem(card), startZone == targetZone); - emit requestCardMenuUpdate(toCardItem(card)); + emit cardZoneChanged(card, startZone == targetZone); + emit requestCardMenuUpdate(card); if (player->getPlayerActions()->isMovingCardsUntil() && startZoneString == ZoneNames::DECK && targetZone->getName() == ZoneNames::STACK) { @@ -419,7 +419,7 @@ void PlayerEventHandler::eventFlipCard(const Event_FlipCard &event) emit logFlipCard(player, card->getName(), event.face_down()); card->setFaceDown(event.face_down()); - emit requestCardMenuUpdate(toCardItem(card)); + emit requestCardMenuUpdate(card); } void PlayerEventHandler::eventDestroyCard(const Event_DestroyCard &event) @@ -434,10 +434,18 @@ void PlayerEventHandler::eventDestroyCard(const Event_DestroyCard &event) return; } - QList attachedCards = card->getAttachedCards(); + QList attachedCards = card->getAttachedCards(); // This list is always empty except for buggy server implementations. for (auto &attachedCard : attachedCards) { + card->removeAttachedCard(attachedCard); + attachedCard->setGridPoint(QPoint(-1, attachedCard->getGridPoint().y())); attachedCard->setAttachedTo(nullptr); + if (attachedCard->getZone() == nullptr) { + attachedCard->deleteView(); + } else { + emit attachedCard->getZone()->cardAdded(attachedCard); + attachedCard->getZone()->reorganizeCards(); + } } emit logDestroyCard(player, card->getName()); @@ -473,14 +481,7 @@ void PlayerEventHandler::eventAttachCard(const Event_AttachCard &event) CardState *oldParent = startCard->getAttachedTo(); - // Route through CardItem::setAttachedTo to keep the attachedCards graph, grid-x reset and zone emits intact: - // CardState::setAttachedTo alone only repoints the pointer, so the target would never list the card and the old - // parent would keep a dangling pointer. - if (CardItem *startItem = toCardItem(startCard)) { - startItem->setAttachedTo(toCardItem(targetCard)); - } else { - startCard->setAttachedTo(targetCard); - } + startCard->setAttachedTo(targetCard); startZone->reorganizeCards(); if ((startZone != targetZone) && targetZone) { @@ -495,7 +496,7 @@ void PlayerEventHandler::eventAttachCard(const Event_AttachCard &event) } else { emit logUnattachCard(player, startCard->getName()); } - emit requestCardMenuUpdate(toCardItem(startCard)); + emit requestCardMenuUpdate(startCard); } void PlayerEventHandler::eventDrawCards(const Event_DrawCards &event) diff --git a/cockatrice/src/game/player/player_event_handler.h b/cockatrice/src/game/player/player_event_handler.h index 6b721bf61..d253deef0 100644 --- a/cockatrice/src/game/player/player_event_handler.h +++ b/cockatrice/src/game/player/player_event_handler.h @@ -27,7 +27,6 @@ #include #include -class CardItem; class CardState; class CardZoneLogic; class PlayerLogic; @@ -62,7 +61,7 @@ template class QList; * Design notes: * - All event handlers assume events are authoritative and already validated * by the server. - * - Most handlers mutate both logical state (CardItem, CardZoneLogic, counters) + * - Most handlers mutate both logical state (CardState, CardZoneLogic, counters) * and visual/UI state (views, arrows, menus). * - Logging signals are emitted *after* or *during* state mutation, depending * on whether later mutations would invalidate log data. @@ -267,8 +266,8 @@ signals: void logAlwaysLookAtTopCard(PlayerLogic *player, CardZoneLogic *zone, bool reveal); /** @} */ - void cardZoneChanged(CardItem *card, bool sameZone); - void requestCardMenuUpdate(CardItem *card); + void cardZoneChanged(CardState *card, bool sameZone); + void requestCardMenuUpdate(CardState *card); /** * @brief A card was just moved by the server and its view needs refreshing. * diff --git a/cockatrice/src/game/player/player_logic.cpp b/cockatrice/src/game/player/player_logic.cpp index f6a3a50e7..ce4e8a95e 100644 --- a/cockatrice/src/game/player/player_logic.cpp +++ b/cockatrice/src/game/player/player_logic.cpp @@ -1,8 +1,9 @@ #include "player_logic.h" -#include "../../game_graphics/board/card_item.h" #include "../abstract_game.h" #include "../board/arrow_data.h" +#include "../board/card_list.h" +#include "../board/card_state.h" #include "../board/counter_state.h" #include "../game_meta_info.h" #include "../zones/hand_zone_logic.h" @@ -33,11 +34,6 @@ #include #include -static CardItem *toCardItem(CardState *card) -{ - return card == nullptr ? nullptr : qobject_cast(card->parent()); -} - PlayerLogic::PlayerLogic(const ServerInfo_User &info, int _id, bool _local, bool _judge, AbstractGame *_parent) : QObject(_parent), game(_parent), playerInfo(new PlayerInfo(info, _id, _local, _judge)), playerEventHandler(new PlayerEventHandler(this)), playerActions(new PlayerActions(this)), active(false), @@ -177,22 +173,20 @@ void PlayerLogic::processPlayerInfo(const ServerInfo_Player &info) const int cardListSize = zoneInfo.card_list_size(); if (!cardListSize) { for (int j = 0; j < zoneInfo.card_count(); ++j) { - zone->addCard((new CardItem(this))->getState(), false, -1); + emit zone->createCardRequested(ServerInfo_Card(), false); } } else { for (int j = 0; j < cardListSize; ++j) { - const ServerInfo_Card &cardInfo = zoneInfo.card_list(j); - auto *card = new CardItem(this); - card->processCardInfo(cardInfo); + ServerInfo_Card cardInfo = zoneInfo.card_list(j); // Zones without coordinates (hand, piles, stack) preserve the order // they arrive in on the server in the positions of their cards list. // The x coordinate of such cards is always 0, so inserting at it // would reverse the list on reconnect. Append instead. - if (zoneInfo.with_coords()) { - zone->addCard(card->getState(), false, cardInfo.x(), cardInfo.y()); - } else { - zone->addCard(card->getState(), false, -1); + if (!zoneInfo.with_coords()) { + cardInfo.set_x(-1); + cardInfo.set_y(-1); } + emit zone->createCardRequested(cardInfo, false); } } if (zoneInfo.has_always_reveal_top_card()) { @@ -232,13 +226,7 @@ void PlayerLogic::processCardAttachment(const ServerInfo_Player &info) continue; } - // Route through CardItem::setAttachedTo to maintain the attachedCards graph and reparenting; - // CardState::setAttachedTo alone only repoints the pointer. - if (CardItem *startItem = toCardItem(startCard)) { - startItem->setAttachedTo(toCardItem(targetCard)); - } else { - startCard->setAttachedTo(targetCard); - } + startCard->setAttachedTo(targetCard); } } } @@ -250,7 +238,7 @@ void PlayerLogic::processCardAttachment(const ServerInfo_Player &info) } } -void PlayerLogic::addCard(CardItem *card) +void PlayerLogic::addCard(AbstractCardItem *card) { emit newCardAdded(card); } diff --git a/cockatrice/src/game/player/player_logic.h b/cockatrice/src/game/player/player_logic.h index ceaeff10b..196ee8ebe 100644 --- a/cockatrice/src/game/player/player_logic.h +++ b/cockatrice/src/game/player/player_logic.h @@ -73,7 +73,7 @@ signals: /** @brief Emitted when the remote playmat (card/params) is updated from player properties. */ void playmatChanged(); void newCardAdded(AbstractCardItem *card); - void requestCardMenuUpdate(CardItem *card); + void requestCardMenuUpdate(CardState *card); void counterAdded(CounterState *state); void counterRemoved(int counterId); void rearrangeCounters(); @@ -103,7 +103,7 @@ public: void processPlayerInfo(const ServerInfo_Player &info); void processCardAttachment(const ServerInfo_Player &info); - void addCard(CardItem *c); + void addCard(AbstractCardItem *c); void deleteCard(CardState *c); bool clearCardsToDelete(); diff --git a/cockatrice/src/game/zones/card_zone_logic.cpp b/cockatrice/src/game/zones/card_zone_logic.cpp index 8cce9f957..522fb5e04 100644 --- a/cockatrice/src/game/zones/card_zone_logic.cpp +++ b/cockatrice/src/game/zones/card_zone_logic.cpp @@ -1,7 +1,9 @@ #include "card_zone_logic.h" #include "../../client/translation.h" -#include "../../game_graphics/board/card_item.h" +#include "../../game_graphics/zones/card_zone.h" +#include "../board/card_list.h" +#include "../board/card_state.h" #include "../player/player_actions.h" #include "../player/player_info.h" #include "../player/player_logic.h" @@ -50,10 +52,15 @@ void CardZoneLogic::addCard(CardState *card, const bool reorganize, const int x, for (auto *viewLogic : views) { if (viewLogic->prepareAddCard(x)) { - auto copy = new CardItem(player, nullptr, card->getCardRef(), card->getId()); - copy->setFaceDown(card->getFaceDown()); - - viewLogic->addCard(copy->getState(), reorganize, x, y); + ServerInfo_Card cardInfo; + const CardRef &cardRef = card->getCardRef(); + cardInfo.set_name(cardRef.name.toStdString()); + cardInfo.set_provider_id(cardRef.providerId.toStdString()); + cardInfo.set_id(card->getId()); + cardInfo.set_face_down(card->getFaceDown()); + cardInfo.set_x(x); + cardInfo.set_y(y); + emit viewLogic->createCardRequested(cardInfo, reorganize); } } @@ -168,9 +175,9 @@ void CardZoneLogic::clearContents() for (CardState *card : toClear) { // If an incorrectly implemented server doesn't return attached cards to whom they belong before dropping a // player, we have to return them to avoid a crash. - const QList &attachedCards = card->getAttachedCards(); - for (CardItem *attachedCard : attachedCards) { - emit attachedCard->getZone()->cardAdded(attachedCard->getState()); + const QList &attachedCards = card->getAttachedCards(); + for (CardState *attachedCard : attachedCards) { + emit attachedCard->getZone()->cardAdded(attachedCard); } } diff --git a/cockatrice/src/game/zones/card_zone_logic.h b/cockatrice/src/game/zones/card_zone_logic.h index 2a6b50c5c..327b78f61 100644 --- a/cockatrice/src/game/zones/card_zone_logic.h +++ b/cockatrice/src/game/zones/card_zone_logic.h @@ -14,10 +14,9 @@ #include #include #include +#include #include -class CardItem; - inline Q_LOGGING_CATEGORY(CardZoneLogicLog, "card_zone_logic"); class PlayerLogic; @@ -33,6 +32,8 @@ class CardZoneLogic : public QObject signals: void cardAdded(CardState *addedCard); + /** @brief Asks the graphics side to build the card item for @p cardInfo and add it to this zone. */ + void createCardRequested(const ServerInfo_Card &cardInfo, bool reorganize); void cardCountChanged(); void reorganizeCards(); void updateGraphics(); diff --git a/cockatrice/src/game/zones/view_zone_logic.cpp b/cockatrice/src/game/zones/view_zone_logic.cpp index a70b8e3d1..3fcf8d075 100644 --- a/cockatrice/src/game/zones/view_zone_logic.cpp +++ b/cockatrice/src/game/zones/view_zone_logic.cpp @@ -37,7 +37,7 @@ ZoneViewZoneLogic::ZoneViewZoneLogic(PlayerLogic *_player, * Note that this method can end up modifying the cardIds despite returning false. * (for example, if the card is inserted into a hidden portion of the deck while the view is reversed) * - * Make sure to call this method once before calling addCard(), so that you skip creating a new CardItem and calling + * Make sure to call this method once before calling addCard(), so that you skip creating the card and calling * addCard() if it's not required. * * @param x The position to insert the card at. diff --git a/cockatrice/src/game_graphics/board/card_item.cpp b/cockatrice/src/game_graphics/board/card_item.cpp index e56b5848c..8030331eb 100644 --- a/cockatrice/src/game_graphics/board/card_item.cpp +++ b/cockatrice/src/game_graphics/board/card_item.cpp @@ -89,14 +89,18 @@ void CardItem::prepareDelete() owner = nullptr; } - const QList &attachedCards = state->getAttachedCards(); - while (!attachedCards.isEmpty()) { - attachedCards.first()->setZone(nullptr); // so that it won't try to call reorganizeCards() - attachedCards.first()->setAttachedTo(nullptr); + const QList &attachedStates = state->getAttachedCards(); + while (!attachedStates.isEmpty()) { + auto *attachedItem = qobject_cast(attachedStates.first()->parent()); + if (attachedItem == nullptr) { + break; + } + attachedItem->setZone(nullptr); // so that it won't try to call reorganizeCards() + attachedItem->setAttachedTo(nullptr); } if (state->getAttachedTo() != nullptr) { - state->getAttachedTo()->removeAttachedCard(this); + state->getAttachedTo()->removeAttachedCard(state); state->setAttachedTo(nullptr); } } @@ -225,36 +229,13 @@ void CardItem::setPT(const QString &_pt) void CardItem::setAttachedTo(CardItem *_attachedTo) { - if (state->getAttachedTo() != nullptr) { - state->getAttachedTo()->removeAttachedCard(this); - } + CardState *newParent = _attachedTo == nullptr ? nullptr : _attachedTo->getState(); + state->setAttachedTo(newParent); - state->setGridPoint(QPoint(-1, state->getGridPoint().y())); - state->setAttachedTo(_attachedTo == nullptr ? nullptr : _attachedTo->getState()); - if (state->getAttachedTo() != nullptr) { - // If the zone is being torn down, it might already be null by the time a card tries to un-attach all its - // attached cards - if (state->getAttachedTo()->getZone() == nullptr) { - deleteLater(); - } else { - emit state->getAttachedTo()->getZone()->cardAdded(state); - state->getAttachedTo()->addAttachedCard(this); - if (state->getZone() != state->getAttachedTo()->getZone()) { - state->getAttachedTo()->getZone()->reorganizeCards(); - } - } - } else { - // If the zone is being torn down, it might already be null by the time a card tries to un-attach all its - // attached cards - if (state->getZone() == nullptr) { - deleteLater(); - } else { - emit state->getZone()->cardAdded(state); - } - } - - if (state->getZone() != nullptr) { - state->getZone()->reorganizeCards(); + // If the zone is being torn down, it might already be null by the time a card tries to un-attach all its attached + // cards + if (newParent != nullptr ? newParent->getZone() == nullptr : state->getZone() == nullptr) { + deleteLater(); } } diff --git a/cockatrice/src/game_graphics/board/card_item.h b/cockatrice/src/game_graphics/board/card_item.h index 79c3dcdbd..706c00195 100644 --- a/cockatrice/src/game_graphics/board/card_item.h +++ b/cockatrice/src/game_graphics/board/card_item.h @@ -120,15 +120,15 @@ public: return state->getAttachedTo(); } void setAttachedTo(CardItem *_attachedTo); - void addAttachedCard(CardItem *card) + void addAttachedCard(CardState *card) { state->addAttachedCard(card); } - void removeAttachedCard(CardItem *card) + void removeAttachedCard(CardState *card) { state->removeAttachedCard(card); } - [[nodiscard]] const QList &getAttachedCards() const + [[nodiscard]] const QList &getAttachedCards() const { return state->getAttachedCards(); } diff --git a/cockatrice/src/game_graphics/game_scene.cpp b/cockatrice/src/game_graphics/game_scene.cpp index 3da4ba7cb..e7362871f 100644 --- a/cockatrice/src/game_graphics/game_scene.cpp +++ b/cockatrice/src/game_graphics/game_scene.cpp @@ -205,7 +205,7 @@ void GameScene::onCardSelectionChanged(AbstractCardItem *abstractCard, bool sele auto *owner = card->getOwner(); if (selected) { - owner->requestCardMenuUpdate(card); + owner->requestCardMenuUpdate(card->getState()); return; } @@ -231,7 +231,7 @@ void GameScene::onCardRightClicked(AbstractCardItem *abstractCard, QPoint screen setActiveCard(card); - if (auto *menu = view->getPlayerMenu()->updateCardMenu(card)) { + if (auto *menu = view->getPlayerMenu()->updateCardMenu(card->getState())) { menu->popup(screenPos); } } @@ -650,11 +650,15 @@ void GameScene::requestArrowDeletion(int playerId, int arrowId) } } -void GameScene::onCardZoneChanged(CardItem *card, bool sameZone) +void GameScene::onCardZoneChanged(CardState *card, bool sameZone) { + auto *item = qobject_cast(card->parent()); + if (!item) { + return; + } QList toDelete; for (auto *arrow : arrowRegistry.all()) { - if (arrow->getStartItem() == card || arrow->getTargetItem() == card) { + if (arrow->getStartItem() == item || arrow->getTargetItem() == item) { if (sameZone) { arrow->updatePath(); } else { diff --git a/cockatrice/src/game_graphics/game_scene.h b/cockatrice/src/game_graphics/game_scene.h index 5856d718d..3f7d40ed0 100644 --- a/cockatrice/src/game_graphics/game_scene.h +++ b/cockatrice/src/game_graphics/game_scene.h @@ -283,7 +283,7 @@ public slots: /// Queues up arrow deletion but doesn't directly modify the scene void requestArrowDeletion(int playerId, int arrowId); - void onCardZoneChanged(CardItem *card, bool sameZone); + void onCardZoneChanged(CardState *card, bool sameZone); /** * @brief Refreshes the view of a card that the server just moved. * diff --git a/cockatrice/src/game_graphics/player/menu/player_menu.cpp b/cockatrice/src/game_graphics/player/menu/player_menu.cpp index 7e69df66e..10e7a7ddc 100644 --- a/cockatrice/src/game_graphics/player/menu/player_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/player_menu.cpp @@ -87,22 +87,24 @@ void PlayerMenu::setMenusForGraphicItems() } } -QMenu *PlayerMenu::updateCardMenu(CardItem *card) +QMenu *PlayerMenu::updateCardMenu(CardState *card) { if (!card) { emit cardMenuUpdated(nullptr); return nullptr; } + auto *cardItem = qobject_cast(card->parent()); + // If is spectator (as spectators don't need card menus), return // only update the menu if the card is actually selected if ((player->getLogic()->getGame()->getPlayerManager()->isSpectator() && !player->getLogic()->getGame()->getPlayerManager()->isJudge()) || - player->getGameScene()->getActiveCard() != card) { + cardItem == nullptr || player->getGameScene()->getActiveCard() != cardItem) { return nullptr; } - CardMenu *menu = new CardMenu(player, card, shortcutsActive); + CardMenu *menu = new CardMenu(player, cardItem, shortcutsActive); connect(menu, &CardMenu::cardInfoRequested, this, &PlayerMenu::cardInfoRequested); emit cardMenuUpdated(menu); diff --git a/cockatrice/src/game_graphics/player/menu/player_menu.h b/cockatrice/src/game_graphics/player/menu/player_menu.h index 27a5efe7e..e21a689de 100644 --- a/cockatrice/src/game_graphics/player/menu/player_menu.h +++ b/cockatrice/src/game_graphics/player/menu/player_menu.h @@ -14,7 +14,7 @@ #include #include -class CardItem; +class CardState; class CardMenu; class PlayerGraphicsItem; class AbstractPlayerComponent; @@ -43,7 +43,7 @@ signals: public slots: void setMenusForGraphicItems(); - QMenu *updateCardMenu(CardItem *card); + QMenu *updateCardMenu(CardState *card); private slots: void refreshShortcuts(); diff --git a/cockatrice/src/game_graphics/zones/card_zone.cpp b/cockatrice/src/game_graphics/zones/card_zone.cpp index b29ad3666..0f91c10b8 100644 --- a/cockatrice/src/game_graphics/zones/card_zone.cpp +++ b/cockatrice/src/game_graphics/zones/card_zone.cpp @@ -24,6 +24,7 @@ CardZone::CardZone(CardZoneLogic *_logic, QGraphicsItem *parent) connect(logic, &CardZoneLogic::cardCountChanged, this, &CardZone::invalidateItems); connect(logic, &CardZoneLogic::reorganizeCards, this, &CardZone::invalidateItems); connect(logic, &CardZoneLogic::cardAdded, this, &CardZone::onCardAdded); + connect(logic, &CardZoneLogic::createCardRequested, this, &CardZone::onCreateCardRequested); connect(logic, &CardZoneLogic::setGraphicsVisibility, this, [this](bool v) { this->setVisible(v); }); connect(logic, &CardZoneLogic::updateGraphics, this, [this]() { update(); }); connect(logic, &CardZoneLogic::reorganizeCards, this, &CardZone::reorganizeCards); @@ -59,6 +60,14 @@ void CardZone::onCardAdded(CardState *addedCard) addedItem->update(); } +void CardZone::onCreateCardRequested(const ServerInfo_Card &cardInfo, bool reorganize) +{ + CardRef cardRef = {QString::fromStdString(cardInfo.name()), QString::fromStdString(cardInfo.provider_id())}; + auto *card = new CardItem(getLogic()->getPlayer(), nullptr, cardRef, cardInfo.id()); + card->processCardInfo(cardInfo); + getLogic()->addCard(card->getState(), reorganize, cardInfo.x(), cardInfo.y()); +} + void CardZone::retranslateUi() { for (auto *card : cardItems()) { diff --git a/cockatrice/src/game_graphics/zones/card_zone.h b/cockatrice/src/game_graphics/zones/card_zone.h index 7568cf25f..ea77cb057 100644 --- a/cockatrice/src/game_graphics/zones/card_zone.h +++ b/cockatrice/src/game_graphics/zones/card_zone.h @@ -22,6 +22,7 @@ class QAction; class QGraphicsItem; class QMenu; class QPoint; +class ServerInfo_Card; template class QList; class CardItem; @@ -59,6 +60,8 @@ public slots: * connection in CardZone's constructor dispatches through the vtable. */ virtual void onCardAdded(CardState *addedCard); + /** @brief Builds the card item for @p cardInfo and hands it to this zone's logic. */ + void onCreateCardRequested(const ServerInfo_Card &cardInfo, bool reorganize); public: enum diff --git a/cockatrice/src/game_graphics/zones/table_zone.cpp b/cockatrice/src/game_graphics/zones/table_zone.cpp index 2fcff1d8a..8cb209c5a 100644 --- a/cockatrice/src/game_graphics/zones/table_zone.cpp +++ b/cockatrice/src/game_graphics/zones/table_zone.cpp @@ -248,11 +248,14 @@ void TableZone::reorganizeCards() cardItems()[i]->setPos(actualX, actualY); cardItems()[i]->setRealZValue(ZValues::tableCardZValue(actualX, actualY)); - QListIterator attachedCardIterator(cardItems()[i]->getAttachedCards()); + QListIterator attachedCardIterator(cardItems()[i]->getAttachedCards()); int j = 0; while (attachedCardIterator.hasNext()) { ++j; - CardItem *attachedCard = attachedCardIterator.next(); + CardItem *attachedCard = qobject_cast(attachedCardIterator.next()->parent()); + if (attachedCard == nullptr) { + continue; + } qreal childX = actualX - j * STACKED_CARD_OFFSET_X; qreal childY = y + 5; attachedCard->setPos(childX, childY); From 345b0e8ba9d2161b5d7cfc3d18a1ec03238874a5 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:44:15 +0100 Subject: [PATCH 073/108] [Refactor] Move game logic into libcockatrice_game (#7437) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Refactor] Move game logic into libcockatrice_game * [Refactor] Include card_state.h in player_actions.h * [Game] Inject settings providers into libcockatrice_game, drop Widgets * [Game] Follow base rename to createCardRequested after rebase * [Game] Drop unused CardItem forward declaration * [Game] Fix includes after moving game logic into libcockatrice_game --------- Co-authored-by: Lukas Brübach --- CMakeLists.txt | 3 + cockatrice/CMakeLists.txt | 23 +--- .../settings_card_preference_provider.h | 7 +- cockatrice/src/game/replay.h | 26 ----- .../game_graphics/board/abstract_card_item.h | 9 +- .../game_graphics/board/abstract_counter.cpp | 8 +- .../src/game_graphics/board/arrow_item.cpp | 10 +- .../src/game_graphics/board/arrow_item.h | 2 +- .../game_graphics/board/arrow_registry.cpp | 2 +- .../src/game_graphics/board/arrow_registry.h | 3 +- .../src/game_graphics/board/card_item.cpp | 22 ++-- .../src/game_graphics/board/card_item.h | 2 +- .../deckview/deck_view_container.cpp | 6 +- .../game_graphics/dialogs/dlg_create_token.h | 3 +- .../dialogs/dlg_move_top_cards_until.h | 3 +- cockatrice/src/game_graphics/game_scene.cpp | 16 +-- cockatrice/src/game_graphics/game_scene.h | 4 +- cockatrice/src/game_graphics/hand_counter.cpp | 4 +- .../game_graphics/log/message_log_widget.cpp | 20 ++-- .../game_graphics/player/menu/card_menu.cpp | 16 +-- .../player/menu/custom_zone_menu.cpp | 4 +- .../game_graphics/player/menu/grave_menu.cpp | 12 +- .../game_graphics/player/menu/hand_menu.cpp | 14 +-- .../player/menu/library_menu.cpp | 14 +-- .../game_graphics/player/menu/move_menu.cpp | 6 +- .../game_graphics/player/menu/player_menu.cpp | 8 +- .../src/game_graphics/player/menu/pt_menu.cpp | 4 +- .../game_graphics/player/menu/rfg_menu.cpp | 8 +- .../game_graphics/player/menu/say_menu.cpp | 4 +- .../player/menu/sideboard_menu.cpp | 6 +- .../player/menu/utility_menu.cpp | 6 +- .../game_graphics/player/player_dialogs.cpp | 11 +- .../src/game_graphics/player/player_dialogs.h | 2 + .../player/player_graphics_item.cpp | 18 +-- .../game_graphics/player/player_target.cpp | 4 +- .../src/game_graphics/zones/card_zone.cpp | 4 +- .../src/game_graphics/zones/hand_zone.cpp | 10 +- .../src/game_graphics/zones/hand_zone.h | 2 +- .../src/game_graphics/zones/pile_zone.cpp | 12 +- .../src/game_graphics/zones/select_zone.cpp | 4 +- .../src/game_graphics/zones/stack_zone.cpp | 12 +- .../src/game_graphics/zones/table_zone.cpp | 12 +- .../src/game_graphics/zones/table_zone.h | 2 +- .../src/game_graphics/zones/view_zone.cpp | 10 +- .../src/game_graphics/zones/view_zone.h | 2 +- .../game_graphics/zones/view_zone_widget.cpp | 19 ++-- .../widgets/replay/replay_manager.cpp | 2 +- .../interface/widgets/replay/replay_manager.h | 3 +- .../interface/widgets/replay/replay_widget.h | 3 +- .../widgets/server/user/user_context_menu.cpp | 6 +- .../src/interface/widgets/tabs/tab_game.cpp | 27 +++-- .../interface/widgets/tabs/tab_supervisor.cpp | 6 +- libcockatrice_game/CMakeLists.txt | 81 +++++++++++++ .../libcockatrice}/game/abstract_game.cpp | 0 .../libcockatrice}/game/abstract_game.h | 0 .../libcockatrice}/game/board/arrow_data.cpp | 0 .../libcockatrice}/game/board/arrow_data.h | 0 .../libcockatrice}/game/board/card_list.cpp | 0 .../libcockatrice}/game/board/card_list.h | 0 .../libcockatrice}/game/board/card_state.cpp | 0 .../libcockatrice}/game/board/card_state.h | 0 .../game/board/counter_state.cpp | 0 .../libcockatrice}/game/board/counter_state.h | 0 .../libcockatrice}/game/game.cpp | 7 +- .../libcockatrice}/game/game.h | 7 +- .../game/game_event_handler.cpp | 0 .../libcockatrice}/game/game_event_handler.h | 1 - .../libcockatrice}/game/game_meta_info.cpp | 0 .../libcockatrice}/game/game_meta_info.h | 0 .../libcockatrice}/game/game_state.cpp | 0 .../libcockatrice}/game/game_state.h | 0 .../libcockatrice}/game/phase.cpp | 0 .../libcockatrice}/game/phase.h | 0 .../game/player/card_menu_action_type.h | 0 .../game/player/card_move_request.h | 0 .../game/player/event_processing_options.h | 0 .../player/move_top_cards_until_options.h | 0 .../game/player/player_actions.cpp | 57 +++++++--- .../game/player/player_actions.h | 25 ++++- .../game/player/player_event_handler.cpp | 0 .../game/player/player_event_handler.h | 0 .../game/player/player_info.cpp | 0 .../libcockatrice}/game/player/player_info.h | 0 .../game/player/player_logic.cpp | 13 ++- .../libcockatrice}/game/player/player_logic.h | 11 +- .../game/player/player_manager.cpp | 9 +- .../game/player/player_manager.h | 12 +- .../libcockatrice}/game/player/token_info.h | 0 .../libcockatrice}/game/replay.cpp | 9 +- .../libcockatrice/game/replay.h | 32 ++++++ .../libcockatrice/game}/translation.h | 0 .../game/zones/card_zone_algorithms.h | 0 .../game/zones/card_zone_logic.cpp | 3 +- .../game/zones/card_zone_logic.h | 5 +- .../game/zones/hand_zone_logic.cpp | 0 .../game/zones/hand_zone_logic.h | 0 .../game/zones/pile_zone_logic.cpp | 0 .../game/zones/pile_zone_logic.h | 0 .../game/zones/stack_zone_logic.cpp | 0 .../game/zones/stack_zone_logic.h | 0 .../game/zones/table_zone_logic.cpp | 0 .../game/zones/table_zone_logic.h | 0 .../game/zones/view_zone_logic.cpp | 14 ++- .../game/zones/view_zone_logic.h | 6 + tests/CMakeLists.txt | 5 + tests/card_zone_algorithms/CMakeLists.txt | 2 +- tests/game/CMakeLists.txt | 28 +++++ tests/game/game_smoke_test.cpp | 106 ++++++++++++++++++ 108 files changed, 598 insertions(+), 281 deletions(-) delete mode 100644 cockatrice/src/game/replay.h create mode 100644 libcockatrice_game/CMakeLists.txt rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/abstract_game.cpp (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/abstract_game.h (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/board/arrow_data.cpp (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/board/arrow_data.h (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/board/card_list.cpp (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/board/card_list.h (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/board/card_state.cpp (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/board/card_state.h (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/board/counter_state.cpp (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/board/counter_state.h (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/game.cpp (75%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/game.h (69%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/game_event_handler.cpp (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/game_event_handler.h (99%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/game_meta_info.cpp (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/game_meta_info.h (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/game_state.cpp (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/game_state.h (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/phase.cpp (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/phase.h (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/player/card_menu_action_type.h (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/player/card_move_request.h (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/player/event_processing_options.h (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/player/move_top_cards_until_options.h (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/player/player_actions.cpp (97%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/player/player_actions.h (91%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/player/player_event_handler.cpp (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/player/player_event_handler.h (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/player/player_info.cpp (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/player/player_info.h (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/player/player_logic.cpp (95%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/player/player_logic.h (94%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/player/player_manager.cpp (86%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/player/player_manager.h (84%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/player/token_info.h (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/replay.cpp (51%) create mode 100644 libcockatrice_game/libcockatrice/game/replay.h rename {cockatrice/src/client => libcockatrice_game/libcockatrice/game}/translation.h (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/zones/card_zone_algorithms.h (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/zones/card_zone_logic.cpp (98%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/zones/card_zone_logic.h (97%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/zones/hand_zone_logic.cpp (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/zones/hand_zone_logic.h (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/zones/pile_zone_logic.cpp (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/zones/pile_zone_logic.h (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/zones/stack_zone_logic.cpp (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/zones/stack_zone_logic.h (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/zones/table_zone_logic.cpp (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/zones/table_zone_logic.h (100%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/zones/view_zone_logic.cpp (92%) rename {cockatrice/src => libcockatrice_game/libcockatrice}/game/zones/view_zone_logic.h (87%) create mode 100644 tests/game/CMakeLists.txt create mode 100644 tests/game/game_smoke_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 41fe393e7..159c4a24f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -420,6 +420,9 @@ if(WITH_ORACLE OR WITH_CLIENT) add_subdirectory(${CMAKE_SOURCE_DIR}/libcockatrice_models ${CMAKE_BINARY_DIR}/libcockatrice_models) add_subdirectory(${CMAKE_SOURCE_DIR}/libcockatrice_filters ${CMAKE_BINARY_DIR}/libcockatrice_filters) endif() +if(WITH_CLIENT) + add_subdirectory(${CMAKE_SOURCE_DIR}/libcockatrice_game ${CMAKE_BINARY_DIR}/libcockatrice_game) +endif() if(WITH_SERVER) add_subdirectory(servatrice) diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 2df79e0f6..a4dc96937 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -69,20 +69,15 @@ set(cockatrice_SOURCES src/filters/filter_builder.cpp src/filters/filter_tree_model.cpp src/filters/syntax_help.cpp - src/game/abstract_game.cpp src/game_graphics/board/abstract_card_drag_item.cpp src/game_graphics/board/abstract_card_item.cpp src/game_graphics/board/abstract_counter.cpp - src/game/board/arrow_data.cpp src/game_graphics/board/arrow_item.cpp src/game_graphics/board/arrow_registry.cpp src/game_graphics/board/arrow_target.cpp src/game_graphics/board/card_drag_item.cpp src/game_graphics/board/card_item.cpp - src/game/board/card_list.cpp - src/game/board/card_state.cpp src/game_graphics/board/counter_general.cpp - src/game/board/counter_state.cpp src/game_graphics/board/translate_counter_name.cpp src/game_graphics/deckview/deck_view.cpp src/game_graphics/deckview/deck_view_container.cpp @@ -91,15 +86,10 @@ set(cockatrice_SOURCES src/game_graphics/dialogs/dlg_create_token.cpp src/game_graphics/dialogs/dlg_move_top_cards_until.cpp src/game_graphics/dialogs/dlg_roll_dice.cpp - src/game/game.cpp - src/game/game_event_handler.cpp - src/game/game_meta_info.cpp src/game_graphics/game_scene.cpp - src/game/game_state.cpp src/game_graphics/game_view.cpp src/game_graphics/hand_counter.cpp src/game_graphics/log/message_log_widget.cpp - src/game/phase.cpp src/game_graphics/phases_toolbar.cpp src/game_graphics/player/menu/card_menu.cpp src/game_graphics/player/menu/custom_zone_menu.cpp @@ -117,23 +107,11 @@ set(cockatrice_SOURCES src/game_graphics/tally/stats_tally.cpp src/game_graphics/tally/subtype_tally.cpp src/game_graphics/tally/tally.cpp - src/game/player/player_actions.cpp src/game_graphics/player/player_area.cpp src/game_graphics/player/player_dialogs.cpp - src/game/player/player_event_handler.cpp src/game_graphics/player/player_graphics_item.cpp - src/game/player/player_info.cpp src/game_graphics/player/player_list_widget.cpp - src/game/player/player_logic.cpp - src/game/player/player_manager.cpp src/game_graphics/player/player_target.cpp - src/game/replay.cpp - src/game/zones/card_zone_logic.cpp - src/game/zones/hand_zone_logic.cpp - src/game/zones/pile_zone_logic.cpp - src/game/zones/stack_zone_logic.cpp - src/game/zones/table_zone_logic.cpp - src/game/zones/view_zone_logic.cpp src/game_graphics/zones/card_zone.cpp src/game_graphics/zones/hand_zone.cpp src/game_graphics/zones/pile_zone.cpp @@ -573,6 +551,7 @@ target_link_libraries( PUBLIC libcockatrice_card libcockatrice_deck_list libcockatrice_filters + libcockatrice_game libcockatrice_utility libcockatrice_network libcockatrice_models diff --git a/cockatrice/src/database/interface/settings_card_preference_provider.h b/cockatrice/src/database/interface/settings_card_preference_provider.h index 1feb2006b..f4bf23141 100644 --- a/cockatrice/src/database/interface/settings_card_preference_provider.h +++ b/cockatrice/src/database/interface/settings_card_preference_provider.h @@ -2,13 +2,18 @@ #define COCKATRICE_SETTINGS_CARD_PREFERENCE_PROVIDER_H #include "../../client/settings/cache_settings.h" +#include #include #include #include -class SettingsCardPreferenceProvider : public ICardPreferenceProvider +class SettingsCardPreferenceProvider : public QObject, public ICardPreferenceProvider { public: + explicit SettingsCardPreferenceProvider(QObject *parent = nullptr) : QObject(parent) + { + } + [[nodiscard]] QString getCardPreferenceOverride(const QString &cardName) const override { return SettingsCache::instance().cardOverrides().getCardPreferenceOverride(cardName); diff --git a/cockatrice/src/game/replay.h b/cockatrice/src/game/replay.h deleted file mode 100644 index cc995dbac..000000000 --- a/cockatrice/src/game/replay.h +++ /dev/null @@ -1,26 +0,0 @@ -/** - * @file replay.h - * @ingroup GameLogic - * @ingroup Replay - */ -//! \todo Document this file. - -#ifndef COCKATRICE_REPLAY_H -#define COCKATRICE_REPLAY_H - -#include "abstract_game.h" - -#include - -class GameReplay; -class QObject; - -class Replay : public AbstractGame -{ - Q_OBJECT - -public: - explicit Replay(QObject *_parent, const GameReplay *_replay, bool isLocalGame); -}; - -#endif // COCKATRICE_REPLAY_H diff --git a/cockatrice/src/game_graphics/board/abstract_card_item.h b/cockatrice/src/game_graphics/board/abstract_card_item.h index 01fc4bb0a..eee8ec743 100644 --- a/cockatrice/src/game_graphics/board/abstract_card_item.h +++ b/cockatrice/src/game_graphics/board/abstract_card_item.h @@ -7,11 +7,18 @@ #ifndef ABSTRACTCARDITEM_H #define ABSTRACTCARDITEM_H -#include "../../game/board/card_state.h" #include "../animated_item.h" #include "arrow_target.h" #include "graphics_item_type.h" +#include +#include +#include +#include +#include +#include +#include + class PlayerLogic; class CardInfo; class QPainter; diff --git a/cockatrice/src/game_graphics/board/abstract_counter.cpp b/cockatrice/src/game_graphics/board/abstract_counter.cpp index 93eb9e011..30ffde320 100644 --- a/cockatrice/src/game_graphics/board/abstract_counter.cpp +++ b/cockatrice/src/game_graphics/board/abstract_counter.cpp @@ -2,10 +2,6 @@ #include "../../client/settings/cache_settings.h" #include "../../client/settings/shortcuts_settings.h" -#include "../../game/board/counter_state.h" -#include "../../game/player/player_actions.h" -#include "../../game/player/player_info.h" -#include "../../game/player/player_logic.h" #include "../../game_graphics/board/translate_counter_name.h" #include "../../interface/widgets/menus/tearoff_menu.h" @@ -23,6 +19,10 @@ #include #include #include +#include +#include +#include +#include #include #include diff --git a/cockatrice/src/game_graphics/board/arrow_item.cpp b/cockatrice/src/game_graphics/board/arrow_item.cpp index cd02a6b1d..f7df98ab5 100644 --- a/cockatrice/src/game_graphics/board/arrow_item.cpp +++ b/cockatrice/src/game_graphics/board/arrow_item.cpp @@ -2,11 +2,6 @@ #include "arrow_item.h" #include "../../client/settings/cache_settings.h" -#include "../../game/board/arrow_data.h" -#include "../../game/player/player_actions.h" -#include "../../game/player/player_info.h" -#include "../../game/player/player_logic.h" -#include "../../game/zones/card_zone_logic.h" #include "../game_scene.h" #include "../player/player_target.h" #include "../z_values.h" @@ -27,6 +22,11 @@ #include #include #include +#include +#include +#include +#include +#include #include #include #include diff --git a/cockatrice/src/game_graphics/board/arrow_item.h b/cockatrice/src/game_graphics/board/arrow_item.h index ab11f6d84..64a48cd14 100644 --- a/cockatrice/src/game_graphics/board/arrow_item.h +++ b/cockatrice/src/game_graphics/board/arrow_item.h @@ -1,7 +1,6 @@ #ifndef ARROWITEM_H #define ARROWITEM_H -#include "../../game/board/arrow_data.h" #include "../animated_item.h" #include "arrow_target.h" #include "graphics_item_type.h" @@ -18,6 +17,7 @@ #include #include #include +#include #include class CardItem; diff --git a/cockatrice/src/game_graphics/board/arrow_registry.cpp b/cockatrice/src/game_graphics/board/arrow_registry.cpp index fe1c34263..10b3cde2f 100644 --- a/cockatrice/src/game_graphics/board/arrow_registry.cpp +++ b/cockatrice/src/game_graphics/board/arrow_registry.cpp @@ -1,9 +1,9 @@ #include "arrow_registry.h" -#include "../../game/board/arrow_data.h" #include "arrow_item.h" #include +#include void ArrowRegistry::insert(QSharedPointer data, ArrowItem *arrow) { diff --git a/cockatrice/src/game_graphics/board/arrow_registry.h b/cockatrice/src/game_graphics/board/arrow_registry.h index f07c5c469..7103747e8 100644 --- a/cockatrice/src/game_graphics/board/arrow_registry.h +++ b/cockatrice/src/game_graphics/board/arrow_registry.h @@ -1,11 +1,10 @@ #ifndef COCKATRICE_ARROW_REGISTRY_H #define COCKATRICE_ARROW_REGISTRY_H -#include "../../game/board/arrow_data.h" - #include #include #include +#include class ArrowItem; struct ArrowData; diff --git a/cockatrice/src/game_graphics/board/card_item.cpp b/cockatrice/src/game_graphics/board/card_item.cpp index 8030331eb..acd0764c9 100644 --- a/cockatrice/src/game_graphics/board/card_item.cpp +++ b/cockatrice/src/game_graphics/board/card_item.cpp @@ -1,17 +1,6 @@ #include "card_item.h" #include "../../client/settings/cache_settings.h" -#include "../../game/abstract_game.h" -#include "../../game/board/card_state.h" -#include "../../game/game_state.h" -#include "../../game/phase.h" -#include "../../game/player/player_actions.h" -#include "../../game/player/player_info.h" -#include "../../game/player/player_logic.h" -#include "../../game/player/player_manager.h" -#include "../../game/zones/card_zone_logic.h" -#include "../../game/zones/table_zone_logic.h" -#include "../../game/zones/view_zone_logic.h" #include "../card_dimensions.h" #include "../game_scene.h" #include "arrow_item.h" @@ -41,6 +30,17 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include #include #include diff --git a/cockatrice/src/game_graphics/board/card_item.h b/cockatrice/src/game_graphics/board/card_item.h index 706c00195..01dacc021 100644 --- a/cockatrice/src/game_graphics/board/card_item.h +++ b/cockatrice/src/game_graphics/board/card_item.h @@ -7,7 +7,6 @@ #ifndef CARDITEM_H #define CARDITEM_H -#include "../../game/board/card_state.h" #include "abstract_card_item.h" #include "graphics_item_type.h" @@ -16,6 +15,7 @@ #include #include #include +#include #include class CardDragItem; diff --git a/cockatrice/src/game_graphics/deckview/deck_view_container.cpp b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp index 870392aa6..3bef9e59e 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view_container.cpp +++ b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp @@ -2,9 +2,6 @@ #include "../../client/settings/cache_settings.h" #include "../../client/settings/shortcuts_settings.h" -#include "../../game/abstract_game.h" -#include "../../game/game_event_handler.h" -#include "../../game/game_state.h" #include "../../interface/card_picture_loader/card_picture_loader.h" #include "../../interface/deck_loader/deck_loader.h" #include "../../interface/widgets/dialogs/dlg_load_deck.h" @@ -34,6 +31,9 @@ #include #include #include +#include +#include +#include #include #include #include diff --git a/cockatrice/src/game_graphics/dialogs/dlg_create_token.h b/cockatrice/src/game_graphics/dialogs/dlg_create_token.h index c63c5d7b2..323e61829 100644 --- a/cockatrice/src/game_graphics/dialogs/dlg_create_token.h +++ b/cockatrice/src/game_graphics/dialogs/dlg_create_token.h @@ -7,12 +7,11 @@ #ifndef DLG_CREATETOKEN_H #define DLG_CREATETOKEN_H -#include "../../game/player/token_info.h" - #include #include #include #include +#include #include class QLabel; diff --git a/cockatrice/src/game_graphics/dialogs/dlg_move_top_cards_until.h b/cockatrice/src/game_graphics/dialogs/dlg_move_top_cards_until.h index 3b0fe19ec..da2e70b0a 100644 --- a/cockatrice/src/game_graphics/dialogs/dlg_move_top_cards_until.h +++ b/cockatrice/src/game_graphics/dialogs/dlg_move_top_cards_until.h @@ -7,12 +7,11 @@ #ifndef DLG_MOVE_TOP_CARDS_UNTIL_H #define DLG_MOVE_TOP_CARDS_UNTIL_H -#include "../../game/player/move_top_cards_until_options.h" - #include #include #include #include +#include #include class FilterString; diff --git a/cockatrice/src/game_graphics/game_scene.cpp b/cockatrice/src/game_graphics/game_scene.cpp index e7362871f..db1040006 100644 --- a/cockatrice/src/game_graphics/game_scene.cpp +++ b/cockatrice/src/game_graphics/game_scene.cpp @@ -1,14 +1,6 @@ #include "game_scene.h" #include "../client/settings/cache_settings.h" -#include "../game/abstract_game.h" -#include "../game/board/arrow_data.h" -#include "../game/player/player_actions.h" -#include "../game/player/player_event_handler.h" -#include "../game/player/player_info.h" -#include "../game/player/player_logic.h" -#include "../game/zones/card_zone_logic.h" -#include "../game/zones/view_zone_logic.h" #include "animated_item.h" #include "board/abstract_card_item.h" #include "board/arrow_item.h" @@ -41,6 +33,14 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include #include #include #include diff --git a/cockatrice/src/game_graphics/game_scene.h b/cockatrice/src/game_graphics/game_scene.h index 3f7d40ed0..0d4db70a8 100644 --- a/cockatrice/src/game_graphics/game_scene.h +++ b/cockatrice/src/game_graphics/game_scene.h @@ -1,8 +1,6 @@ #ifndef GAMESCENE_H #define GAMESCENE_H -#include "../game/board/arrow_data.h" -#include "../game/zones/card_zone_logic.h" #include "animated_item.h" #include "board/arrow_item.h" #include "board/arrow_registry.h" @@ -20,6 +18,8 @@ #include #include #include +#include +#include #include // IWYU pragma: keep diff --git a/cockatrice/src/game_graphics/hand_counter.cpp b/cockatrice/src/game_graphics/hand_counter.cpp index 9320c1295..b9ea2d554 100644 --- a/cockatrice/src/game_graphics/hand_counter.cpp +++ b/cockatrice/src/game_graphics/hand_counter.cpp @@ -1,7 +1,5 @@ #include "hand_counter.h" -#include "../game/board/card_list.h" -#include "../game/zones/card_zone_logic.h" #include "../interface/pixel_map_generator.h" #include @@ -14,6 +12,8 @@ #include #include #include +#include +#include #include class QGraphicsItem; diff --git a/cockatrice/src/game_graphics/log/message_log_widget.cpp b/cockatrice/src/game_graphics/log/message_log_widget.cpp index ab244dc0b..046a1ae90 100644 --- a/cockatrice/src/game_graphics/log/message_log_widget.cpp +++ b/cockatrice/src/game_graphics/log/message_log_widget.cpp @@ -3,16 +3,6 @@ #include "../../client/settings/cache_settings.h" #include "../../client/settings/card_counter_settings.h" #include "../../client/sound_engine.h" -#include "../../client/translation.h" -#include "../../game/abstract_game.h" -#include "../../game/board/card_list.h" -#include "../../game/game_state.h" -#include "../../game/phase.h" -#include "../../game/player/player_event_handler.h" -#include "../../game/player/player_info.h" -#include "../../game/player/player_logic.h" -#include "../../game/player/player_manager.h" -#include "../../game/zones/card_zone_logic.h" #include "../../interface/card_localization.h" #include "../../interface/widgets/server/chat_view/chat_view.h" #include "../board/card_item.h" @@ -24,6 +14,16 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include #include #include diff --git a/cockatrice/src/game_graphics/player/menu/card_menu.cpp b/cockatrice/src/game_graphics/player/menu/card_menu.cpp index 2dcb82bb6..9f00c0cba 100644 --- a/cockatrice/src/game_graphics/player/menu/card_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/card_menu.cpp @@ -3,15 +3,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/card_counter_settings.h" #include "../../../client/settings/shortcuts_settings.h" -#include "../../../game/abstract_game.h" -#include "../../../game/player/card_menu_action_type.h" -#include "../../../game/player/player_info.h" -#include "../../../game/player/player_manager.h" -#include "../../../game/zones/card_zone_logic.h" #include "../../board/card_item.h" -#include "../../game/player/player_actions.h" -#include "../../game/player/player_logic.h" -#include "../../game/zones/view_zone_logic.h" #include "../../game_scene.h" #include "../player_graphics_item.h" #include "libcockatrice/card/card_info.h" @@ -35,6 +27,14 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include #include #include #include diff --git a/cockatrice/src/game_graphics/player/menu/custom_zone_menu.cpp b/cockatrice/src/game_graphics/player/menu/custom_zone_menu.cpp index e16ebc112..87a0d1540 100644 --- a/cockatrice/src/game_graphics/player/menu/custom_zone_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/custom_zone_menu.cpp @@ -1,13 +1,13 @@ #include "custom_zone_menu.h" -#include "../../../game/player/player_info.h" -#include "../../game/player/player_logic.h" #include "../../game_scene.h" #include "../player_graphics_item.h" #include #include #include +#include +#include CustomZoneMenu::CustomZoneMenu(PlayerGraphicsItem *_player) : player(_player) { diff --git a/cockatrice/src/game_graphics/player/menu/grave_menu.cpp b/cockatrice/src/game_graphics/player/menu/grave_menu.cpp index 580d2642e..001a4d0cf 100644 --- a/cockatrice/src/game_graphics/player/menu/grave_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/grave_menu.cpp @@ -2,13 +2,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" -#include "../../../game/player/player_info.h" -#include "../../../game/player/player_manager.h" -#include "../../../game/zones/pile_zone_logic.h" #include "../../../interface/widgets/menus/tearoff_menu.h" -#include "../../game/abstract_game.h" -#include "../../game/player/player_actions.h" -#include "../../game/player/player_logic.h" #include "../player_graphics_item.h" #include @@ -19,6 +13,12 @@ #include #include #include +#include +#include +#include +#include +#include +#include #include class QWidget; diff --git a/cockatrice/src/game_graphics/player/menu/hand_menu.cpp b/cockatrice/src/game_graphics/player/menu/hand_menu.cpp index 141542cb4..e992f3ff5 100644 --- a/cockatrice/src/game_graphics/player/menu/hand_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/hand_menu.cpp @@ -2,14 +2,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" -#include "../../../game/board/card_list.h" -#include "../../../game/player/player_info.h" -#include "../../../game/player/player_manager.h" -#include "../../../game/zones/hand_zone_logic.h" #include "../../../interface/widgets/menus/tearoff_menu.h" -#include "../../game/abstract_game.h" -#include "../../game/player/player_actions.h" -#include "../../game/player/player_logic.h" #include "../player_graphics_item.h" #include @@ -20,6 +13,13 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include #include class QWidget; diff --git a/cockatrice/src/game_graphics/player/menu/library_menu.cpp b/cockatrice/src/game_graphics/player/menu/library_menu.cpp index aa389006e..5f90c3760 100644 --- a/cockatrice/src/game_graphics/player/menu/library_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/library_menu.cpp @@ -2,14 +2,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" -#include "../../../game/board/card_list.h" -#include "../../../game/player/player_info.h" -#include "../../../game/player/player_manager.h" -#include "../../../game/zones/pile_zone_logic.h" #include "../../../interface/widgets/menus/tearoff_menu.h" -#include "../../game/abstract_game.h" -#include "../../game/player/player_actions.h" -#include "../../game/player/player_logic.h" #include "../player_graphics_item.h" #include @@ -23,6 +16,13 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include class QWidget; diff --git a/cockatrice/src/game_graphics/player/menu/move_menu.cpp b/cockatrice/src/game_graphics/player/menu/move_menu.cpp index 9802c5c3a..b81c4619b 100644 --- a/cockatrice/src/game_graphics/player/menu/move_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/move_menu.cpp @@ -2,13 +2,13 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" -#include "../../../game/player/card_menu_action_type.h" -#include "../../game/player/player_actions.h" -#include "../../game/player/player_logic.h" #include "../../game_scene.h" #include "../player_graphics_item.h" #include +#include +#include +#include MoveMenu::MoveMenu(PlayerGraphicsItem *player) : QMenu(tr("Move to")) { diff --git a/cockatrice/src/game_graphics/player/menu/player_menu.cpp b/cockatrice/src/game_graphics/player/menu/player_menu.cpp index 10e7a7ddc..582586ac2 100644 --- a/cockatrice/src/game_graphics/player/menu/player_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/player_menu.cpp @@ -2,10 +2,6 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" -#include "../../../game/abstract_game.h" -#include "../../../game/player/player_info.h" -#include "../../../game/player/player_logic.h" -#include "../../../game/player/player_manager.h" #include "../../../game_graphics/zones/hand_zone.h" #include "../../../game_graphics/zones/pile_zone.h" #include "../../../game_graphics/zones/table_zone.h" @@ -26,6 +22,10 @@ #include #include +#include +#include +#include +#include PlayerMenu::PlayerMenu(PlayerGraphicsItem *_player) : QObject(_player), player(_player) { diff --git a/cockatrice/src/game_graphics/player/menu/pt_menu.cpp b/cockatrice/src/game_graphics/player/menu/pt_menu.cpp index f07d73aef..e6c65fcfb 100644 --- a/cockatrice/src/game_graphics/player/menu/pt_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/pt_menu.cpp @@ -2,12 +2,12 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" -#include "../../game/player/player_actions.h" -#include "../../game/player/player_logic.h" #include "../../game_scene.h" #include "../player_graphics_item.h" #include +#include +#include PtMenu::PtMenu(PlayerGraphicsItem *player) : QMenu(tr("Power / toughness")) { diff --git a/cockatrice/src/game_graphics/player/menu/rfg_menu.cpp b/cockatrice/src/game_graphics/player/menu/rfg_menu.cpp index 98514280f..415d4a2ef 100644 --- a/cockatrice/src/game_graphics/player/menu/rfg_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/rfg_menu.cpp @@ -1,10 +1,6 @@ #include "rfg_menu.h" -#include "../../../game/player/player_info.h" -#include "../../../game/zones/pile_zone_logic.h" #include "../../../interface/widgets/menus/tearoff_menu.h" -#include "../../game/player/player_actions.h" -#include "../../game/player/player_logic.h" #include "../player_graphics_item.h" #include @@ -12,6 +8,10 @@ #include #include #include +#include +#include +#include +#include #include class QWidget; diff --git a/cockatrice/src/game_graphics/player/menu/say_menu.cpp b/cockatrice/src/game_graphics/player/menu/say_menu.cpp index d467c6469..486508da9 100644 --- a/cockatrice/src/game_graphics/player/menu/say_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/say_menu.cpp @@ -1,14 +1,14 @@ #include "say_menu.h" #include "../../../client/settings/cache_settings.h" -#include "../../game/player/player_actions.h" -#include "../../game/player/player_logic.h" #include "../player_graphics_item.h" #include #include #include #include +#include +#include #include SayMenu::SayMenu(PlayerGraphicsItem *_player) : player(_player) diff --git a/cockatrice/src/game_graphics/player/menu/sideboard_menu.cpp b/cockatrice/src/game_graphics/player/menu/sideboard_menu.cpp index 1eef3604f..e601a0e09 100644 --- a/cockatrice/src/game_graphics/player/menu/sideboard_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/sideboard_menu.cpp @@ -2,13 +2,13 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" -#include "../../../game/player/player_info.h" -#include "../../game/player/player_actions.h" -#include "../../game/player/player_logic.h" #include "../player_graphics_item.h" #include #include +#include +#include +#include SideboardMenu::SideboardMenu(PlayerGraphicsItem *player, QMenu *playerMenu) : QMenu(playerMenu) { diff --git a/cockatrice/src/game_graphics/player/menu/utility_menu.cpp b/cockatrice/src/game_graphics/player/menu/utility_menu.cpp index 7332c7807..3b8affff5 100644 --- a/cockatrice/src/game_graphics/player/menu/utility_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/utility_menu.cpp @@ -2,9 +2,6 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" -#include "../../../game/player/player_info.h" -#include "../../game/player/player_actions.h" -#include "../../game/player/player_logic.h" #include "../../game_scene.h" #include "../player_graphics_item.h" #include "libcockatrice/card/card_info.h" @@ -14,6 +11,9 @@ #include #include #include +#include +#include +#include UtilityMenu::UtilityMenu(PlayerGraphicsItem *_player, QMenu *playerMenu) : QMenu(playerMenu), player(_player) { diff --git a/cockatrice/src/game_graphics/player/player_dialogs.cpp b/cockatrice/src/game_graphics/player/player_dialogs.cpp index e60a53a80..a5baa8ca8 100644 --- a/cockatrice/src/game_graphics/player/player_dialogs.cpp +++ b/cockatrice/src/game_graphics/player/player_dialogs.cpp @@ -2,8 +2,6 @@ #include "../../client/settings/cache_settings.h" #include "../../client/settings/card_counter_settings.h" -#include "../../game/player/player_actions.h" -#include "../../game/player/player_logic.h" #include "../../interface/widgets/utility/get_text_with_max.h" #include "../board/abstract_counter.h" #include "../board/card_item.h" @@ -18,6 +16,8 @@ #include #include #include +#include +#include #include PlayerDialogs::PlayerDialogs(PlayerGraphicsItem *_player, PlayerActions *_playerActions) @@ -35,7 +35,7 @@ PlayerDialogs::PlayerDialogs(PlayerGraphicsItem *_player, PlayerActions *_player &PlayerDialogs::onShuffleBottomDialogRequested); connect(playerActions, &PlayerActions::requestMulliganDialog, this, &PlayerDialogs::onMulliganDialogRequested); - + connect(playerActions, &PlayerActions::startingHandSizeChosen, this, &PlayerDialogs::onStartingHandSizeChosen); connect(playerActions, &PlayerActions::requestDrawCardsDialog, this, &PlayerDialogs::onDrawCardsDialogRequested); connect(playerActions, &PlayerActions::requestMoveTopCardsToDialog, this, @@ -127,6 +127,11 @@ void PlayerDialogs::onMulliganDialogRequested(int startSize, int handSize, int d } } +void PlayerDialogs::onStartingHandSizeChosen(int number) +{ + SettingsCache::instance().userInterface().setStartingHandSize(number); +} + void PlayerDialogs::onDrawCardsDialogRequested(int defaultNumberTopCards, int deckSize) { bool ok; diff --git a/cockatrice/src/game_graphics/player/player_dialogs.h b/cockatrice/src/game_graphics/player/player_dialogs.h index 9dc960a1c..4ac443f3c 100644 --- a/cockatrice/src/game_graphics/player/player_dialogs.h +++ b/cockatrice/src/game_graphics/player/player_dialogs.h @@ -17,6 +17,7 @@ class CardItem; class CardRelation; +class CardState; class PlayerActions; class QWidget; struct MoveTopCardsUntilOptions; @@ -58,6 +59,7 @@ public slots: void onSetPTDialogRequested(const QString &oldPT); void onSetAnnotationDialogRequested(const QString &oldAnnotation); void onSetCardCounterDialogRequested(int counterId, const QString &oldValueForDlg); + void onStartingHandSizeChosen(int number); private: PlayerGraphicsItem *player; diff --git a/cockatrice/src/game_graphics/player/player_graphics_item.cpp b/cockatrice/src/game_graphics/player/player_graphics_item.cpp index 14c0dba1c..0805dbb5b 100644 --- a/cockatrice/src/game_graphics/player/player_graphics_item.cpp +++ b/cockatrice/src/game_graphics/player/player_graphics_item.cpp @@ -1,15 +1,6 @@ #include "player_graphics_item.h" #include "../../client/settings/cache_settings.h" -#include "../../game/board/counter_state.h" -#include "../../game/player/player_actions.h" -#include "../../game/player/player_info.h" -#include "../../game/player/player_logic.h" -#include "../../game/zones/card_zone_logic.h" -#include "../../game/zones/hand_zone_logic.h" -#include "../../game/zones/pile_zone_logic.h" -#include "../../game/zones/stack_zone_logic.h" -#include "../../game/zones/table_zone_logic.h" #include "../../interface/card_picture_loader/card_picture_loader.h" #include "../../interface/widgets/cards/art_crop_attribution.h" #include "../../interface/widgets/cards/card_art_utils.h" @@ -45,6 +36,15 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include #include #include diff --git a/cockatrice/src/game_graphics/player/player_target.cpp b/cockatrice/src/game_graphics/player/player_target.cpp index 24734cd42..1cd97e7b5 100644 --- a/cockatrice/src/game_graphics/player/player_target.cpp +++ b/cockatrice/src/game_graphics/player/player_target.cpp @@ -1,8 +1,6 @@ #include "player_target.h" #include "../../client/settings/cache_settings.h" -#include "../../game/player/player_info.h" -#include "../../game/player/player_logic.h" #include "../../interface/pixel_map_generator.h" #include "../board/abstract_counter.h" #include "../board/abstract_graphics_item.h" @@ -30,6 +28,8 @@ #include #include #include +#include +#include #include #include #include diff --git a/cockatrice/src/game_graphics/zones/card_zone.cpp b/cockatrice/src/game_graphics/zones/card_zone.cpp index 0f91c10b8..fd3b5849f 100644 --- a/cockatrice/src/game_graphics/zones/card_zone.cpp +++ b/cockatrice/src/game_graphics/zones/card_zone.cpp @@ -1,13 +1,13 @@ #include "card_zone.h" -#include "../../game/board/card_list.h" -#include "../../game/zones/card_zone_logic.h" #include "../board/abstract_graphics_item.h" #include "../board/card_item.h" #include #include #include +#include +#include #include class QGraphicsItem; diff --git a/cockatrice/src/game_graphics/zones/hand_zone.cpp b/cockatrice/src/game_graphics/zones/hand_zone.cpp index 8cd5e4631..31758c455 100644 --- a/cockatrice/src/game_graphics/zones/hand_zone.cpp +++ b/cockatrice/src/game_graphics/zones/hand_zone.cpp @@ -1,11 +1,6 @@ #include "hand_zone.h" #include "../../client/settings/cache_settings.h" -#include "../../game/player/player_actions.h" -#include "../../game/player/player_info.h" -#include "../../game/player/player_logic.h" -#include "../../game/zones/card_zone_logic.h" -#include "../../game/zones/hand_zone_logic.h" #include "../../interface/theme_manager.h" #include "../board/card_drag_item.h" #include "../board/card_item.h" @@ -18,6 +13,11 @@ #include #include #include +#include +#include +#include +#include +#include #include #include diff --git a/cockatrice/src/game_graphics/zones/hand_zone.h b/cockatrice/src/game_graphics/zones/hand_zone.h index 5c91d684d..ed8f1e8d5 100644 --- a/cockatrice/src/game_graphics/zones/hand_zone.h +++ b/cockatrice/src/game_graphics/zones/hand_zone.h @@ -7,10 +7,10 @@ #ifndef HANDZONE_H #define HANDZONE_H -#include "../../game/board/card_list.h" #include "select_zone.h" #include +#include #include class HandZoneLogic; diff --git a/cockatrice/src/game_graphics/zones/pile_zone.cpp b/cockatrice/src/game_graphics/zones/pile_zone.cpp index 61946bea1..08af241b6 100644 --- a/cockatrice/src/game_graphics/zones/pile_zone.cpp +++ b/cockatrice/src/game_graphics/zones/pile_zone.cpp @@ -1,12 +1,6 @@ #include "pile_zone.h" #include "../../client/settings/cache_settings.h" -#include "../../game/board/card_list.h" -#include "../../game/player/player_actions.h" -#include "../../game/player/player_info.h" -#include "../../game/player/player_logic.h" -#include "../../game/zones/card_zone_logic.h" -#include "../../game/zones/pile_zone_logic.h" #include "../board/card_drag_item.h" #include "../board/card_item.h" #include "../card_dimensions.h" @@ -24,6 +18,12 @@ #include #include #include +#include +#include +#include +#include +#include +#include #include #include #include diff --git a/cockatrice/src/game_graphics/zones/select_zone.cpp b/cockatrice/src/game_graphics/zones/select_zone.cpp index 7a74c5342..ccac9decc 100644 --- a/cockatrice/src/game_graphics/zones/select_zone.cpp +++ b/cockatrice/src/game_graphics/zones/select_zone.cpp @@ -1,8 +1,6 @@ #include "select_zone.h" #include "../../client/settings/cache_settings.h" -#include "../../game/board/card_list.h" -#include "../../game/zones/card_zone_logic.h" #include "../board/card_item.h" #include "../game_scene.h" @@ -17,6 +15,8 @@ #include #include #include +#include +#include #include #include #include diff --git a/cockatrice/src/game_graphics/zones/stack_zone.cpp b/cockatrice/src/game_graphics/zones/stack_zone.cpp index 4c97a1969..d46a4bd94 100644 --- a/cockatrice/src/game_graphics/zones/stack_zone.cpp +++ b/cockatrice/src/game_graphics/zones/stack_zone.cpp @@ -1,11 +1,5 @@ #include "stack_zone.h" -#include "../../game/board/card_list.h" -#include "../../game/player/player_actions.h" -#include "../../game/player/player_info.h" -#include "../../game/player/player_logic.h" -#include "../../game/zones/card_zone_logic.h" -#include "../../game/zones/stack_zone_logic.h" #include "../../interface/theme_manager.h" #include "../board/card_drag_item.h" #include "../board/card_item.h" @@ -19,6 +13,12 @@ #include #include #include +#include +#include +#include +#include +#include +#include #include #include diff --git a/cockatrice/src/game_graphics/zones/table_zone.cpp b/cockatrice/src/game_graphics/zones/table_zone.cpp index 8cb209c5a..0dffab353 100644 --- a/cockatrice/src/game_graphics/zones/table_zone.cpp +++ b/cockatrice/src/game_graphics/zones/table_zone.cpp @@ -1,12 +1,6 @@ #include "table_zone.h" #include "../../client/settings/cache_settings.h" -#include "../../game/board/card_list.h" -#include "../../game/player/player_actions.h" -#include "../../game/player/player_info.h" -#include "../../game/player/player_logic.h" -#include "../../game/zones/card_zone_logic.h" -#include "../../game/zones/table_zone_logic.h" #include "../../interface/theme_manager.h" #include "../board/abstract_card_item.h" #include "../board/card_drag_item.h" @@ -32,6 +26,12 @@ #include #include #include +#include +#include +#include +#include +#include +#include #include #include #include diff --git a/cockatrice/src/game_graphics/zones/table_zone.h b/cockatrice/src/game_graphics/zones/table_zone.h index f3fc0dc5a..8aa4936ad 100644 --- a/cockatrice/src/game_graphics/zones/table_zone.h +++ b/cockatrice/src/game_graphics/zones/table_zone.h @@ -7,7 +7,6 @@ #ifndef TABLEZONE_H #define TABLEZONE_H -#include "../../game/zones/table_zone_logic.h" #include "../animated_item.h" #include "../card_dimensions.h" #include "select_zone.h" @@ -18,6 +17,7 @@ #include #include #include +#include #include class CardDragItem; diff --git a/cockatrice/src/game_graphics/zones/view_zone.cpp b/cockatrice/src/game_graphics/zones/view_zone.cpp index 27041e04c..83c13d467 100644 --- a/cockatrice/src/game_graphics/zones/view_zone.cpp +++ b/cockatrice/src/game_graphics/zones/view_zone.cpp @@ -1,11 +1,6 @@ #include "view_zone.h" #include "../../client/settings/cache_settings.h" -#include "../../game/player/player_actions.h" -#include "../../game/player/player_info.h" -#include "../../game/player/player_logic.h" -#include "../../game/zones/card_zone_logic.h" -#include "../../game/zones/view_zone_logic.h" #include "../board/card_drag_item.h" #include "../board/card_item.h" #include "../card_dimensions.h" @@ -26,6 +21,11 @@ #include #include #include +#include +#include +#include +#include +#include #include #include #include diff --git a/cockatrice/src/game_graphics/zones/view_zone.h b/cockatrice/src/game_graphics/zones/view_zone.h index 4032da760..f91b24834 100644 --- a/cockatrice/src/game_graphics/zones/view_zone.h +++ b/cockatrice/src/game_graphics/zones/view_zone.h @@ -7,7 +7,6 @@ #ifndef ZONEVIEWERZONE_H #define ZONEVIEWERZONE_H -#include "../../game/board/card_list.h" #include "select_zone.h" #include @@ -17,6 +16,7 @@ #include #include #include +#include #include #include diff --git a/cockatrice/src/game_graphics/zones/view_zone_widget.cpp b/cockatrice/src/game_graphics/zones/view_zone_widget.cpp index d6d42c3d9..b1ca42ee1 100644 --- a/cockatrice/src/game_graphics/zones/view_zone_widget.cpp +++ b/cockatrice/src/game_graphics/zones/view_zone_widget.cpp @@ -1,13 +1,7 @@ #include "view_zone_widget.h" #include "../../client/settings/cache_settings.h" -#include "../../client/translation.h" #include "../../filters/syntax_help.h" -#include "../../game/board/card_list.h" -#include "../../game/player/player_actions.h" -#include "../../game/player/player_logic.h" -#include "../../game/zones/card_zone_logic.h" -#include "../../game/zones/view_zone_logic.h" #include "../../interface/pixel_map_generator.h" #include "../card_dimensions.h" #include "../z_values.h" @@ -45,6 +39,12 @@ #include #include #include +#include +#include +#include +#include +#include +#include #include #include #include @@ -177,9 +177,10 @@ ZoneViewWidget::ZoneViewWidget(PlayerLogic *_player, vbox->addItem(zoneHBox); - zone = new ZoneViewZone(new ZoneViewZoneLogic(player, _origZone, numberCards, _revealZone, _writeableRevealZone, - _isReversed, zoneContainer), - zoneContainer); + zone = + new ZoneViewZone(new ZoneViewZoneLogic(player, _origZone, numberCards, _revealZone, _writeableRevealZone, + _isReversed, &SettingsCache::instance().userInterface(), zoneContainer), + zoneContainer); connect(zone, &ZoneViewZone::wheelEventReceived, scrollBarProxy, &ScrollableGraphicsProxyWidget::recieveWheelEvent); retranslateUi(); diff --git a/cockatrice/src/interface/widgets/replay/replay_manager.cpp b/cockatrice/src/interface/widgets/replay/replay_manager.cpp index f3f1da2c4..8ba0cb653 100644 --- a/cockatrice/src/interface/widgets/replay/replay_manager.cpp +++ b/cockatrice/src/interface/widgets/replay/replay_manager.cpp @@ -1,13 +1,13 @@ #include "replay_manager.h" #include "../../../client/settings/cache_settings.h" -#include "../../../game/player/event_processing_options.h" #include "libcockatrice/protocol/pb/game_event.pb.h" #include "libcockatrice/protocol/pb/game_event_container.pb.h" #include "libcockatrice/protocol/pb/game_replay.pb.h" #include #include +#include #include #include #include diff --git a/cockatrice/src/interface/widgets/replay/replay_manager.h b/cockatrice/src/interface/widgets/replay/replay_manager.h index 3b33deada..3849b86ae 100644 --- a/cockatrice/src/interface/widgets/replay/replay_manager.h +++ b/cockatrice/src/interface/widgets/replay/replay_manager.h @@ -1,11 +1,10 @@ #ifndef COCKATRICE_REPLAY_MANAGER_H #define COCKATRICE_REPLAY_MANAGER_H -#include "../../../game/player/event_processing_options.h" - #include #include #include +#include #include class GameReplay; diff --git a/cockatrice/src/interface/widgets/replay/replay_widget.h b/cockatrice/src/interface/widgets/replay/replay_widget.h index 74aace570..0e78442ce 100644 --- a/cockatrice/src/interface/widgets/replay/replay_widget.h +++ b/cockatrice/src/interface/widgets/replay/replay_widget.h @@ -8,9 +8,8 @@ #ifndef REPLAY_WIDGET_H #define REPLAY_WIDGET_H -#include "../../../game/player/event_processing_options.h" - #include +#include #include class ReplayManager; diff --git a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp index 0b5523f67..447e63673 100644 --- a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp @@ -1,8 +1,5 @@ #include "user_context_menu.h" -#include "../../../../game/abstract_game.h" -#include "../../../../game/game_event_handler.h" -#include "../../../../game/game_meta_info.h" #include "../../dialogs/dlg_report_user.h" #include "../../interface/widgets/tabs/tab_supervisor.h" #include "../chat_view/chat_view.h" @@ -37,6 +34,9 @@ #include #include #include +#include +#include +#include #include #include #include diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.cpp b/cockatrice/src/interface/widgets/tabs/tab_game.cpp index c637dd0dd..9e724727f 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_game.cpp @@ -2,16 +2,10 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" -#include "../../../game/abstract_game.h" -#include "../../../game/game_event_handler.h" -#include "../../../game/game_meta_info.h" -#include "../../../game/game_state.h" -#include "../../../game/player/player_info.h" -#include "../../../game/player/player_manager.h" +#include "../../../database/interface/settings_card_preference_provider.h" #include "../../../game_graphics/board/abstract_card_item.h" -#include "../game/game.h" -#include "../game/player/player_logic.h" -#include "../game/replay.h" +#include "../game_graphics/board/arrow_item.h" +#include "../game_graphics/board/card_item.h" #include "../game_graphics/deckview/deck_view_container.h" #include "../game_graphics/deckview/tabbed_deck_view_container.h" #include "../game_graphics/game_scene.h" @@ -71,6 +65,15 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include #include #include @@ -97,7 +100,8 @@ TabGame::TabGame(TabSupervisor *_tabSupervisor, GameReplay *_replay) : Tab(_tabSupervisor), sayLabel(nullptr), sayEdit(nullptr) { // THIS CTOR IS USED ON REPLAY - game = new Replay(this, _replay, tabSupervisor->getIsLocalGame()); + game = new Replay(this, _replay, tabSupervisor->getIsLocalGame(), &SettingsCache::instance().userInterface(), + new SettingsCardPreferenceProvider(this)); createCardInfoDock(true); createPlayerListDock(true); @@ -141,7 +145,8 @@ TabGame::TabGame(TabSupervisor *_tabSupervisor, : Tab(_tabSupervisor), userListProxy(_tabSupervisor->getUserListManager()) { // THIS CTOR IS USED ON GAMES - game = new Game(this, tabSupervisor->getIsLocalGame(), _clients, event, _roomGameTypes); + game = new Game(this, tabSupervisor->getIsLocalGame(), _clients, event, _roomGameTypes, + &SettingsCache::instance().userInterface(), new SettingsCardPreferenceProvider(this)); createCardInfoDock(); createPlayerListDock(); diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index 06d60a756..3316232a4 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -2,9 +2,6 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" -#include "../../../game/abstract_game.h" -#include "../../../game/game_event_handler.h" -#include "../../../game/game_meta_info.h" #include "../../intents/contexts/context_connect_to_server.h" #include "../../intents/contexts/context_join_game.h" #include "../../intents/contexts/context_join_room.h" @@ -63,6 +60,9 @@ #include #include #include +#include +#include +#include #include #include #include diff --git a/libcockatrice_game/CMakeLists.txt b/libcockatrice_game/CMakeLists.txt new file mode 100644 index 000000000..e7d362ca2 --- /dev/null +++ b/libcockatrice_game/CMakeLists.txt @@ -0,0 +1,81 @@ +set(CMAKE_AUTOMOC ON) +set(CMAKE_AUTOUIC ON) +set(CMAKE_AUTORCC ON) + +set(HEADERS + libcockatrice/game/abstract_game.h + libcockatrice/game/board/arrow_data.h + libcockatrice/game/board/card_list.h + libcockatrice/game/board/card_state.h + libcockatrice/game/board/counter_state.h + libcockatrice/game/game.h + libcockatrice/game/game_event_handler.h + libcockatrice/game/game_meta_info.h + libcockatrice/game/game_state.h + libcockatrice/game/phase.h + libcockatrice/game/player/card_menu_action_type.h + libcockatrice/game/player/card_move_request.h + libcockatrice/game/player/event_processing_options.h + libcockatrice/game/player/move_top_cards_until_options.h + libcockatrice/game/player/player_actions.h + libcockatrice/game/player/player_event_handler.h + libcockatrice/game/player/player_info.h + libcockatrice/game/player/player_logic.h + libcockatrice/game/player/player_manager.h + libcockatrice/game/player/token_info.h + libcockatrice/game/replay.h + libcockatrice/game/translation.h + libcockatrice/game/zones/card_zone_algorithms.h + libcockatrice/game/zones/card_zone_logic.h + libcockatrice/game/zones/hand_zone_logic.h + libcockatrice/game/zones/pile_zone_logic.h + libcockatrice/game/zones/stack_zone_logic.h + libcockatrice/game/zones/table_zone_logic.h + libcockatrice/game/zones/view_zone_logic.h +) + +qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) + +add_library( + libcockatrice_game STATIC + ${MOC_SOURCES} + libcockatrice/game/abstract_game.cpp + libcockatrice/game/board/arrow_data.cpp + libcockatrice/game/board/card_list.cpp + libcockatrice/game/board/card_state.cpp + libcockatrice/game/board/counter_state.cpp + libcockatrice/game/game.cpp + libcockatrice/game/game_event_handler.cpp + libcockatrice/game/game_meta_info.cpp + libcockatrice/game/game_state.cpp + libcockatrice/game/phase.cpp + libcockatrice/game/player/player_actions.cpp + libcockatrice/game/player/player_event_handler.cpp + libcockatrice/game/player/player_info.cpp + libcockatrice/game/player/player_logic.cpp + libcockatrice/game/player/player_manager.cpp + libcockatrice/game/replay.cpp + libcockatrice/game/zones/card_zone_logic.cpp + libcockatrice/game/zones/hand_zone_logic.cpp + libcockatrice/game/zones/pile_zone_logic.cpp + libcockatrice/game/zones/stack_zone_logic.cpp + libcockatrice/game/zones/table_zone_logic.cpp + libcockatrice/game/zones/view_zone_logic.cpp +) + +target_include_directories(libcockatrice_game PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) + +target_link_libraries( + libcockatrice_game + PUBLIC libcockatrice_card + libcockatrice_deck_list + libcockatrice_filters + libcockatrice_interfaces + libcockatrice_network + libcockatrice_protocol + libcockatrice_settings + libcockatrice_utility + ${QT_CORE_MODULE} + ${COCKATRICE_QT_VERSION_NAME}::Concurrent + ${COCKATRICE_QT_VERSION_NAME}::Gui +) diff --git a/cockatrice/src/game/abstract_game.cpp b/libcockatrice_game/libcockatrice/game/abstract_game.cpp similarity index 100% rename from cockatrice/src/game/abstract_game.cpp rename to libcockatrice_game/libcockatrice/game/abstract_game.cpp diff --git a/cockatrice/src/game/abstract_game.h b/libcockatrice_game/libcockatrice/game/abstract_game.h similarity index 100% rename from cockatrice/src/game/abstract_game.h rename to libcockatrice_game/libcockatrice/game/abstract_game.h diff --git a/cockatrice/src/game/board/arrow_data.cpp b/libcockatrice_game/libcockatrice/game/board/arrow_data.cpp similarity index 100% rename from cockatrice/src/game/board/arrow_data.cpp rename to libcockatrice_game/libcockatrice/game/board/arrow_data.cpp diff --git a/cockatrice/src/game/board/arrow_data.h b/libcockatrice_game/libcockatrice/game/board/arrow_data.h similarity index 100% rename from cockatrice/src/game/board/arrow_data.h rename to libcockatrice_game/libcockatrice/game/board/arrow_data.h diff --git a/cockatrice/src/game/board/card_list.cpp b/libcockatrice_game/libcockatrice/game/board/card_list.cpp similarity index 100% rename from cockatrice/src/game/board/card_list.cpp rename to libcockatrice_game/libcockatrice/game/board/card_list.cpp diff --git a/cockatrice/src/game/board/card_list.h b/libcockatrice_game/libcockatrice/game/board/card_list.h similarity index 100% rename from cockatrice/src/game/board/card_list.h rename to libcockatrice_game/libcockatrice/game/board/card_list.h diff --git a/cockatrice/src/game/board/card_state.cpp b/libcockatrice_game/libcockatrice/game/board/card_state.cpp similarity index 100% rename from cockatrice/src/game/board/card_state.cpp rename to libcockatrice_game/libcockatrice/game/board/card_state.cpp diff --git a/cockatrice/src/game/board/card_state.h b/libcockatrice_game/libcockatrice/game/board/card_state.h similarity index 100% rename from cockatrice/src/game/board/card_state.h rename to libcockatrice_game/libcockatrice/game/board/card_state.h diff --git a/cockatrice/src/game/board/counter_state.cpp b/libcockatrice_game/libcockatrice/game/board/counter_state.cpp similarity index 100% rename from cockatrice/src/game/board/counter_state.cpp rename to libcockatrice_game/libcockatrice/game/board/counter_state.cpp diff --git a/cockatrice/src/game/board/counter_state.h b/libcockatrice_game/libcockatrice/game/board/counter_state.h similarity index 100% rename from cockatrice/src/game/board/counter_state.h rename to libcockatrice_game/libcockatrice/game/board/counter_state.h diff --git a/cockatrice/src/game/game.cpp b/libcockatrice_game/libcockatrice/game/game.cpp similarity index 75% rename from cockatrice/src/game/game.cpp rename to libcockatrice_game/libcockatrice/game/game.cpp index 7343d052a..a1a4a31a8 100644 --- a/cockatrice/src/game/game.cpp +++ b/libcockatrice_game/libcockatrice/game/game.cpp @@ -16,13 +16,16 @@ Game::Game(QObject *_parent, bool isLocalGame, QList &_clients, const Event_GameJoined &event, - const QMap &_roomGameTypes) + const QMap &_roomGameTypes, + IInterfaceSettingsProvider *interfaceSettings, + ICardPreferenceProvider *cardPreferences) : AbstractGame(_parent) { gameMetaInfo->setFromProto(event.game_info()); gameMetaInfo->setRoomGameTypes(_roomGameTypes); gameState = new GameState(this, 0, event.host_id(), isLocalGame, _clients, false, event.resuming(), -1, false); connect(gameMetaInfo, &GameMetaInfo::startedChanged, gameState, &GameState::onStartedChanged); - playerManager = new PlayerManager(this, event.player_id(), event.judge(), event.spectator()); + playerManager = new PlayerManager(this, event.player_id(), event.judge(), event.spectator(), interfaceSettings, + cardPreferences); gameMetaInfo->setStarted(false); } diff --git a/cockatrice/src/game/game.h b/libcockatrice_game/libcockatrice/game/game.h similarity index 69% rename from cockatrice/src/game/game.h rename to libcockatrice_game/libcockatrice/game/game.h index 6a309cfb2..f80b75ea5 100644 --- a/cockatrice/src/game/game.h +++ b/libcockatrice_game/libcockatrice/game/game.h @@ -18,6 +18,9 @@ class QString; template class QMap; template class QList; +class ICardPreferenceProvider; +class IInterfaceSettingsProvider; + class Game : public AbstractGame { Q_OBJECT @@ -27,7 +30,9 @@ public: bool isLocalGame, QList &_clients, const Event_GameJoined &event, - const QMap &_roomGameTypes); + const QMap &_roomGameTypes, + IInterfaceSettingsProvider *interfaceSettings = nullptr, + ICardPreferenceProvider *cardPreferences = nullptr); }; #endif // COCKATRICE_GAME_H diff --git a/cockatrice/src/game/game_event_handler.cpp b/libcockatrice_game/libcockatrice/game/game_event_handler.cpp similarity index 100% rename from cockatrice/src/game/game_event_handler.cpp rename to libcockatrice_game/libcockatrice/game/game_event_handler.cpp diff --git a/cockatrice/src/game/game_event_handler.h b/libcockatrice_game/libcockatrice/game/game_event_handler.h similarity index 99% rename from cockatrice/src/game/game_event_handler.h rename to libcockatrice_game/libcockatrice/game/game_event_handler.h index 15e6b771c..f7b11780b 100644 --- a/cockatrice/src/game/game_event_handler.h +++ b/libcockatrice_game/libcockatrice/game/game_event_handler.h @@ -37,7 +37,6 @@ class AbstractClient; class AbstractGame; class GameEventContainer; -class GameEventContext; class PendingCommand; class PlayerLogic; class Response; diff --git a/cockatrice/src/game/game_meta_info.cpp b/libcockatrice_game/libcockatrice/game/game_meta_info.cpp similarity index 100% rename from cockatrice/src/game/game_meta_info.cpp rename to libcockatrice_game/libcockatrice/game/game_meta_info.cpp diff --git a/cockatrice/src/game/game_meta_info.h b/libcockatrice_game/libcockatrice/game/game_meta_info.h similarity index 100% rename from cockatrice/src/game/game_meta_info.h rename to libcockatrice_game/libcockatrice/game/game_meta_info.h diff --git a/cockatrice/src/game/game_state.cpp b/libcockatrice_game/libcockatrice/game/game_state.cpp similarity index 100% rename from cockatrice/src/game/game_state.cpp rename to libcockatrice_game/libcockatrice/game/game_state.cpp diff --git a/cockatrice/src/game/game_state.h b/libcockatrice_game/libcockatrice/game/game_state.h similarity index 100% rename from cockatrice/src/game/game_state.h rename to libcockatrice_game/libcockatrice/game/game_state.h diff --git a/cockatrice/src/game/phase.cpp b/libcockatrice_game/libcockatrice/game/phase.cpp similarity index 100% rename from cockatrice/src/game/phase.cpp rename to libcockatrice_game/libcockatrice/game/phase.cpp diff --git a/cockatrice/src/game/phase.h b/libcockatrice_game/libcockatrice/game/phase.h similarity index 100% rename from cockatrice/src/game/phase.h rename to libcockatrice_game/libcockatrice/game/phase.h diff --git a/cockatrice/src/game/player/card_menu_action_type.h b/libcockatrice_game/libcockatrice/game/player/card_menu_action_type.h similarity index 100% rename from cockatrice/src/game/player/card_menu_action_type.h rename to libcockatrice_game/libcockatrice/game/player/card_menu_action_type.h diff --git a/cockatrice/src/game/player/card_move_request.h b/libcockatrice_game/libcockatrice/game/player/card_move_request.h similarity index 100% rename from cockatrice/src/game/player/card_move_request.h rename to libcockatrice_game/libcockatrice/game/player/card_move_request.h diff --git a/cockatrice/src/game/player/event_processing_options.h b/libcockatrice_game/libcockatrice/game/player/event_processing_options.h similarity index 100% rename from cockatrice/src/game/player/event_processing_options.h rename to libcockatrice_game/libcockatrice/game/player/event_processing_options.h diff --git a/cockatrice/src/game/player/move_top_cards_until_options.h b/libcockatrice_game/libcockatrice/game/player/move_top_cards_until_options.h similarity index 100% rename from cockatrice/src/game/player/move_top_cards_until_options.h rename to libcockatrice_game/libcockatrice/game/player/move_top_cards_until_options.h diff --git a/cockatrice/src/game/player/player_actions.cpp b/libcockatrice_game/libcockatrice/game/player/player_actions.cpp similarity index 97% rename from cockatrice/src/game/player/player_actions.cpp rename to libcockatrice_game/libcockatrice/game/player/player_actions.cpp index b7e59b4e3..90823fec6 100644 --- a/cockatrice/src/game/player/player_actions.cpp +++ b/libcockatrice_game/libcockatrice/game/player/player_actions.cpp @@ -1,7 +1,5 @@ #include "player_actions.h" -#include "../../client/settings/cache_settings.h" -#include "../../game_graphics/dialogs/dlg_create_token.h" #include "../abstract_game.h" #include "../board/arrow_data.h" #include "../board/card_state.h" @@ -47,6 +45,8 @@ #include #include #include +#include +#include #include #include #include @@ -65,8 +65,7 @@ #include #include #include -#include -#include +#include #include #include #include @@ -80,8 +79,11 @@ // milliseconds in between triggers of the move top cards until action static constexpr int MOVE_TOP_CARD_UNTIL_INTERVAL = 100; -PlayerActions::PlayerActions(PlayerLogic *_player) - : QObject(_player), player(_player), lastTokenTableRow(0), movingCardsUntil(false) +PlayerActions::PlayerActions(PlayerLogic *_player, + IInterfaceSettingsProvider *_interfaceSettings, + ICardPreferenceProvider *_cardPreferences) + : QObject(_player), player(_player), interfaceSettings(_interfaceSettings), cardPreferences(_cardPreferences), + lastTokenTableRow(0), movingCardsUntil(false) { connect(this, &PlayerActions::requestZoneViewToggle, player, &PlayerLogic::onRequestZoneViewToggle); @@ -91,6 +93,26 @@ PlayerActions::PlayerActions(PlayerLogic *_player) connect(moveTopCardTimer, &QTimer::timeout, [this]() { actMoveTopCardToPlay(); }); } +bool PlayerActions::getPlayToStack() const +{ + return interfaceSettings ? interfaceSettings->getPlayToStack() : true; +} + +int PlayerActions::getStartingHandSize() const +{ + return interfaceSettings ? interfaceSettings->getStartingHandSize() : 7; +} + +bool PlayerActions::getAnnotateTokens() const +{ + return interfaceSettings ? interfaceSettings->getAnnotateTokens() : false; +} + +QString PlayerActions::getCardPreferenceOverride(const QString &cardName) const +{ + return cardPreferences ? cardPreferences->getCardPreferenceOverride(cardName) : QString(); +} + void PlayerActions::playCard(CardState *card, bool faceDown) { if (card == nullptr) { @@ -112,7 +134,7 @@ void PlayerActions::playCard(CardState *card, bool faceDown) const CardInfo &info = exactCard.getInfo(); int tableRow = info.getUiAttributes().tableRow; - bool playToStack = SettingsCache::instance().userInterface().getPlayToStack(); + bool playToStack = getPlayToStack(); QString currentZone = card->getZone()->getName(); if (!faceDown && currentZone == ZoneNames::STACK && tableRow == 3) { cmd.set_target_zone(ZoneNames::GRAVE); @@ -214,7 +236,7 @@ void PlayerActions::createArrow(CardState *startCard, const ArrowData &arrow, in // if the card is in hand then it has just been moved to the stack or table as part of drawing the arrow if (arrow.startZone == ZoneNames::HAND) { CardInfoPtr ci = startCard->getCard().getCardPtr(); - bool playToStack = SettingsCache::instance().userInterface().getPlayToStack(); + bool playToStack = getPlayToStack(); if (ci && ((!playToStack && ci->getUiAttributes().tableRow == 3) || (playToStack && ci->getUiAttributes().tableRow != 0 && startCard->getZone()->getName() != ZoneNames::STACK))) { @@ -409,7 +431,7 @@ void PlayerActions::actDrawCard() void PlayerActions::actRequestMulliganDialog() { - int startSize = SettingsCache::instance().userInterface().getStartingHandSize(); + int startSize = getStartingHandSize(); int handSize = player->getHandZone()->getCards().size(); int deckSize = player->getDeckZone()->getCards().size() + handSize; @@ -425,7 +447,7 @@ void PlayerActions::actMulligan(int number) } doMulligan(number); - SettingsCache::instance().userInterface().setStartingHandSize(number); + emit startingHandSizeChosen(number); } void PlayerActions::actMulliganSameSize() @@ -974,13 +996,12 @@ void PlayerActions::setLastTokenInfo(CardInfoPtr cardInfo) return; } - lastTokenInfo = { - .name = cardInfo->getName(), - .color = cardInfo->getColors().isEmpty() ? QString() : cardInfo->getColors().left(1).toLower(), - .pt = cardInfo->getPowTough(), - .annotation = SettingsCache::instance().userInterface().getAnnotateTokens() ? cardInfo->getText() : "", - .destroy = true, - .providerId = SettingsCache::instance().cardOverrides().getCardPreferenceOverride(cardInfo->getName())}; + lastTokenInfo = {.name = cardInfo->getName(), + .color = cardInfo->getColors().isEmpty() ? QString() : cardInfo->getColors().left(1).toLower(), + .pt = cardInfo->getPowTough(), + .annotation = getAnnotateTokens() ? cardInfo->getText() : "", + .destroy = true, + .providerId = getCardPreferenceOverride(cardInfo->getName())}; lastTokenTableRow = TableZoneLogic::tableRowToGridY(cardInfo->getUiAttributes().tableRow); @@ -1208,7 +1229,7 @@ void PlayerActions::createCard(const CardState *sourceCard, } cmd.set_pt(cardInfo->getPowTough().toStdString()); - if (SettingsCache::instance().userInterface().getAnnotateTokens()) { + if (getAnnotateTokens()) { cmd.set_annotation(cardInfo->getText().toStdString()); } else { cmd.set_annotation(""); diff --git a/cockatrice/src/game/player/player_actions.h b/libcockatrice_game/libcockatrice/game/player/player_actions.h similarity index 91% rename from cockatrice/src/game/player/player_actions.h rename to libcockatrice_game/libcockatrice/game/player/player_actions.h index 6875f865c..7ed52b9a1 100644 --- a/cockatrice/src/game/player/player_actions.h +++ b/libcockatrice_game/libcockatrice/game/player/player_actions.h @@ -9,6 +9,7 @@ #define COCKATRICE_PLAYER_ACTIONS_H #include "../board/card_list.h" +#include "../board/card_state.h" #include "card_menu_action_type.h" #include "card_move_request.h" #include "libcockatrice/card/card_info.h" @@ -22,7 +23,6 @@ #include #include -class CardItem; class CardRelation; class QAction; class QTimer; @@ -39,8 +39,12 @@ class Message; class CardState; class CardZoneLogic; class Command_MoveCard; +class GameEventContext; +class ICardPreferenceProvider; +class IInterfaceSettingsProvider; class PendingCommand; class PlayerLogic; +class QAction; struct ArrowData; class PlayerActions : public QObject { @@ -52,7 +56,9 @@ public: RANDOM_CARD_FROM_ZONE = -2 }; - explicit PlayerActions(PlayerLogic *player); + explicit PlayerActions(PlayerLogic *player, + IInterfaceSettingsProvider *interfaceSettings = nullptr, + ICardPreferenceProvider *cardPreferences = nullptr); void sendGameCommand(PendingCommand *pend); void sendGameCommand(const google::protobuf::Message &command); @@ -143,6 +149,12 @@ signals: void requestSortHand(const QList &options); void requestEnableAndSetCreateAnotherTokenAction(const QString &lastTokenName); void requestSetLastToken(CardInfoPtr lastToken); + /** + * @brief The player chose a mulligan size to remember as the starting hand size. + * + * Game logic must not write client settings itself, so persistence is left to the application. + */ + void startingHandSizeChosen(int number); public slots: void setLastToken(CardInfoPtr cardInfo); @@ -275,6 +287,10 @@ public slots: private: PlayerLogic *player; + // Borrowed settings providers (owned by the application). Null outside the client, in which case the compiled-in + // defaults below apply. + IInterfaceSettingsProvider *interfaceSettings; + ICardPreferenceProvider *cardPreferences; int defaultNumberTopCards = 1; int defaultNumberTopCardsToPlaceBelow = 1; @@ -292,6 +308,11 @@ private: bool lastRelatedCreationSucceeded = false; + [[nodiscard]] bool getPlayToStack() const; + [[nodiscard]] int getStartingHandSize() const; + [[nodiscard]] bool getAnnotateTokens() const; + [[nodiscard]] QString getCardPreferenceOverride(const QString &cardName) const; + void createCard(const CardState *sourceCard, const QString &dbCardName, CardRelationType attach = CardRelationType::DoesNotAttach, diff --git a/cockatrice/src/game/player/player_event_handler.cpp b/libcockatrice_game/libcockatrice/game/player/player_event_handler.cpp similarity index 100% rename from cockatrice/src/game/player/player_event_handler.cpp rename to libcockatrice_game/libcockatrice/game/player/player_event_handler.cpp diff --git a/cockatrice/src/game/player/player_event_handler.h b/libcockatrice_game/libcockatrice/game/player/player_event_handler.h similarity index 100% rename from cockatrice/src/game/player/player_event_handler.h rename to libcockatrice_game/libcockatrice/game/player/player_event_handler.h diff --git a/cockatrice/src/game/player/player_info.cpp b/libcockatrice_game/libcockatrice/game/player/player_info.cpp similarity index 100% rename from cockatrice/src/game/player/player_info.cpp rename to libcockatrice_game/libcockatrice/game/player/player_info.cpp diff --git a/cockatrice/src/game/player/player_info.h b/libcockatrice_game/libcockatrice/game/player/player_info.h similarity index 100% rename from cockatrice/src/game/player/player_info.h rename to libcockatrice_game/libcockatrice/game/player/player_info.h diff --git a/cockatrice/src/game/player/player_logic.cpp b/libcockatrice_game/libcockatrice/game/player/player_logic.cpp similarity index 95% rename from cockatrice/src/game/player/player_logic.cpp rename to libcockatrice_game/libcockatrice/game/player/player_logic.cpp index ce4e8a95e..96bd07164 100644 --- a/cockatrice/src/game/player/player_logic.cpp +++ b/libcockatrice_game/libcockatrice/game/player/player_logic.cpp @@ -34,10 +34,17 @@ #include #include -PlayerLogic::PlayerLogic(const ServerInfo_User &info, int _id, bool _local, bool _judge, AbstractGame *_parent) +PlayerLogic::PlayerLogic(const ServerInfo_User &info, + int _id, + bool _local, + bool _judge, + AbstractGame *_parent, + IInterfaceSettingsProvider *interfaceSettings, + ICardPreferenceProvider *cardPreferences) : QObject(_parent), game(_parent), playerInfo(new PlayerInfo(info, _id, _local, _judge)), - playerEventHandler(new PlayerEventHandler(this)), playerActions(new PlayerActions(this)), active(false), - conceded(false), zoneId(0), dialogSemaphore(false) + playerEventHandler(new PlayerEventHandler(this)), + playerActions(new PlayerActions(this, interfaceSettings, cardPreferences)), active(false), conceded(false), + zoneId(0), dialogSemaphore(false) { initializeZones(); } diff --git a/cockatrice/src/game/player/player_logic.h b/libcockatrice_game/libcockatrice/game/player/player_logic.h similarity index 94% rename from cockatrice/src/game/player/player_logic.h rename to libcockatrice_game/libcockatrice/game/player/player_logic.h index 196ee8ebe..d6c4f5187 100644 --- a/cockatrice/src/game/player/player_logic.h +++ b/libcockatrice_game/libcockatrice/game/player/player_logic.h @@ -37,7 +37,6 @@ // signatures, which the moc-generated code needs to be complete. #include -class CardItem; class CounterState; class QWidget; class ServerInfo_PlayerProperties; @@ -48,6 +47,8 @@ template class QSharedPointer; inline Q_LOGGING_CATEGORY(PlayerLog, "player"); class AbstractCardItem; class AbstractGame; +class ICardPreferenceProvider; +class IInterfaceSettingsProvider; class PlayerInfo; class PlayerEventHandler; class PlayerActions; @@ -93,7 +94,13 @@ public slots: void onRequestZoneViewToggle(const QString &zoneName, int numberCards, bool isReversed); public: - PlayerLogic(const ServerInfo_User &info, int _id, bool _local, bool _judge, AbstractGame *_parent); + PlayerLogic(const ServerInfo_User &info, + int _id, + bool _local, + bool _judge, + AbstractGame *_parent, + IInterfaceSettingsProvider *interfaceSettings = nullptr, + ICardPreferenceProvider *cardPreferences = nullptr); ~PlayerLogic() override; void initializeZones(); diff --git a/cockatrice/src/game/player/player_manager.cpp b/libcockatrice_game/libcockatrice/game/player/player_manager.cpp similarity index 86% rename from cockatrice/src/game/player/player_manager.cpp rename to libcockatrice_game/libcockatrice/game/player/player_manager.cpp index 4730805ad..284d21f68 100644 --- a/cockatrice/src/game/player/player_manager.cpp +++ b/libcockatrice_game/libcockatrice/game/player/player_manager.cpp @@ -12,9 +12,12 @@ PlayerManager::PlayerManager(AbstractGame *_game, int _localPlayerId, bool _localPlayerIsJudge, - bool localPlayerIsSpectator) + bool localPlayerIsSpectator, + IInterfaceSettingsProvider *_interfaceSettings, + ICardPreferenceProvider *_cardPreferences) : QObject(_game), game(_game), players(QMap()), localPlayerId(_localPlayerId), - localPlayerIsJudge(_localPlayerIsJudge), localPlayerIsSpectator(localPlayerIsSpectator) + localPlayerIsJudge(_localPlayerIsJudge), localPlayerIsSpectator(localPlayerIsSpectator), + interfaceSettings(_interfaceSettings), cardPreferences(_cardPreferences) { } @@ -52,7 +55,7 @@ bool PlayerManager::isLocalPlayer(int playerId) PlayerLogic *PlayerManager::addPlayer(int playerId, const ServerInfo_User &info) { auto *newPlayer = new PlayerLogic(info, playerId, isLocalPlayer(playerId) || game->getGameState()->getIsLocalGame(), - isJudge(), getGame()); + isJudge(), getGame(), interfaceSettings, cardPreferences); connect(newPlayer, &PlayerLogic::concededChanged, this, &PlayerManager::onPlayerConceded); players.insert(playerId, newPlayer); emit playerAdded(newPlayer); diff --git a/cockatrice/src/game/player/player_manager.h b/libcockatrice_game/libcockatrice/game/player/player_manager.h similarity index 84% rename from cockatrice/src/game/player/player_manager.h rename to libcockatrice_game/libcockatrice/game/player/player_manager.h index 7a9f7645b..c95bbbb96 100644 --- a/cockatrice/src/game/player/player_manager.h +++ b/libcockatrice_game/libcockatrice/game/player/player_manager.h @@ -16,6 +16,8 @@ #include class AbstractGame; +class ICardPreferenceProvider; +class IInterfaceSettingsProvider; class PlayerLogic; class PlayerManager : public QObject @@ -23,7 +25,12 @@ class PlayerManager : public QObject Q_OBJECT public: - PlayerManager(AbstractGame *_game, int _localPlayerId, bool _localPlayerIsJudge, bool localPlayerIsSpectator); + PlayerManager(AbstractGame *_game, + int _localPlayerId, + bool _localPlayerIsJudge, + bool localPlayerIsSpectator, + IInterfaceSettingsProvider *interfaceSettings = nullptr, + ICardPreferenceProvider *cardPreferences = nullptr); AbstractGame *game; QMap players; @@ -31,6 +38,9 @@ public: bool localPlayerIsJudge; bool localPlayerIsSpectator; QMap spectators; + // Borrowed settings providers forwarded to each PlayerLogic (owned by the application). + IInterfaceSettingsProvider *interfaceSettings; + ICardPreferenceProvider *cardPreferences; [[nodiscard]] bool isSpectator() const { diff --git a/cockatrice/src/game/player/token_info.h b/libcockatrice_game/libcockatrice/game/player/token_info.h similarity index 100% rename from cockatrice/src/game/player/token_info.h rename to libcockatrice_game/libcockatrice/game/player/token_info.h diff --git a/cockatrice/src/game/replay.cpp b/libcockatrice_game/libcockatrice/game/replay.cpp similarity index 51% rename from cockatrice/src/game/replay.cpp rename to libcockatrice_game/libcockatrice/game/replay.cpp index 4ab4a6321..4d41a12ea 100644 --- a/cockatrice/src/game/replay.cpp +++ b/libcockatrice_game/libcockatrice/game/replay.cpp @@ -9,10 +9,15 @@ class GameReplay; class QObject; -Replay::Replay(QObject *_parent, const GameReplay *_replay, bool isLocalGame) : AbstractGame(_parent) +Replay::Replay(QObject *_parent, + const GameReplay *_replay, + bool isLocalGame, + IInterfaceSettingsProvider *interfaceSettings, + ICardPreferenceProvider *cardPreferences) + : AbstractGame(_parent) { gameState = new GameState(this, 0, -1, isLocalGame, {}, false, false, -1, false); connect(gameMetaInfo, &GameMetaInfo::startedChanged, gameState, &GameState::onStartedChanged); - playerManager = new PlayerManager(this, -1, false, true); + playerManager = new PlayerManager(this, -1, false, true, interfaceSettings, cardPreferences); loadReplay(_replay); } diff --git a/libcockatrice_game/libcockatrice/game/replay.h b/libcockatrice_game/libcockatrice/game/replay.h new file mode 100644 index 000000000..f8f82a70d --- /dev/null +++ b/libcockatrice_game/libcockatrice/game/replay.h @@ -0,0 +1,32 @@ +/** + * @file replay.h + * @ingroup GameLogic + * @ingroup Replay + */ +//! \todo Document this file. + +#ifndef COCKATRICE_REPLAY_H +#define COCKATRICE_REPLAY_H + +#include "abstract_game.h" + +#include + +class GameReplay; +class QObject; +class ICardPreferenceProvider; +class IInterfaceSettingsProvider; + +class Replay : public AbstractGame +{ + Q_OBJECT + +public: + explicit Replay(QObject *_parent, + const GameReplay *_replay, + bool isLocalGame, + IInterfaceSettingsProvider *interfaceSettings = nullptr, + ICardPreferenceProvider *cardPreferences = nullptr); +}; + +#endif // COCKATRICE_REPLAY_H diff --git a/cockatrice/src/client/translation.h b/libcockatrice_game/libcockatrice/game/translation.h similarity index 100% rename from cockatrice/src/client/translation.h rename to libcockatrice_game/libcockatrice/game/translation.h diff --git a/cockatrice/src/game/zones/card_zone_algorithms.h b/libcockatrice_game/libcockatrice/game/zones/card_zone_algorithms.h similarity index 100% rename from cockatrice/src/game/zones/card_zone_algorithms.h rename to libcockatrice_game/libcockatrice/game/zones/card_zone_algorithms.h diff --git a/cockatrice/src/game/zones/card_zone_logic.cpp b/libcockatrice_game/libcockatrice/game/zones/card_zone_logic.cpp similarity index 98% rename from cockatrice/src/game/zones/card_zone_logic.cpp rename to libcockatrice_game/libcockatrice/game/zones/card_zone_logic.cpp index 522fb5e04..a160dcc1d 100644 --- a/cockatrice/src/game/zones/card_zone_logic.cpp +++ b/libcockatrice_game/libcockatrice/game/zones/card_zone_logic.cpp @@ -1,12 +1,11 @@ #include "card_zone_logic.h" -#include "../../client/translation.h" -#include "../../game_graphics/zones/card_zone.h" #include "../board/card_list.h" #include "../board/card_state.h" #include "../player/player_actions.h" #include "../player/player_info.h" #include "../player/player_logic.h" +#include "../translation.h" #include "libcockatrice/card/database/card_database.h" #include "view_zone_logic.h" diff --git a/cockatrice/src/game/zones/card_zone_logic.h b/libcockatrice_game/libcockatrice/game/zones/card_zone_logic.h similarity index 97% rename from cockatrice/src/game/zones/card_zone_logic.h rename to libcockatrice_game/libcockatrice/game/zones/card_zone_logic.h index 327b78f61..d3cd9121f 100644 --- a/cockatrice/src/game/zones/card_zone_logic.h +++ b/libcockatrice_game/libcockatrice/game/zones/card_zone_logic.h @@ -7,8 +7,8 @@ #ifndef COCKATRICE_CARD_ZONE_LOGIC_H #define COCKATRICE_CARD_ZONE_LOGIC_H -#include "../../client/translation.h" #include "../board/card_list.h" +#include "../translation.h" #include #include @@ -21,9 +21,6 @@ inline Q_LOGGING_CATEGORY(CardZoneLogicLog, "card_zone_logic"); class PlayerLogic; class ZoneViewZoneLogic; -class QMenu; -class QAction; -class QPainter; class CardDragItem; class CardZoneLogic : public QObject diff --git a/cockatrice/src/game/zones/hand_zone_logic.cpp b/libcockatrice_game/libcockatrice/game/zones/hand_zone_logic.cpp similarity index 100% rename from cockatrice/src/game/zones/hand_zone_logic.cpp rename to libcockatrice_game/libcockatrice/game/zones/hand_zone_logic.cpp diff --git a/cockatrice/src/game/zones/hand_zone_logic.h b/libcockatrice_game/libcockatrice/game/zones/hand_zone_logic.h similarity index 100% rename from cockatrice/src/game/zones/hand_zone_logic.h rename to libcockatrice_game/libcockatrice/game/zones/hand_zone_logic.h diff --git a/cockatrice/src/game/zones/pile_zone_logic.cpp b/libcockatrice_game/libcockatrice/game/zones/pile_zone_logic.cpp similarity index 100% rename from cockatrice/src/game/zones/pile_zone_logic.cpp rename to libcockatrice_game/libcockatrice/game/zones/pile_zone_logic.cpp diff --git a/cockatrice/src/game/zones/pile_zone_logic.h b/libcockatrice_game/libcockatrice/game/zones/pile_zone_logic.h similarity index 100% rename from cockatrice/src/game/zones/pile_zone_logic.h rename to libcockatrice_game/libcockatrice/game/zones/pile_zone_logic.h diff --git a/cockatrice/src/game/zones/stack_zone_logic.cpp b/libcockatrice_game/libcockatrice/game/zones/stack_zone_logic.cpp similarity index 100% rename from cockatrice/src/game/zones/stack_zone_logic.cpp rename to libcockatrice_game/libcockatrice/game/zones/stack_zone_logic.cpp diff --git a/cockatrice/src/game/zones/stack_zone_logic.h b/libcockatrice_game/libcockatrice/game/zones/stack_zone_logic.h similarity index 100% rename from cockatrice/src/game/zones/stack_zone_logic.h rename to libcockatrice_game/libcockatrice/game/zones/stack_zone_logic.h diff --git a/cockatrice/src/game/zones/table_zone_logic.cpp b/libcockatrice_game/libcockatrice/game/zones/table_zone_logic.cpp similarity index 100% rename from cockatrice/src/game/zones/table_zone_logic.cpp rename to libcockatrice_game/libcockatrice/game/zones/table_zone_logic.cpp diff --git a/cockatrice/src/game/zones/table_zone_logic.h b/libcockatrice_game/libcockatrice/game/zones/table_zone_logic.h similarity index 100% rename from cockatrice/src/game/zones/table_zone_logic.h rename to libcockatrice_game/libcockatrice/game/zones/table_zone_logic.h diff --git a/cockatrice/src/game/zones/view_zone_logic.cpp b/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.cpp similarity index 92% rename from cockatrice/src/game/zones/view_zone_logic.cpp rename to libcockatrice_game/libcockatrice/game/zones/view_zone_logic.cpp index 3fcf8d075..88941f12b 100644 --- a/cockatrice/src/game/zones/view_zone_logic.cpp +++ b/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.cpp @@ -1,11 +1,11 @@ #include "view_zone_logic.h" -#include "../../client/settings/cache_settings.h" #include "../board/card_state.h" #include "../player/player_actions.h" #include "../player/player_logic.h" #include +#include #include class PlayerLogic; @@ -23,13 +23,19 @@ ZoneViewZoneLogic::ZoneViewZoneLogic(PlayerLogic *_player, bool _revealZone, bool _writeableRevealZone, bool _isReversed, + IInterfaceSettingsProvider *_interfaceSettings, QObject *parent) : CardZoneLogic(_player, _origZone->getName(), false, false, true, parent), origZone(_origZone), numberCards(_numberCards), revealZone(_revealZone), writeableRevealZone(_writeableRevealZone), - isReversed(_isReversed) + isReversed(_isReversed), interfaceSettings(_interfaceSettings) { } +bool ZoneViewZoneLogic::getCloseEmptyCardView() const +{ + return interfaceSettings ? interfaceSettings->getCloseEmptyCardView() : true; +} + /** * Checks if inserting a card at the given position requires an actual new card to be created and added to the view. * Also does any cardId updates that would be required if a card is inserted in that position. @@ -65,7 +71,7 @@ bool ZoneViewZoneLogic::prepareAddCard(int x) // autoclose check is done both here and in removeCard - if (cards.isEmpty() && !doInsert && SettingsCache::instance().userInterface().getCloseEmptyCardView()) { + if (cards.isEmpty() && !doInsert && getCloseEmptyCardView()) { emit closeView(); } @@ -152,7 +158,7 @@ void ZoneViewZoneLogic::removeCard(int position, bool toNewZone) // card gets dragged within the view. // Another autoclose check is done in prepareAddCard so that the view autocloses if the last card was moved to an // unrevealed portion of the same zone. - if (cards.isEmpty() && SettingsCache::instance().userInterface().getCloseEmptyCardView() && toNewZone) { + if (cards.isEmpty() && getCloseEmptyCardView() && toNewZone) { emit closeView(); return; } diff --git a/cockatrice/src/game/zones/view_zone_logic.h b/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.h similarity index 87% rename from cockatrice/src/game/zones/view_zone_logic.h rename to libcockatrice_game/libcockatrice/game/zones/view_zone_logic.h index 66d4d4791..3592db780 100644 --- a/cockatrice/src/game/zones/view_zone_logic.h +++ b/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.h @@ -13,6 +13,7 @@ class PlayerLogic; class QObject; class PendingCommand; +class IInterfaceSettingsProvider; class ZoneViewZoneLogic : public CardZoneLogic { @@ -27,6 +28,8 @@ private: int numberCards; bool revealZone, writeableRevealZone; bool isReversed; + // Borrowed settings provider (owned by the application). Null outside the client, in which case views stay open. + IInterfaceSettingsProvider *interfaceSettings; public: enum CardAction @@ -42,6 +45,7 @@ public: bool _revealZone, bool _writeableRevealZone, bool _isReversed, + IInterfaceSettingsProvider *interfaceSettings = nullptr, QObject *parent = nullptr); bool prepareAddCard(int x); @@ -58,6 +62,8 @@ public: /** @brief Removes all cards from the view, without touching anything else. Used in replay rewind */ void clearCards(); + [[nodiscard]] bool getCloseEmptyCardView() const; + int getNumberCards() const { return numberCards; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 19f9268ee..c3b9a7547 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -487,6 +487,11 @@ add_subdirectory(movecard_tests) add_subdirectory(oracle) add_subdirectory(settings) +# libcockatrice_game only exists when the client is built. +if(WITH_CLIENT) + add_subdirectory(game) +endif() + # theme_migration_test compiles client sources, whose headers are only reachable from a client build. if(WITH_CLIENT) add_subdirectory(theme_migration) diff --git a/tests/card_zone_algorithms/CMakeLists.txt b/tests/card_zone_algorithms/CMakeLists.txt index 945f8a0ed..2418092de 100644 --- a/tests/card_zone_algorithms/CMakeLists.txt +++ b/tests/card_zone_algorithms/CMakeLists.txt @@ -1,4 +1,4 @@ cockatrice_add_test( NAME card_zone_algorithms_test SOURCES card_zone_algorithms_test.cpp INCLUDE_DIRS - ${CMAKE_SOURCE_DIR}/cockatrice/src/game/zones + ${CMAKE_SOURCE_DIR}/libcockatrice_game/libcockatrice/game/zones ) diff --git a/tests/game/CMakeLists.txt b/tests/game/CMakeLists.txt new file mode 100644 index 000000000..4a7492c71 --- /dev/null +++ b/tests/game/CMakeLists.txt @@ -0,0 +1,28 @@ +add_executable( + game_smoke_test + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/cache_settings.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/card_counter_settings.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/shortcuts_settings.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/network/update/client/release_channel.cpp + ${VERSION_STRING_CPP} + game_smoke_test.cpp +) + +# SettingsCache is still reached from the game logic, but it lives in the application sources +# rather than in libcockatrice_settings, so the client settings sources are compiled in here +# exactly like loader_local_matching_test and theme_migration_test do. +target_include_directories(game_smoke_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src) + +target_link_libraries( + game_smoke_test + PRIVATE libcockatrice_game + PRIVATE Threads::Threads + PRIVATE ${GTEST_BOTH_LIBRARIES} + PRIVATE ${TEST_QT_MODULES} +) + +add_test(NAME game_smoke_test COMMAND game_smoke_test) + +if(NOT GTEST_FOUND) + add_dependencies(game_smoke_test gtest) +endif() diff --git a/tests/game/game_smoke_test.cpp b/tests/game/game_smoke_test.cpp new file mode 100644 index 000000000..356d544bf --- /dev/null +++ b/tests/game/game_smoke_test.cpp @@ -0,0 +1,106 @@ +/** + * @file game_smoke_test.cpp + * + * Links the game logic library into a target that contains no graphics code at all, and drives + * the two seams the graphics layer used to own: the attachment graph on CardState and the + * card-creation request a zone makes so the graphics side can build the matching card item. + */ + +#include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +CardRef cardRefFor(const QString &name) +{ + CardRef cardRef; + cardRef.name = name; + return cardRef; +} + +} // namespace + +TEST(CardStateTest, AttachmentGraphKeepsBothEndsConsistent) +{ + CardState host(nullptr, nullptr, cardRefFor(QStringLiteral("Host")), 1); + CardState guest(nullptr, nullptr, cardRefFor(QStringLiteral("Guest")), 2); + + guest.setAttachedTo(&host); + host.addAttachedCard(&guest); + + EXPECT_EQ(guest.getAttachedTo(), &host); + ASSERT_EQ(host.getAttachedCards().size(), 1); + EXPECT_EQ(host.getAttachedCards().first(), &guest); + + host.removeAttachedCard(&guest); + guest.setAttachedTo(nullptr); + + EXPECT_TRUE(host.getAttachedCards().isEmpty()); + EXPECT_EQ(guest.getAttachedTo(), nullptr); +} + +TEST(CardZoneLogicTest, AddsAndTakesCardsWithoutAGraphicsSide) +{ + HandZoneLogic zone(nullptr, QStringLiteral("hand"), false, false, true); + CardState card(nullptr, nullptr, cardRefFor(QStringLiteral("Lightning Bolt")), 7); + + QSignalSpy cardAddedSpy(&zone, &CardZoneLogic::cardAdded); + QSignalSpy cardCountSpy(&zone, &CardZoneLogic::cardCountChanged); + + zone.addCard(&card, false, 0); + + ASSERT_EQ(zone.getCards().size(), 1); + EXPECT_EQ(zone.getCards().first(), &card); + EXPECT_EQ(card.getZone(), &zone); + EXPECT_EQ(cardAddedSpy.count(), 1); + EXPECT_GE(cardCountSpy.count(), 1); + + EXPECT_EQ(zone.takeCard(0, 7), &card); + EXPECT_TRUE(zone.getCards().isEmpty()); + EXPECT_EQ(card.getId(), 7); +} + +TEST(CardZoneLogicTest, AsksTheGraphicsSideToCreateCards) +{ + HandZoneLogic zone(nullptr, QStringLiteral("library"), false, true, true); + QSignalSpy requestSpy(&zone, &CardZoneLogic::createCardRequested); + + ServerInfo_Card cardInfo; + cardInfo.set_name("Lightning Bolt"); + cardInfo.set_id(7); + cardInfo.set_x(0); + cardInfo.set_y(1); + + zone.createCardRequested(cardInfo, true); + + ASSERT_EQ(requestSpy.count(), 1); + const QList arguments = requestSpy.takeFirst(); + EXPECT_EQ(arguments.at(0).value().id(), 7); + EXPECT_TRUE(arguments.at(1).toBool()); +} + +int main(int argc, char **argv) +{ + // Keep SettingsCache reads and writes away from the real user profile: the game logic still + // reaches for a handful of settings, and the client settings sources linked into this test + // would otherwise touch the developer's own configuration. + QStandardPaths::setTestModeEnabled(true); + + QTemporaryDir home; + if (home.isValid()) { + qputenv("HOME", home.path().toLocal8Bit()); + } + + QCoreApplication app(argc, argv); + QLoggingCategory::setFilterRules("settings_cache.*=false"); + + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From 52d3978f8467c65d47e4a71a7741a1eec59f9cd7 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:49:05 +0100 Subject: [PATCH 074/108] [Build] Let AUTOMOC process library headers (#7638) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 13 libraries using qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) listed their\nheaders twice and depended on a generated source list. Add the headers to the\ntarget sources directly and delete the qt6_wrap_cpp call; AUTOMOC scans them\nand generates the same moc output.\n\nThe change exposed that server_card_counter_test never defined the rng global\nthat the server remote library references; every sibling server test does.\nGive the test the same definition so the target links.\n\nVerification: full client, Oracle, server and test build passed. Co-authored-by: Lukas Brübach --- libcockatrice_card/CMakeLists.txt | 4 +--- libcockatrice_deck_list/CMakeLists.txt | 4 +--- libcockatrice_filters/CMakeLists.txt | 6 ++---- libcockatrice_interfaces/CMakeLists.txt | 4 +--- .../libcockatrice/models/database/CMakeLists.txt | 4 +--- .../libcockatrice/models/deck_list/CMakeLists.txt | 4 +--- .../libcockatrice/network/client/abstract/CMakeLists.txt | 4 +--- .../libcockatrice/network/client/local/CMakeLists.txt | 4 +--- .../libcockatrice/network/client/remote/CMakeLists.txt | 4 +--- .../libcockatrice/network/server/local/CMakeLists.txt | 4 +--- .../libcockatrice/network/server/remote/CMakeLists.txt | 4 +--- libcockatrice_rng/CMakeLists.txt | 4 +--- libcockatrice_settings/CMakeLists.txt | 4 +--- tests/server_card_counter_test.cpp | 4 ++++ 14 files changed, 18 insertions(+), 40 deletions(-) diff --git a/libcockatrice_card/CMakeLists.txt b/libcockatrice_card/CMakeLists.txt index b3583522c..e2d5029ed 100644 --- a/libcockatrice_card/CMakeLists.txt +++ b/libcockatrice_card/CMakeLists.txt @@ -21,11 +21,9 @@ set(HEADERS libcockatrice/card/relation/card_relation.h ) -qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) - add_library( libcockatrice_card STATIC - ${MOC_SOURCES} + ${HEADERS} libcockatrice/card/card_info.cpp libcockatrice/card/card_info_comparator.cpp libcockatrice/card/card_localization.cpp diff --git a/libcockatrice_deck_list/CMakeLists.txt b/libcockatrice_deck_list/CMakeLists.txt index 224c10942..ffd4c28a1 100644 --- a/libcockatrice_deck_list/CMakeLists.txt +++ b/libcockatrice_deck_list/CMakeLists.txt @@ -18,11 +18,9 @@ set(HEADERS libcockatrice/deck_list/sideboard_plan.h ) -qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) - add_library( libcockatrice_deck_list STATIC - ${MOC_SOURCES} + ${HEADERS} libcockatrice/deck_list/tree/abstract_deck_list_card_node.cpp libcockatrice/deck_list/tree/abstract_deck_list_node.cpp libcockatrice/deck_list/tree/deck_list_card_node.cpp diff --git a/libcockatrice_filters/CMakeLists.txt b/libcockatrice_filters/CMakeLists.txt index 2d45b8e5b..df2ead83f 100644 --- a/libcockatrice_filters/CMakeLists.txt +++ b/libcockatrice_filters/CMakeLists.txt @@ -6,11 +6,9 @@ set(HEADERS libcockatrice/filters/filter_card.h libcockatrice/filters/filter_str libcockatrice/filters/filter_tree.h ) -qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) - add_library( - libcockatrice_filters STATIC ${MOC_SOURCES} libcockatrice/filters/filter_card.cpp - libcockatrice/filters/filter_string.cpp libcockatrice/filters/filter_tree.cpp + libcockatrice_filters STATIC ${HEADERS} libcockatrice/filters/filter_card.cpp libcockatrice/filters/filter_string.cpp + libcockatrice/filters/filter_tree.cpp ) add_dependencies(libcockatrice_filters libcockatrice_card) diff --git a/libcockatrice_interfaces/CMakeLists.txt b/libcockatrice_interfaces/CMakeLists.txt index 3b17569b1..16ea686b5 100644 --- a/libcockatrice_interfaces/CMakeLists.txt +++ b/libcockatrice_interfaces/CMakeLists.txt @@ -24,9 +24,7 @@ set(HEADERS libcockatrice/interfaces/noop_card_set_priority_controller.h ) -qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) - -add_library(libcockatrice_interfaces STATIC ${MOC_SOURCES}) +add_library(libcockatrice_interfaces STATIC ${HEADERS}) target_include_directories(libcockatrice_interfaces PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) diff --git a/libcockatrice_models/libcockatrice/models/database/CMakeLists.txt b/libcockatrice_models/libcockatrice/models/database/CMakeLists.txt index a73e36dbb..2471adacf 100644 --- a/libcockatrice_models/libcockatrice/models/database/CMakeLists.txt +++ b/libcockatrice_models/libcockatrice/models/database/CMakeLists.txt @@ -12,11 +12,9 @@ set(HEADERS token/token_edit_model.h ) -qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) - add_library( libcockatrice_models_database STATIC - ${MOC_SOURCES} + ${HEADERS} card_database_model.cpp card_database_display_model.cpp card/card_completer_proxy_model.cpp diff --git a/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt b/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt index 324bed102..b46275437 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt +++ b/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt @@ -4,10 +4,8 @@ set(CMAKE_AUTORCC ON) set(HEADERS deck_list_model.h deck_list_sort_filter_proxy_model.h) -qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) - add_library( - libcockatrice_models_deck_list STATIC ${MOC_SOURCES} deck_list_model.cpp deck_list_model_custom_zones.cpp + libcockatrice_models_deck_list STATIC ${HEADERS} deck_list_model.cpp deck_list_model_custom_zones.cpp deck_list_sort_filter_proxy_model.cpp ) diff --git a/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt index 53ad524b4..880cef5a1 100644 --- a/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt @@ -6,9 +6,7 @@ set(HEADERS abstract_client.h latency_tracker.h) set(SOURCES abstract_client.cpp latency_tracker.cpp) -qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) - -add_library(libcockatrice_network_client_abstract STATIC ${MOC_SOURCES} ${SOURCES}) +add_library(libcockatrice_network_client_abstract STATIC ${HEADERS} ${SOURCES}) add_dependencies(libcockatrice_network_client_abstract libcockatrice_protocol libcockatrice_network_server_remote) diff --git a/libcockatrice_network/libcockatrice/network/client/local/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/client/local/CMakeLists.txt index 08c12b7f8..4d5676fc0 100644 --- a/libcockatrice_network/libcockatrice/network/client/local/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/client/local/CMakeLists.txt @@ -6,9 +6,7 @@ set(HEADERS local_client.h) set(SOURCES local_client.cpp) -qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) - -add_library(libcockatrice_network_client_local STATIC ${MOC_SOURCES} ${SOURCES}) +add_library(libcockatrice_network_client_local STATIC ${HEADERS} ${SOURCES}) add_dependencies(libcockatrice_network_client_local libcockatrice_network_client_abstract) diff --git a/libcockatrice_network/libcockatrice/network/client/remote/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/client/remote/CMakeLists.txt index c32ca0aa8..ecc822b7c 100644 --- a/libcockatrice_network/libcockatrice/network/client/remote/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/client/remote/CMakeLists.txt @@ -6,9 +6,7 @@ set(HEADERS remote_client.h) set(SOURCES remote_client.cpp) -qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) - -add_library(libcockatrice_network_client_remote STATIC ${MOC_SOURCES} ${SOURCES}) +add_library(libcockatrice_network_client_remote STATIC ${HEADERS} ${SOURCES}) add_dependencies(libcockatrice_network_client_remote libcockatrice_network_client_abstract libcockatrice_protocol) diff --git a/libcockatrice_network/libcockatrice/network/server/local/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/server/local/CMakeLists.txt index 020753d7f..c5f8ee432 100644 --- a/libcockatrice_network/libcockatrice/network/server/local/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/server/local/CMakeLists.txt @@ -6,9 +6,7 @@ set(HEADERS local_server.h local_server_interface.h) set(SOURCES local_server.cpp local_server_interface.cpp) -qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) - -add_library(libcockatrice_network_server_local STATIC ${MOC_SOURCES} ${SOURCES}) +add_library(libcockatrice_network_server_local STATIC ${HEADERS} ${SOURCES}) add_dependencies(libcockatrice_network_server_local libcockatrice_protocol) diff --git a/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt index 2127487d2..26c2be69a 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt @@ -29,11 +29,9 @@ set(HEADERS serverinfo_user_container.h ) -qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) - add_library( libcockatrice_network_server_remote STATIC - ${MOC_SOURCES} + ${HEADERS} game/dump_zone_range.cpp game/server_abstract_participant.cpp game/server_abstract_player.cpp diff --git a/libcockatrice_rng/CMakeLists.txt b/libcockatrice_rng/CMakeLists.txt index 6128e3f6e..c5b19ae85 100644 --- a/libcockatrice_rng/CMakeLists.txt +++ b/libcockatrice_rng/CMakeLists.txt @@ -4,10 +4,8 @@ set(CMAKE_AUTORCC ON) set(HEADERS libcockatrice/rng/rng_abstract.h libcockatrice/rng/rng_sfmt.h libcockatrice/rng/sfmt/SFMT.h) -qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) - add_library( - libcockatrice_rng STATIC ${MOC_SOURCES} libcockatrice/rng/rng_abstract.cpp libcockatrice/rng/rng_sfmt.cpp + libcockatrice_rng STATIC ${HEADERS} libcockatrice/rng/rng_abstract.cpp libcockatrice/rng/rng_sfmt.cpp libcockatrice/rng/sfmt/SFMT.c ) diff --git a/libcockatrice_settings/CMakeLists.txt b/libcockatrice_settings/CMakeLists.txt index 5a2fa42a9..d23fb3350 100644 --- a/libcockatrice_settings/CMakeLists.txt +++ b/libcockatrice_settings/CMakeLists.txt @@ -31,11 +31,9 @@ set(HEADERS libcockatrice/settings/visual_deck_storage_settings.h ) -qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) - add_library( libcockatrice_settings STATIC - ${MOC_SOURCES} + ${HEADERS} libcockatrice/settings/appearance_settings.cpp libcockatrice/settings/cache_storage_settings.cpp libcockatrice/settings/card_database_settings.cpp diff --git a/tests/server_card_counter_test.cpp b/tests/server_card_counter_test.cpp index 6308d80b9..6b884864d 100644 --- a/tests/server_card_counter_test.cpp +++ b/tests/server_card_counter_test.cpp @@ -8,10 +8,14 @@ #include #include #include +#include #include #include #include +// Referenced by other translation units in the server_remote library. +RNG_Abstract *rng = nullptr; + TEST(ServerCardCounter, IncrementNewCounter) { Server_Card card(CardRef{"TestCard", ""}, 1, 0, 0); From f2c359ba9fa3ed33934fceef40c095701d5c22cd Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:49:45 +0100 Subject: [PATCH 075/108] [CI] Keep the vcpkg cache directory guard unconditional (#7527) The cache action creates its directory only when a prefix matches an entry, and it reads that directory unguarded when it saves, so a scope nobody has written to yet fails the job. Create it up front on every outcome, and fall back to the workspace path when the cache step was skipped and set no path output. Co-authored-by: opencode --- .github/workflows/desktop-build.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index c97b48342..54068f5a0 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -584,11 +584,17 @@ jobs: # job. A scope nobody has written to yet therefore leaves the directory missing # and fails the job, which is exactly the state every scope starts in. Make the # directory up front so an empty scope is a cache miss instead of an error. + # always() covers the cache step itself failing: the action's post save still runs + # then, and it should report the real error instead of ENOENT. It cannot rescue a + # failure before the cache step, since a post step only runs for an action whose + # main step did. The fallback keeps this guard from failing when the cache step was + # skipped and left no path output. - name: "Create vcpkg cache directory" + if: always() shell: bash env: CACHE_DIR: '${{ steps.vcpkg-cache.outputs.path }}' - run: mkdir -p "$CACHE_DIR" + run: mkdir -p "${CACHE_DIR:-$GITHUB_WORKSPACE/.vcpkg-cache}" # Uses environment variables, see compile.sh for more details - name: "Build Cockatrice" From d4002dc52b4280b8ad32a94be2a2e1a148195855 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:52:11 +0100 Subject: [PATCH 076/108] [Build] Reject APIs deprecated before Qt 6.4 (#7632) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the deprecation cutoff after FindQtRuntime so the compiler rejects any API deprecated before the project's minimum Qt version. Qt 6.5 renamed the switch, so the fallback name is used on 6.4. The full client, Oracle, server and test build passes with the cutoff enabled. Co-authored-by: Lukas Brübach --- CMakeLists.txt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index 159c4a24f..4cdc19911 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -250,6 +250,15 @@ message(STATUS "Update Translations: ${UPDATE_TRANSLATIONS}") include(FindQtRuntime) +# Reject APIs that were deprecated before the oldest Qt release we support +# (6.4). Qt 6.5 renamed the switch, so use whichever spelling the found +# version understands while still compiling everywhere. +if(Qt6_VERSION VERSION_GREATER_EQUAL 6.5) + add_compile_definitions(QT_DISABLE_DEPRECATED_UP_TO=0x060400) +else() + add_compile_definitions(QT_DISABLE_DEPRECATED_BEFORE=0x060400) +endif() + set(CMAKE_AUTOMOC TRUE) # Find other needed libraries From ae43659b914925bd8a747d2fbc52990d0a5e27a4 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:53:00 +0100 Subject: [PATCH 077/108] [Build] Use versionless Qt CMake commands (#7639) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Build] Use versionless Qt CMake commands Replace the versioned qt6_add_executable, qt6_add_resources, qt6_add_shaders,\nqt6_add_translations and qt6_wrap_cpp calls with their unversioned forms.\nAll five are documented as available at the project's 6.4 minimum and accept\nthe same arguments.\n\nVerification: full client, Oracle, server and test build passed. * versionless qt_finalize_target and _project --------- Co-authored-by: Lukas Brübach Co-authored-by: tooomm --- CMakeLists.txt | 2 +- cockatrice/CMakeLists.txt | 14 +++++++------- oracle/CMakeLists.txt | 10 +++++----- servatrice/CMakeLists.txt | 2 +- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 4cdc19911..d9acabc5e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -462,5 +462,5 @@ endif() if(Qt6_FOUND AND Qt6_VERSION_MINOR GREATER_EQUAL 3) # Qt6.3+ requires project finalization to support translations - qt6_finalize_project() + qt_finalize_project() endif() diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index a4dc96937..ce7ad5712 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -490,7 +490,7 @@ if(APPLE) set(cockatrice_SOURCES ${cockatrice_SOURCES} ${CMAKE_CURRENT_SOURCE_DIR}/resources/appicon.icns) endif(APPLE) -qt6_add_resources(cockatrice_RESOURCES_RCC ${cockatrice_RESOURCES}) +qt_add_resources(cockatrice_RESOURCES_RCC ${cockatrice_RESOURCES}) # Declare path variables set(ICONDIR @@ -511,7 +511,7 @@ set(COCKATRICE_MAC_QM_INSTALL_DIR "cockatrice.app/Contents/Resources/translation set(COCKATRICE_UNIX_QM_INSTALL_DIR "share/cockatrice/translations") set(COCKATRICE_WIN32_QM_INSTALL_DIR "translations") -qt6_add_executable( +qt_add_executable( cockatrice WIN32 MACOSX_BUNDLE @@ -523,7 +523,7 @@ qt6_add_executable( target_precompile_headers(cockatrice PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtwidgets_pch.h") -qt6_add_shaders( +qt_add_shaders( cockatrice "onboarding_shaders" PREFIX @@ -535,7 +535,7 @@ qt6_add_shaders( src/interface/widgets/onboarding/shaders/brand_plate.frag ) -qt6_add_resources( +qt_add_resources( cockatrice "onboarding_qml" PREFIX @@ -768,7 +768,7 @@ endif() if(Qt6LinguistTools_FOUND) #Qt6 Translations happen after the executable is built up if(UPDATE_TRANSLATIONS) - qt6_add_translations( + qt_add_translations( cockatrice TS_FILES ${cockatrice_TS} @@ -778,7 +778,7 @@ if(Qt6LinguistTools_FOUND) cockatrice_QM ) else() - qt6_add_translations(cockatrice TS_FILES ${cockatrice_TS} QM_FILES_OUTPUT_VARIABLE cockatrice_QM) + qt_add_translations(cockatrice TS_FILES ${cockatrice_TS} QM_FILES_OUTPUT_VARIABLE cockatrice_QM) endif() if(UNIX) @@ -804,4 +804,4 @@ if(Qt6LinguistTools_FOUND) endforeach() endif() -qt6_finalize_target(cockatrice) +qt_finalize_target(cockatrice) diff --git a/oracle/CMakeLists.txt b/oracle/CMakeLists.txt index 0df345812..587463572 100644 --- a/oracle/CMakeLists.txt +++ b/oracle/CMakeLists.txt @@ -63,7 +63,7 @@ set(oracle_RESOURCES oracle.qrc) # ------------------------ # Qt resources # ------------------------ -qt6_add_resources(oracle_RESOURCES_RCC ${oracle_RESOURCES}) +qt_add_resources(oracle_RESOURCES_RCC ${oracle_RESOURCES}) # ------------------------ # Include directories @@ -108,7 +108,7 @@ set(ORACLE_UNIX_QM_INSTALL_DIR "share/oracle/translations") set(ORACLE_WIN32_QM_INSTALL_DIR "translations") # Qt6 Translations are linked after the executable is created in manual mode -qt6_add_executable( +qt_add_executable( oracle WIN32 MACOSX_BUNDLE @@ -283,7 +283,7 @@ endif() if(Qt6LinguistTools_FOUND) #Qt6 Translations happen after the executable is built up if(UPDATE_TRANSLATIONS) - qt6_add_translations( + qt_add_translations( oracle TS_FILES ${oracle_TS} @@ -293,7 +293,7 @@ if(Qt6LinguistTools_FOUND) oracle_QM ) else() - qt6_add_translations(oracle TS_FILES ${oracle_TS} QM_FILES_OUTPUT_VARIABLE oracle_QM) + qt_add_translations(oracle TS_FILES ${oracle_TS} QM_FILES_OUTPUT_VARIABLE oracle_QM) endif() if(UNIX) @@ -319,4 +319,4 @@ if(Qt6LinguistTools_FOUND) endforeach() endif() -qt6_finalize_target(oracle) +qt_finalize_target(oracle) diff --git a/servatrice/CMakeLists.txt b/servatrice/CMakeLists.txt index 255469934..9d8dd46d2 100644 --- a/servatrice/CMakeLists.txt +++ b/servatrice/CMakeLists.txt @@ -56,7 +56,7 @@ if(APPLE) set(servatrice_SOURCES ${servatrice_SOURCES} ${CMAKE_CURRENT_SOURCE_DIR}/resources/appicon.icns) endif(APPLE) -qt6_add_resources(servatrice_RESOURCES_RCC ${servatrice_RESOURCES}) +qt_add_resources(servatrice_RESOURCES_RCC ${servatrice_RESOURCES}) set(QT_DONT_USE_QTGUI TRUE) From 825aba953a52c4a325a971e8aa52e62e6f17efd0 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 19:02:49 +0100 Subject: [PATCH 078/108] [Servatrice] Fail loudly when startup initialization fails (#7602) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Servatrice] Exit non-zero when server initialization fails initServer() reported every startup failure through a zero exit code: the database could not be opened, the server printed nothing of its own, and the process looked like a clean shutdown. Log the two refusal reasons at critical level and return 1 so supervisors and the Docker exit status can tell the difference. * [Servatrice] Keep console logging independent of the logfile logMessage() returned before the console echo unless a logfile was open and server/writelog was true, so --log-to-console produced nothing when server/logfile was empty or could not be opened. That is how a refused database connection could end in a silent exit. Print to the console before the logfile gates, drop the duplicate echo from flushBuffer(), and read writelog/logfilters once in main instead of calling the non-thread-safe global QSettings from every logging thread. * [Servatrice] Uninstall the log handler before tearing down globals The handler reaches into the global logger and settingsCache. Leaving it installed while main deletes those objects turns any Qt warning emitted during QCoreApplication/QFactoryLoader teardown into a use-after-free; with QT_DEBUG_PLUGINS=1 that warning exists, and Servatrice died with SIGSEGV in QSettings::value() on the way out. * [Servatrice] Buffer console logs on the logger thread Address review feedback on the startup failure change: - Append console lines to a buffer drained by the logger thread, so a stalled stdout reader cannot block game threads. - Report islServer->errorString() instead of the literal call text. - Let the constructor reuse reloadConfig() instead of parsing the filter list twice. --------- Co-authored-by: Lukas Brübach --- servatrice/src/main.cpp | 14 ++- servatrice/src/servatrice.cpp | 14 +-- servatrice/src/server_logger.cpp | 140 +++++++++++++++--------- servatrice/src/server_logger.h | 13 ++- servatrice/src/signalhandler.cpp | 2 + tests/CMakeLists.txt | 11 ++ tests/server_logger_test.cpp | 179 +++++++++++++++++++++++++++++++ 7 files changed, 311 insertions(+), 62 deletions(-) create mode 100644 tests/server_logger_test.cpp diff --git a/servatrice/src/main.cpp b/servatrice/src/main.cpp index eb019a2b7..4d513d026 100644 --- a/servatrice/src/main.cpp +++ b/servatrice/src/main.cpp @@ -41,6 +41,7 @@ #include #include #include +#include #include #include #include @@ -162,14 +163,15 @@ int main(int argc, char *argv[]) configPath = SettingsCache::guessConfigurationPath(); } else if (!QFile::exists(configPath)) { qCritical() << "Could not find configuration file at" << configPath; - return 1; + return EXIT_FAILURE; } qWarning() << "Using configuration file: " << configPath; settingsCache = new SettingsCache(configPath); loggerThread = new QThread; loggerThread->setObjectName("logger"); - logger = new ServerLogger(logToConsole); + logger = new ServerLogger(logToConsole, settingsCache->value("server/writelog", 1).toBool(), + settingsCache->value("server/logfilters").toString()); logger->moveToThread(loggerThread); loggerThread->start(); @@ -214,8 +216,16 @@ int main(int argc, char *argv[]) std::cerr << "Server quit." << std::endl; std::cerr << "-------------------------" << std::endl; + } else { + // initServer() logged the reason; a failed startup must not look like a clean exit. + std::cerr << "Server initialization failed, exiting." << std::endl; + retval = EXIT_FAILURE; } + // The handler reads the logger and settingsCache globals torn down below; leaving it + // installed lets Qt's own shutdown warnings dereference freed memory. + qInstallMessageHandler(nullptr); + delete smtpClient; delete rng; delete signalhandler; diff --git a/servatrice/src/servatrice.cpp b/servatrice/src/servatrice.cpp index b6eaf11f4..49d8e1e4c 100644 --- a/servatrice/src/servatrice.cpp +++ b/servatrice/src/servatrice.cpp @@ -308,7 +308,8 @@ bool Servatrice::initServer() authenticationMethod = AuthenticationPassword; } else { if (getRegOnlyServerEnabled()) { - qDebug() << "Registration only server enabled but no authentication method defined: Error."; + qCCritical(ServatriceLog) << "Registration only server enabled but no authentication method defined; " + "refusing to start."; return false; } qDebug() << "Authenticating method: none"; @@ -379,7 +380,7 @@ bool Servatrice::initServer() bool dbOpened = servatriceDatabaseInterface->initDatabase( "QMYSQL", getDBHostNameString(), getDBDatabaseNameString(), getDBUserNameString(), getDBPasswordString()); if (!dbOpened) { - qDebug() << "Failed to open database"; + qCCritical(ServatriceLog) << "Failed to open the database; refusing to start."; return false; } updateServerList(); @@ -600,11 +601,11 @@ bool Servatrice::initServer() if (islServer->listen(QHostAddress::Any, static_cast(getISLNetworkPort()))) { qDebug() << "ISL server listening."; } else { - throw QString("islServer->listen()"); + throw islServer->errorString(); } } } catch (QString &error) { - qDebug() << "ERROR --" << error; + qCCritical(ServatriceLog) << "Failed to start the ISL server:" << error; return false; } @@ -641,7 +642,7 @@ bool Servatrice::initServer() if (gameServer->listen(tcpHost, static_cast(getServerTCPPort()))) { qDebug() << "Server listening."; } else { - qDebug() << "gameServer->listen(): Error:" << gameServer->errorString(); + qCCritical(ServatriceLog) << "Failed to listen for TCP clients:" << gameServer->errorString(); return false; } } @@ -657,7 +658,8 @@ bool Servatrice::initServer() if (websocketGameServer->listen(webSocketHost, static_cast(getServerWebSocketPort()))) { qDebug() << "Websocket server listening."; } else { - qDebug() << "websocketGameServer->listen(): Error:" << websocketGameServer->errorString(); + qCCritical(ServatriceLog) << "Failed to listen for websocket clients:" + << websocketGameServer->errorString(); return false; } } diff --git a/servatrice/src/server_logger.cpp b/servatrice/src/server_logger.cpp index 1c83299b8..dfe51183a 100644 --- a/servatrice/src/server_logger.cpp +++ b/servatrice/src/server_logger.cpp @@ -1,7 +1,5 @@ #include "server_logger.h" -#include "settingscache.h" - #include #include #include @@ -10,33 +8,37 @@ #include #include #include -#include #include #include #include #include #include -ServerLogger::ServerLogger(bool _logToConsole, QObject *parent) +ServerLogger::ServerLogger(bool _logToConsole, bool _writeLog, const QString &_logFilters, QObject *parent) : QObject(parent), logToConsole(_logToConsole), flushRunning(false) { + reloadConfig(_writeLog, _logFilters); + connect(this, SIGNAL(sigFlushBuffer()), this, SLOT(flushBuffer()), Qt::QueuedConnection); } ServerLogger::~ServerLogger() { flushBuffer(); + // logFile is a child of this object and is closed and deleted by ~QObject. // This does not work with the destroyed() signal as this destructor is called after the main event loop is done. thread()->quit(); } void ServerLogger::startLog(const QString &logFileName) { + delete logFile; + logFile = nullptr; + if (!logFileName.isEmpty()) { QFileInfo fi(logFileName); QDir fileDir(fi.path()); if (!fileDir.exists() && !fileDir.mkpath(fileDir.absolutePath())) { std::cerr << "ERROR: logfile folder doesn't exist and i can't create it." << std::endl; - logFile = 0; return; } @@ -44,14 +46,22 @@ void ServerLogger::startLog(const QString &logFileName) if (!logFile->open(QIODevice::Append)) { std::cerr << "ERROR: can't open() logfile." << std::endl; delete logFile; - logFile = 0; + logFile = nullptr; return; } - } else { - logFile = 0; } +} - connect(this, SIGNAL(sigFlushBuffer()), this, SLOT(flushBuffer()), Qt::QueuedConnection); +void ServerLogger::reloadConfig(bool _writeLog, const QString &_logFilters) +{ + const bool filterActive = !_logFilters.trimmed().isEmpty(); + const QStringList filters = _logFilters.split(QStringLiteral(","), Qt::SkipEmptyParts); + + configMutex.lock(); + writeLog = _writeLog; + messageFilterActive = filterActive; + logFilters = filters; + configMutex.unlock(); } bool ServerLogger::isLoggingEnabled() const @@ -61,43 +71,54 @@ bool ServerLogger::isLoggingEnabled() const void ServerLogger::logMessage(const QString &message, void *caller) { - if (!logFile) { - return; - } - QString callerString; if (caller) { - callerString = QString::number((qulonglong)caller, 16) + " "; + callerString = QString::number(reinterpret_cast(caller), 16) + " "; } - // filter out all log entries based on values in configuration file - bool shouldWeWriteLog = settingsCache->value("server/writelog", 1).toBool(); - QString logFilters = settingsCache->value("server/logfilters").toString(); - QStringList listlogFilters = logFilters.split(",", Qt::SkipEmptyParts); - bool shouldWeSkipLine = false; + const QString line = QDateTime::currentDateTime().toString() + " " + callerString + message; + bool queued = false; - if (!shouldWeWriteLog) { - return; + // The console is an independent sink: writelog and logfilters only describe the logfile + // (see servatrice.ini.example), so a disabled or unopenable logfile must not swallow + // startup errors such as a refused database connection. Lines are buffered for the logger + // thread instead of written here, so a stalled stdout reader cannot stall game threads. + if (logToConsole) { + bufferMutex.lock(); + consoleBuffer.append(line); + bufferMutex.unlock(); + queued = true; } - if (!logFilters.trimmed().isEmpty()) { - shouldWeSkipLine = true; - for (const QString &logFilter : listlogFilters) { - if (message.contains(logFilter, Qt::CaseInsensitive)) { - shouldWeSkipLine = false; - break; + configMutex.lock(); + const bool shouldWriteFile = writeLog; + const bool filterMessages = messageFilterActive; + const QStringList filters = logFilters; + configMutex.unlock(); + + if (logFile && shouldWriteFile) { + // filter out all log entries based on values in configuration file + bool shouldWriteLine = !filterMessages; + if (filterMessages) { + for (const QString &logFilter : filters) { + if (message.contains(logFilter, Qt::CaseInsensitive)) { + shouldWriteLine = true; + break; + } } } + + if (shouldWriteLine) { + bufferMutex.lock(); + buffer.append(line); + bufferMutex.unlock(); + queued = true; + } } - if (shouldWeSkipLine) { - return; + if (queued) { + emit sigFlushBuffer(); } - - bufferMutex.lock(); - buffer.append(QDateTime::currentDateTime().toString() + " " + callerString + message); - bufferMutex.unlock(); - emit sigFlushBuffer(); } void ServerLogger::flushBuffer() @@ -107,25 +128,42 @@ void ServerLogger::flushBuffer() } flushRunning = true; - QTextStream stream(logFile); - forever - { - bufferMutex.lock(); - if (buffer.isEmpty()) { - bufferMutex.unlock(); - flushRunning = false; - return; - } - QString message = buffer.takeFirst(); - bufferMutex.unlock(); - stream << message << "\n"; - stream.flush(); + bufferMutex.lock(); + QStringList consoleLines; + consoleLines.swap(consoleBuffer); + bufferMutex.unlock(); - if (logToConsole) { - std::cout << message.toStdString() << std::endl; - } + for (const QString &line : consoleLines) { + std::cout << line.toStdString() << '\n'; } + if (!consoleLines.isEmpty()) { + std::cout.flush(); + } + + if (logFile) { + QTextStream stream(logFile); + forever + { + bufferMutex.lock(); + if (buffer.isEmpty()) { + bufferMutex.unlock(); + break; + } + QString message = buffer.takeFirst(); + bufferMutex.unlock(); + + stream << message << "\n"; + stream.flush(); + } + } else { + // No logfile to write to; do not let the buffer grow without bound. + bufferMutex.lock(); + buffer.clear(); + bufferMutex.unlock(); + } + + flushRunning = false; } void ServerLogger::rotateLogs() @@ -141,5 +179,3 @@ void ServerLogger::rotateLogs() std::cerr << "ERROR: Failed to open log file for writing!" << std::endl; } } - -QFile *ServerLogger::logFile; diff --git a/servatrice/src/server_logger.h b/servatrice/src/server_logger.h index e0be4b712..531430d56 100644 --- a/servatrice/src/server_logger.h +++ b/servatrice/src/server_logger.h @@ -14,7 +14,7 @@ class ServerLogger : public QObject { Q_OBJECT public: - ServerLogger(bool _logToConsole, QObject *parent = 0); + ServerLogger(bool _logToConsole, bool _writeLog, const QString &_logFilters, QObject *parent = nullptr); ~ServerLogger(); /** @@ -27,6 +27,7 @@ public: public slots: void startLog(const QString &logFileName); + void reloadConfig(bool _writeLog, const QString &_logFilters); void logMessage(const QString &message, void *caller = 0); void rotateLogs(); private slots: @@ -36,9 +37,17 @@ signals: private: bool logToConsole; - static QFile *logFile; + bool writeLog = true; + bool messageFilterActive = false; + QStringList logFilters; + // Guards writeLog/messageFilterActive/logFilters: SIGHUP rewrites them while other threads log. + QMutex configMutex; + QFile *logFile = nullptr; bool flushRunning; QStringList buffer; + // Console lines wait here for the logger thread, so game threads never block on stdout. + QStringList consoleBuffer; + // Guards buffer and consoleBuffer. QMutex bufferMutex; }; diff --git a/servatrice/src/signalhandler.cpp b/servatrice/src/signalhandler.cpp index 32cd5b206..b8a7e3538 100644 --- a/servatrice/src/signalhandler.cpp +++ b/servatrice/src/signalhandler.cpp @@ -73,6 +73,8 @@ void SignalHandler::internalSigHupHandler() logger->rotateLogs(); settingsCache->sync(); + logger->reloadConfig(settingsCache->value("server/writelog", 1).toBool(), + settingsCache->value("server/logfilters").toString()); snHup->setEnabled(true); } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index c3b9a7547..c8d3fbd59 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -251,6 +251,17 @@ cockatrice_add_test( LABELS server ) +cockatrice_add_test( + NAME + server_logger_test + SOURCES + ${CMAKE_SOURCE_DIR}/servatrice/src/server_logger.cpp + server_logger_test.cpp + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/servatrice/src + LABELS + server +) cockatrice_add_test( NAME server_card_counter_test diff --git a/tests/server_logger_test.cpp b/tests/server_logger_test.cpp new file mode 100644 index 000000000..94a6909bf --- /dev/null +++ b/tests/server_logger_test.cpp @@ -0,0 +1,179 @@ +#include "server_logger.h" + +#include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include + +namespace +{ + +/** + * @brief Redirects std::cout for the lifetime of the object so the logger's console + * sink can be asserted on. + */ +class CoutCapture +{ +public: + CoutCapture() : previousBuffer(std::cout.rdbuf(captured.rdbuf())) + { + } + ~CoutCapture() + { + std::cout.rdbuf(previousBuffer); + } + + QString text() const + { + return QString::fromStdString(captured.str()); + } + +private: + std::ostringstream captured; + std::streambuf *previousBuffer; +}; + +// The console sink is written by the logger thread; drive it directly here, as the event +// loop would in the server. +void flushLogger(ServerLogger &logger) +{ + QMetaObject::invokeMethod(&logger, "flushBuffer", Qt::DirectConnection); +} + +// Regression: --log-to-console used to be gated behind a successfully opened logfile, so an +// empty server/logfile silenced every diagnostic, including critical startup errors. +TEST(ServerLoggerTest, ConsoleSinkDoesNotDependOnLogFile) +{ + ServerLogger logger(true, true, QString()); + logger.startLog(QString()); + + CoutCapture capture; + logger.logMessage("could not open the database"); + flushLogger(logger); + + EXPECT_TRUE(capture.text().contains("could not open the database")) << capture.text().toStdString(); +} + +// writelog only describes the logfile; disabling it must not silence the console. +TEST(ServerLoggerTest, ConsoleSinkSurvivesDisabledFileLog) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + + ServerLogger logger(true, false, QString()); + logger.startLog(dir.filePath("server.log")); + + CoutCapture capture; + logger.logMessage("startup failed"); + flushLogger(logger); + + EXPECT_TRUE(capture.text().contains("startup failed")) << capture.text().toStdString(); +} + +// logfilters selects what goes into the logfile; the console is not filtered. +TEST(ServerLoggerTest, ConsoleSinkIgnoresLogFilters) +{ + ServerLogger logger(true, true, "database"); + logger.startLog(QString()); + + CoutCapture capture; + logger.logMessage("unrelated message"); + flushLogger(logger); + + EXPECT_TRUE(capture.text().contains("unrelated message")) << capture.text().toStdString(); +} + +TEST(ServerLoggerTest, StaysQuietWhenConsoleIsDisabled) +{ + ServerLogger logger(false, false, QString()); + logger.startLog(QString()); + + CoutCapture capture; + logger.logMessage("not for the console"); + flushLogger(logger); + + EXPECT_TRUE(capture.text().isEmpty()) << capture.text().toStdString(); +} + +TEST(ServerLoggerTest, WritesMatchingFilteredMessagesToTheLogFile) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString logPath = dir.filePath("server.log"); + + { + ServerLogger logger(false, true, "database"); + logger.startLog(logPath); + logger.logMessage("database is unavailable"); + logger.logMessage("unrelated message"); + } + + QFile logFile(logPath); + ASSERT_TRUE(logFile.open(QIODevice::ReadOnly)); + const QString contents = QString::fromUtf8(logFile.readAll()); + EXPECT_TRUE(contents.contains("database is unavailable")) << contents.toStdString(); + EXPECT_FALSE(contents.contains("unrelated message")) << contents.toStdString(); +} + +// SIGHUP reloads the configuration; the logger must pick up the new filter. +TEST(ServerLoggerTest, ReloadConfigAppliesANewFilter) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString logPath = dir.filePath("server.log"); + + { + ServerLogger logger(false, true, "database"); + logger.startLog(logPath); + logger.logMessage("filtered out"); + logger.reloadConfig(true, QString()); + logger.logMessage("kept after reload"); + } + + QFile logFile(logPath); + ASSERT_TRUE(logFile.open(QIODevice::ReadOnly)); + const QString contents = QString::fromUtf8(logFile.readAll()); + EXPECT_FALSE(contents.contains("filtered out")) << contents.toStdString(); + EXPECT_TRUE(contents.contains("kept after reload")) << contents.toStdString(); +} + +TEST(ServerLoggerTest, DisabledFileLogLeavesTheLogFileEmpty) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString logPath = dir.filePath("server.log"); + + { + ServerLogger logger(false, false, QString()); + logger.startLog(logPath); + logger.logMessage("not for the logfile"); + } + + QFile logFile(logPath); + ASSERT_TRUE(logFile.open(QIODevice::ReadOnly)); + EXPECT_TRUE(logFile.readAll().isEmpty()); +} + +// A filter list consisting only of separators activates filtering with no entries; the old +// trimmed-empty check suppressed the logfile in that case and this preserves it. +TEST(ServerLoggerTest, DegenerateFilterSuppressesTheLogFile) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString logPath = dir.filePath("server.log"); + + { + ServerLogger logger(false, true, ","); + logger.startLog(logPath); + logger.logMessage("anything"); + } + + QFile logFile(logPath); + ASSERT_TRUE(logFile.open(QIODevice::ReadOnly)); + EXPECT_TRUE(logFile.readAll().isEmpty()); +} + +} // namespace From 344dbd2caaf8eba61c8cbb7cd8534a68cc9f26d1 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 19:02:55 +0100 Subject: [PATCH 079/108] [Servatrice] Fix silently broken socket error connections (#7626) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Servatrice] Fix silently broken socket error connections QAbstractSocket::error() was removed in Qt 6 in favour of errorOccurred(). The ISL interface connected to the old name through SIGNAL(), which compiles but fails at runtime, so catchSocketError never ran. The WebSocket interface used the same name on QWebSocket, where it still exists but is deprecated since 6.5. Use errorOccurred for both. The WebSocket connect needs a lambda because catchSocketError is a protected base-class slot and a base pointer-to-member is not accessible from the derived class. * [Servatrice] Guard the WebSocket error connect against Qt 6.4 QWebSocket gained errorOccurred in 6.5 and dropped the error signal with it, so the typed connect cannot compile against the 6.4 floor that Debian 12 and Ubuntu 24.04 still use. Keep the typed connect on 6.5+ and fall back to the old-style connect below it, which is what the code did before. Verified both branches compile: forcing the guard to the else path and rebuilding serversocketinterface.cpp succeeds, so the 6.4 spelling is intact. * [Servatrice] Initialize ISL serverId before authentication catchSocketError() removes islInterfaces[serverId], but the incoming connection constructor left serverId uninitialized until initServer() set it after the SSL handshake and certificate check. A socket error during waitForEncrypted() therefore read an indeterminate key. * [Servatrice] Correct the QWebSocket error signal note error() was deprecated in 6.5, not removed; errorOccurred() was added in 6.5. --------- Co-authored-by: Lukas Brübach --- servatrice/src/isl_interface.cpp | 5 ++--- servatrice/src/serversocketinterface.cpp | 7 +++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/servatrice/src/isl_interface.cpp b/servatrice/src/isl_interface.cpp index 7121bfa48..8635808ac 100644 --- a/servatrice/src/isl_interface.cpp +++ b/servatrice/src/isl_interface.cpp @@ -59,8 +59,7 @@ void IslInterface::sharedCtor(const QSslCertificate &cert, const QSslKey &privat socket->setPrivateKey(privateKey); connect(socket, SIGNAL(readyRead()), this, SLOT(readClient()), Qt::QueuedConnection); - connect(socket, SIGNAL(error(QAbstractSocket::SocketError)), this, - SLOT(catchSocketError(QAbstractSocket::SocketError))); + connect(socket, &QAbstractSocket::errorOccurred, this, &IslInterface::catchSocketError); connect(this, SIGNAL(outputBufferChanged()), this, SLOT(flushOutputBuffer()), Qt::QueuedConnection); } @@ -68,7 +67,7 @@ IslInterface::IslInterface(int _socketDescriptor, const QSslCertificate &cert, const QSslKey &privateKey, Servatrice *_server) - : QObject(), socketDescriptor(_socketDescriptor), server(_server), messageInProgress(false) + : QObject(), serverId(-1), socketDescriptor(_socketDescriptor), server(_server), messageInProgress(false) { sharedCtor(cert, privateKey); } diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index 2cb4df8ba..78593f6dc 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -4651,8 +4651,15 @@ void WebsocketServerSocketInterface::initConnection(void *_socket) connect(socket, SIGNAL(binaryMessageReceived(const QByteArray &)), this, SLOT(binaryMessageReceived(const QByteArray &))); + // QWebSocket::errorOccurred was added in Qt 6.5 (error() is deprecated from 6.5 on), + // so a typed connect cannot compile against the 6.4 floor. +#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0) + connect(socket, &QWebSocket::errorOccurred, this, + [this](QAbstractSocket::SocketError error) { catchSocketError(error); }); +#else connect(socket, SIGNAL(error(QAbstractSocket::SocketError)), this, SLOT(catchSocketError(QAbstractSocket::SocketError))); +#endif connect(socket, SIGNAL(disconnected()), this, SLOT(catchSocketDisconnected())); // Add this object to the server's list of connections before it can receive socket events. From 00151380e3bedb475e532a69d0b4490a5b33f438 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 31 Oct 2026 19:37:15 +0100 Subject: [PATCH 080/108] [CardDB] Break the CardInfo <-> CardSet cycle on reload (#7645) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [CardDB] Drop the CardInfo self pointer The weak self pointer had to be assigned right after every construction, and a missed assignment silently stopped change notifications. CardInfo is always owned by a QSharedPointer, so it now derives from QEnableSharedFromThis and asks for sharedFromThis() instead: nothing to initialize, and the CardSet cycle stays broken. CardSet::newInstance() also loses the commented-out self-pointer assignment left over from the same design. * [CardDB] Pin the cardInfoChanged contract without the self pointer Dropping CardInfo's self pointer moved change notifications onto QEnableSharedFromThis, so the signal now rests on the card being owned by a QSharedPointer. Two tests pin that down: - A property change on a card owned by a CardDatabase still emits cardInfoChanged() carrying that card, which is what the card database views and models listen to. - clone() starts out unowned, so a change to the clone is reported as the clone's and must not reach the original's observers. * [CardDB] Treat an unowned CardInfo as a bug, not a no-op Every CardInfo is built inside a QSharedPointer (both factory overloads and clone()), and no constructor notifies anyone, so sharedFromThis() is always available by the time addToSet() or a setter runs. Assert that and warn in a released build, instead of silently skipping the notification or the set append. The copy constructor keeps its explicit QEnableSharedFromThis initializer: GCC's -Wextra warns about the uninitialized base in every translation unit that copies a CardInfo otherwise. A comment there records that the clone deliberately starts unowned. * [CardDB] Break the CardInfo <-> CardSet cycle on reload CardSet is a QList and CardInfo::addToSet() appends the card to its sets, while a card keeps a CardSetPtr per printing in setsToPrintings. The sets therefore hold strong references back to the cards, and the previous database snapshot stayed alive after a reload, so RSS grew by roughly a second database on every reload. Empty the sets before the cards are dropped: CardDatabase::clear() and swapInDatabaseData() clear the sets before replacing the containers, and removeCard() detaches the card from its sets, so nothing keeps a card alive on behalf of a set once the database lets go of it. Two tests pin the behaviour: a reload and a clear() must drop the last strong reference to a card from the previous snapshot. * [CardDB] Keep the set detach in one place clear() and swapInDatabaseData() both emptied every set before dropping the cards, so share that as detachSetMembership() instead of two copies that have to stay in sync. removeCard() now detaches through the first printing of each set directly, the same way CardInfo::newInstance() appends, instead of an inner loop that always breaks after the first item. Because clear() empties the sets up front, its per-card detach was walking every card's printings to remove it from sets that were already empty. Split removeCard() into the per-card detach and unindexCard(), and let clear() call only the latter. * [CardDB] Test that removeCard detaches the card from its set removeCard() is the one teardown path a caller can trigger for a single card (the token editor uses it), and it now detaches the card from its sets as well. Without that detach the set's strong CardInfoPtr keeps the removed card alive forever, which is exactly what the reload and clear tests pin for the whole-snapshot paths. The test loads the fixture, removes Cat, and requires the card to be gone from the CAT set and to be destroyed once the test drops its own reference. * [Servatrice] Drop the settings cache from the logger #7602 removed #include "settingscache.h" from server_logger.cpp but left isLoggingEnabled() reading settingsCache, so master no longer compiles: the servatrice target fails on the undeclared global and server_logger_test, which compiles the same file without main.cpp, fails to link it. The same commit made reloadConfig() the single place that stores writelog and the log filters, precisely so logging threads stop reading the global QSettings. isLoggingEnabled() now mirrors logMessage() and reads those members instead: the console is a sink of its own, and the logfile needs an open file plus writelog. The mutex is mutable so the const query can take it. --------- Co-authored-by: Lukas Brübach --- .../libcockatrice/card/card_info.cpp | 30 ++++- .../libcockatrice/card/card_info.h | 51 ++++----- .../card/database/card_database.cpp | 36 +++++- .../card/database/card_database.h | 17 +++ .../libcockatrice/card/set/card_set.cpp | 1 - servatrice/src/server_logger.cpp | 5 +- servatrice/src/server_logger.h | 2 +- tests/carddatabase/carddatabase_test.cpp | 106 ++++++++++++++++++ tests/server_logger_test.cpp | 22 ++++ 9 files changed, 232 insertions(+), 38 deletions(-) diff --git a/libcockatrice_card/libcockatrice/card/card_info.cpp b/libcockatrice_card/libcockatrice/card/card_info.cpp index 388c9ab57..90bfd3f44 100644 --- a/libcockatrice_card/libcockatrice/card/card_info.cpp +++ b/libcockatrice_card/libcockatrice/card/card_info.cpp @@ -6,6 +6,7 @@ #include "relation/card_relation.h" #include "set/card_set.h" +#include #include #include #include @@ -19,6 +20,18 @@ const QHash &CardInfo::getPropertiesHash() const return properties.getProperties(); } +void CardInfo::notifyChanged() +{ + const CardInfoPtr self = sharedFromThis(); + Q_ASSERT_X(self, "CardInfo::notifyChanged", "every CardInfo is owned by a QSharedPointer"); + if (!self) { + qCWarning(CardInfoLog) << "notifyChanged() on an unowned CardInfo" << name << "- observers stay stale"; + return; + } + + emit cardInfoChanged(self); +} + void CardInfo::setProperty(const QString &_name, const QString &_value) { bool changed = properties.insert(_name, _value); @@ -26,7 +39,7 @@ void CardInfo::setProperty(const QString &_name, const QString &_value) return; } - emit cardInfoChanged(smartThis); + notifyChanged(); } CardInfo::CardInfo(const QString &_name, @@ -97,7 +110,6 @@ CardInfoPtr CardInfo::newInstance(const QString &_name, { CardInfoPtr ptr(new CardInfo(_name, _text, _isToken, _properties, _relatedCards, _reverseRelatedCards, _sets, _uiAttributes, std::move(_localizedNames), std::move(_localizedTexts))); - ptr->setSmartPointer(ptr); for (const auto &printings : _sets) { for (const PrintingInfo &printing : printings) { @@ -126,7 +138,6 @@ CardInfoPtr CardInfo::newInstance(const QString &_name, CardInfoPtr ptr(new CardInfo(_name, _text, _isToken, std::move(_propertiesBlob), _relatedCards, _reverseRelatedCards, _sets, _uiAttributes, std::move(_simpleName), std::move(_altNames), std::move(_localizedNames), std::move(_localizedTexts))); - ptr->setSmartPointer(ptr); if (_appendToSets) { for (const auto &printings : _sets) { @@ -169,8 +180,15 @@ bool CardInfo::isLegalInFormat(const QString &format) const void CardInfo::addToSet(const CardSetPtr &_set, const PrintingInfo &_info) { - if (!_set->contains(smartThis)) { - _set->append(smartThis); + const CardInfoPtr self = sharedFromThis(); + Q_ASSERT_X(self, "CardInfo::addToSet", "every CardInfo is owned by a QSharedPointer"); + if (!self) { + qCWarning(CardInfoLog) << "addToSet() on an unowned CardInfo" << name << "- the set will not list it"; + return; + } + + if (!_set->contains(self)) { + _set->append(self); } if (!setsToPrintings[_set->getShortName()].contains(_info)) { setsToPrintings[_set->getShortName()].append(_info); @@ -188,7 +206,7 @@ void CardInfo::combineLegalities(const QHash &props) properties.insert(it.key(), it.value()); } } - emit cardInfoChanged(smartThis); + notifyChanged(); } void CardInfo::refreshCachedSets() diff --git a/libcockatrice_card/libcockatrice/card/card_info.h b/libcockatrice_card/libcockatrice/card/card_info.h index eb1250a1c..5452ef98b 100644 --- a/libcockatrice_card/libcockatrice/card/card_info.h +++ b/libcockatrice_card/libcockatrice/card/card_info.h @@ -7,6 +7,7 @@ #include #include +#include #include #include #include @@ -50,7 +51,7 @@ Q_DECLARE_METATYPE(CardInfoPtr) * Each CardInfo may belong to multiple sets through its printings, and can * be related to other cards through defined relationships. */ -class CardInfo : public QObject +class CardInfo : public QObject, public QEnableSharedFromThis { Q_OBJECT @@ -74,11 +75,10 @@ private: * @anchor PrivateCardProperties */ ///@{ - CardInfoPtr smartThis; ///< Smart pointer to self for safe cross-references. - QString name; ///< Full name of the card. - QString simpleName; ///< Simplified name for fuzzy matching. - QString text; ///< Text description or rules text of the card. - bool isToken; ///< Whether this card is a token or not. + QString name; ///< Full name of the card. + QString simpleName; ///< Simplified name for fuzzy matching. + QString text; ///< Text description or rules text of the card. + bool isToken; ///< Whether this card is a token or not. QMap localizedNames; ///< Localized card names, keyed by language code. QMap localizedTexts; ///< Localized rules text, keyed by language code. @@ -162,13 +162,14 @@ public: * @param other Another CardInfo to copy. */ CardInfo(const CardInfo &other) - : QObject(other.parent()), name(other.name), simpleName(other.simpleName), text(other.text), - isToken(other.isToken), localizedNames(other.localizedNames), localizedTexts(other.localizedTexts), - properties(other.properties), relatedCards(other.relatedCards), + : QObject(other.parent()), QEnableSharedFromThis(), name(other.name), simpleName(other.simpleName), + text(other.text), isToken(other.isToken), localizedNames(other.localizedNames), + localizedTexts(other.localizedTexts), properties(other.properties), relatedCards(other.relatedCards), reverseRelatedCards(other.reverseRelatedCards), reverseRelatedCardsToMe(other.reverseRelatedCardsToMe), setsToPrintings(other.setsToPrintings), uiAttributes(other.uiAttributes), setsNames(other.setsNames), altNames(other.altNames) { + // The copy deliberately starts unowned: the QSharedPointer that wraps it registers the self reference. } /** @@ -252,27 +253,13 @@ public: /** * @brief Clones the current CardInfo instance. * - * Uses the copy constructor and ensures the smart pointer is properly set. + * Uses the copy constructor; the copy is managed by its own shared pointer. * * @return Shared pointer to the cloned CardInfo. */ [[nodiscard]] CardInfoPtr clone() const { - auto newCardInfo = CardInfoPtr(new CardInfo(*this)); - newCardInfo->setSmartPointer(newCardInfo); // Set the smart pointer for the new instance - return newCardInfo; - } - - /** - * @brief Sets the internal smart pointer to self. - * - * Used internally to allow safe cross-references among CardInfo and CardSet. - * - * @param _ptr Shared pointer pointing to this instance. - */ - void setSmartPointer(CardInfoPtr _ptr) - { - smartThis = std::move(_ptr); + return CardInfoPtr(new CardInfo(*this)); } /** @name Basic Properties Accessors */ //@{ @@ -295,7 +282,7 @@ public: void setText(const QString &_text) { text = _text; - emit cardInfoChanged(smartThis); + notifyChanged(); } /** @@ -358,7 +345,7 @@ public: return; } localizedNames.insert(lang, _localizedName); - emit cardInfoChanged(smartThis); + notifyChanged(); } /** @@ -373,7 +360,7 @@ public: return; } localizedTexts.insert(lang, _localizedText); - emit cardInfoChanged(smartThis); + notifyChanged(); } /** @@ -552,6 +539,14 @@ public: static QString simplifyName(const QString &name); private: + /** + * @brief Emits cardInfoChanged() with a strong reference to this card. + * + * A CardInfo is always owned by a QSharedPointer, so the self reference is always available; a released + * build that somehow finds it missing warns rather than emitting a null card. + */ + void notifyChanged(); + /** * @brief Refreshes the cached, human-readable list of set names. * diff --git a/libcockatrice_card/libcockatrice/card/database/card_database.cpp b/libcockatrice_card/libcockatrice/card/database/card_database.cpp index 946412606..ed097ef3b 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database.cpp +++ b/libcockatrice_card/libcockatrice/card/database/card_database.cpp @@ -53,13 +53,26 @@ CardDatabase::~CardDatabase() clear(); } +void CardDatabase::detachSetMembership() +{ + for (const auto &set : sets) { + if (set) { + set->clear(); + } + } +} + void CardDatabase::clear() { QMutexLocker locker(clearDatabaseMutex); + // The sets hold strong card references, so empty them before dropping the cards: this breaks the + // CardInfo <-> CardSet cycle and spares the per-card detach over the whole database. + detachSetMembership(); + for (const auto &card : cards.values()) { if (card) { - removeCard(card); + unindexCard(card); } } @@ -156,6 +169,23 @@ void CardDatabase::removeCard(const CardInfoPtr &card) return; } + // The set holds a strong reference back to the card, so detach it: leaving it there keeps both + // objects alive after the database drops the card. newInstance() appends through the first + // printing of every set, so detaching through that same printing mirrors the append. + for (const auto &printings : card->getSets()) { + if (printings.isEmpty()) { + continue; + } + if (const CardSetPtr set = printings.first().getSet()) { + set->removeAll(card); + } + } + + unindexCard(card); +} + +void CardDatabase::unindexCard(const CardInfoPtr &card) +{ for (auto *cardRelation : card->getRelatedCards()) { cardRelation->deleteLater(); } @@ -280,6 +310,10 @@ void CardDatabase::swapInDatabaseData(CardDatabaseData data) card->clearDirectRelations(); } + // Break the CardSet <-> CardInfo cycle before replacing the containers, otherwise the previous + // snapshot survives the reload through the sets' strong card lists. + detachSetMembership(); + cards = std::move(data.cards); simpleNameCards = std::move(data.simpleNameCards); sets = std::move(data.sets); diff --git a/libcockatrice_card/libcockatrice/card/database/card_database.h b/libcockatrice_card/libcockatrice/card/database/card_database.h index 9f0d67b67..5c1cb43c1 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database.h +++ b/libcockatrice_card/libcockatrice/card/database/card_database.h @@ -74,6 +74,23 @@ private: */ void refreshCachedReverseRelatedCards(CardNameMap &cardMap); + /** + * @brief Empties every set, dropping the strong card references they hold. + * + * A card keeps a CardSetPtr through its printings while its set keeps a strong CardInfoPtr, so the + * two form a reference cycle. Dropping a whole snapshot has to break that cycle once, up front, + * instead of relying on the per-card detach in removeCard(). + */ + void detachSetMembership(); + + /** + * @brief Drops a card from the database indexes and notifies observers. + * + * Callers are responsible for detaching the card from its sets first. + * @param card Pointer to the card to unindex. + */ + void unindexCard(const CardInfoPtr &card); + /** @brief Mutexes for thread safety. */ QBasicMutex *clearDatabaseMutex = new QBasicMutex(), *addCardMutex = new QBasicMutex(), *removeCardMutex = new QBasicMutex(); diff --git a/libcockatrice_card/libcockatrice/card/set/card_set.cpp b/libcockatrice_card/libcockatrice/card/set/card_set.cpp index e2b41a811..a0f0f4578 100644 --- a/libcockatrice_card/libcockatrice/card/set/card_set.cpp +++ b/libcockatrice_card/libcockatrice/card/set/card_set.cpp @@ -27,7 +27,6 @@ CardSetPtr CardSet::newInstance(ICardSetPriorityController *_priorityController, const Priority _priority) { CardSetPtr ptr(new CardSet(_priorityController, _shortName, _longName, _setType, _releaseDate, _priority)); - // ptr->setSmartPointer(ptr); return ptr; } diff --git a/servatrice/src/server_logger.cpp b/servatrice/src/server_logger.cpp index dfe51183a..09453b7ee 100644 --- a/servatrice/src/server_logger.cpp +++ b/servatrice/src/server_logger.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -66,7 +67,9 @@ void ServerLogger::reloadConfig(bool _writeLog, const QString &_logFilters) bool ServerLogger::isLoggingEnabled() const { - return logFile != nullptr && settingsCache->value("server/writelog", 1).toBool(); + // Mirror logMessage(): the console is an independent sink and the logfile needs writeLog. + QMutexLocker locker(&configMutex); + return logToConsole || (logFile != nullptr && writeLog); } void ServerLogger::logMessage(const QString &message, void *caller) diff --git a/servatrice/src/server_logger.h b/servatrice/src/server_logger.h index 531430d56..40df9fd62 100644 --- a/servatrice/src/server_logger.h +++ b/servatrice/src/server_logger.h @@ -41,7 +41,7 @@ private: bool messageFilterActive = false; QStringList logFilters; // Guards writeLog/messageFilterActive/logFilters: SIGHUP rewrites them while other threads log. - QMutex configMutex; + mutable QMutex configMutex; QFile *logFile = nullptr; bool flushRunning; QStringList buffer; diff --git a/tests/carddatabase/carddatabase_test.cpp b/tests/carddatabase/carddatabase_test.cpp index eb522434d..22cbe9074 100644 --- a/tests/carddatabase/carddatabase_test.cpp +++ b/tests/carddatabase/carddatabase_test.cpp @@ -17,10 +17,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -608,6 +610,110 @@ TEST_F(CardDatabaseCacheInvalidationTest, MarkAllSetsAsKnownIsBatched) // Ignored sets keep their known state but are enabled. EXPECT_TRUE(controller.isEnabled("XXX")); } + +TEST_F(CardDatabaseCacheInvalidationTest, ReloadReleasesPreviousSnapshot) +{ + NoopCardPreferenceProvider prefs; + SandboxPathProvider pathProvider(root); + CardDatabase db(nullptr, &prefs, &pathProvider, &controller); + + db.loadCardDatabases(); + CardInfoPtr card = db.getCardList().value("Cat"); + ASSERT_FALSE(card.isNull()); + QWeakPointer weakCard(card); + card.clear(); + + db.reloadCardDatabasesAndNotify(); + + // CardSet holds strong CardInfoPtrs and CardInfo holds the CardSetPtr through its printings; if that + // cycle is not broken, the previous snapshot survives the reload. + EXPECT_TRUE(weakCard.isNull()) << "a reload must release the previous card snapshot"; +} + +TEST_F(CardDatabaseCacheInvalidationTest, ClearReleasesCards) +{ + NoopCardPreferenceProvider prefs; + SandboxPathProvider pathProvider(root); + CardDatabase db(nullptr, &prefs, &pathProvider, &controller); + + db.loadCardDatabases(); + CardInfoPtr card = db.getCardList().value("Cat"); + ASSERT_FALSE(card.isNull()); + QWeakPointer weakCard(card); + card.clear(); + + db.clear(); + + EXPECT_TRUE(weakCard.isNull()) << "clear() must release cards held by set membership"; +} + +TEST_F(CardDatabaseCacheInvalidationTest, RemoveCardDetachesItFromItsSet) +{ + // removeCard() is the one teardown path a caller can trigger for a single card (the token editor + // uses it). Without the detach the set's strong reference keeps the removed card alive forever. + NoopCardPreferenceProvider prefs; + SandboxPathProvider pathProvider(root); + CardDatabase db(nullptr, &prefs, &pathProvider, &controller); + + db.loadCardDatabases(); + CardInfoPtr card = db.getCardList().value("Cat"); + ASSERT_FALSE(card.isNull()); + const CardSetPtr catSet = db.getSet("CAT"); + ASSERT_TRUE(catSet->contains(card)); + + db.removeCard(card); + + EXPECT_FALSE(catSet->contains(card)) << "removeCard() must detach the card from its set"; + QWeakPointer weakCard(card); + card.clear(); + EXPECT_TRUE(weakCard.isNull()) << "a removed card must not stay alive through its set"; +} + +TEST_F(CardDatabaseCacheInvalidationTest, RemoveSelfPointerStillNotifiesDatabaseCard) +{ + // cardInfoChanged() used to be emitted through a self pointer stored on the card. Without it the + // card must still notify its observers whenever its properties change. + NoopCardPreferenceProvider prefs; + SandboxPathProvider pathProvider(root); + CardDatabase db(nullptr, &prefs, &pathProvider, &controller); + + db.loadCardDatabases(); + const CardInfoPtr card = db.getCardList().value("Cat"); + ASSERT_FALSE(card.isNull()); + QSignalSpy spy(card.data(), &CardInfo::cardInfoChanged); + + card->setProperty("manacost", "1G"); + + ASSERT_EQ(spy.count(), 1) << "a property change on a database card must still emit cardInfoChanged"; + const QList arguments = spy.takeFirst(); + ASSERT_EQ(arguments.size(), 1); + EXPECT_EQ(arguments.first().value().data(), card.data()) + << "the signal must carry the card that changed"; +} + +TEST_F(CardDatabaseCacheInvalidationTest, RemoveSelfPointerCloneNotifiesAsItself) +{ + // A clone owns its own shared reference, so its changes must be reported as the clone's, never the + // original's: decks and views listening to the original must not be invalidated by a clone edit. + NoopCardPreferenceProvider prefs; + SandboxPathProvider pathProvider(root); + CardDatabase db(nullptr, &prefs, &pathProvider, &controller); + + db.loadCardDatabases(); + const CardInfoPtr original = db.getCardList().value("Cat"); + ASSERT_FALSE(original.isNull()); + const CardInfoPtr clone = original->clone(); + QSignalSpy originalSpy(original.data(), &CardInfo::cardInfoChanged); + QSignalSpy cloneSpy(clone.data(), &CardInfo::cardInfoChanged); + + clone->setProperty("manacost", "1G"); + + ASSERT_EQ(cloneSpy.count(), 1) << "the clone must notify its own observers"; + EXPECT_EQ(cloneSpy.first().first().value().data(), clone.data()) + << "the clone's signal must carry the clone"; + EXPECT_EQ(originalSpy.count(), 0) << "the clone must not notify the original's observers"; +} + } // namespace int main(int argc, char **argv) diff --git a/tests/server_logger_test.cpp b/tests/server_logger_test.cpp index 94a6909bf..3c2d047cc 100644 --- a/tests/server_logger_test.cpp +++ b/tests/server_logger_test.cpp @@ -98,6 +98,28 @@ TEST(ServerLoggerTest, StaysQuietWhenConsoleIsDisabled) EXPECT_TRUE(capture.text().isEmpty()) << capture.text().toStdString(); } +TEST(ServerLoggerTest, IsLoggingEnabledMirrorsTheSinks) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + + // The console is a sink of its own: messages must not be discarded for want of a logfile. + { + ServerLogger logger(true, false, QString()); + EXPECT_TRUE(logger.isLoggingEnabled()); + } + + // The logfile sink needs an open file and writelog; reloadConfig() carries the SIGHUP change. + { + ServerLogger logger(false, true, QString()); + EXPECT_FALSE(logger.isLoggingEnabled()) << "no logfile is open yet"; + logger.startLog(dir.filePath("enabled.log")); + EXPECT_TRUE(logger.isLoggingEnabled()); + logger.reloadConfig(false, QString()); + EXPECT_FALSE(logger.isLoggingEnabled()) << "writelog=false leaves no sink enabled"; + } +} + TEST(ServerLoggerTest, WritesMatchingFilteredMessagesToTheLogFile) { QTemporaryDir dir; From ff393b899041fde2ad08f4efc1f99e07a855e823 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 1 Nov 2026 12:49:35 +0100 Subject: [PATCH 081/108] [Game] Connect zone dump response before sending the request (#7711) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ZoneViewZone::initializeCards() connected PendingCommand::finished only after ZoneViewZoneLogic::requestZoneDump() had already sent the command. Local games process the dump synchronously, so finished was emitted before the view connected and the library/sideboard view stayed empty. Split requestZoneDump() into prepareZoneDump() and sendZoneDump(), and connect before sending, matching the TabRoom prepare/send convention. Co-authored-by: Lukas Brübach --- cockatrice/src/game_graphics/zones/view_zone.cpp | 6 +++++- .../libcockatrice/game/zones/view_zone_logic.cpp | 16 +++++++++------- .../libcockatrice/game/zones/view_zone_logic.h | 15 +++++++++++---- 3 files changed, 25 insertions(+), 12 deletions(-) diff --git a/cockatrice/src/game_graphics/zones/view_zone.cpp b/cockatrice/src/game_graphics/zones/view_zone.cpp index 83c13d467..be2e471d4 100644 --- a/cockatrice/src/game_graphics/zones/view_zone.cpp +++ b/cockatrice/src/game_graphics/zones/view_zone.cpp @@ -99,9 +99,13 @@ void ZoneViewZone::initializeCards(const QList &cardLis } reorganizeCards(); } else if (!qobject_cast(getLogic())->getOriginalZone()->contentsKnown()) { - PendingCommand *pend = qobject_cast(getLogic())->requestZoneDump(); + auto *viewLogic = qobject_cast(getLogic()); + PendingCommand *pend = viewLogic->prepareZoneDump(); if (pend != nullptr) { + // A local game answers synchronously, so the handler has to be connected before the + // command is sent or the response would be delivered to nobody. connect(pend, &PendingCommand::finished, this, &ZoneViewZone::zoneDumpReceived); + viewLogic->sendZoneDump(pend); } } else { const CardList &c = qobject_cast(getLogic())->getOriginalZone()->getCards(); diff --git a/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.cpp b/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.cpp index 88941f12b..db1fa32c3 100644 --- a/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.cpp +++ b/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.cpp @@ -192,12 +192,14 @@ void ZoneViewZoneLogic::setWriteableRevealZone(bool _writeableRevealZone) writeableRevealZone = _writeableRevealZone; } -PendingCommand *ZoneViewZoneLogic::requestZoneDump() +PendingCommand *ZoneViewZoneLogic::prepareZoneDump() { - PlayerActions *actions = getPlayer()->getPlayerActions(); - PendingCommand *pend = actions->prepareZoneDump(this, numberCards); - if (pend != nullptr) { - actions->sendGameCommand(pend); - } - return pend; + return getPlayer()->getPlayerActions()->prepareZoneDump(this, numberCards); +} + +void ZoneViewZoneLogic::sendZoneDump(PendingCommand *pendingCommand) +{ + if (pendingCommand != nullptr) { + getPlayer()->getPlayerActions()->sendGameCommand(pendingCommand); + } } diff --git a/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.h b/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.h index 3592db780..a29e543a3 100644 --- a/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.h +++ b/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.h @@ -88,14 +88,21 @@ public: } /** - * @brief Prepares and sends the zone-dump command for this view. + * @brief Builds the zone-dump command for this view without sending it. * @return The pending command, or nullptr when there is no zone to dump. * * Keeps command construction in game/ so graphics views don't reach - * through the player into PlayerActions to set it up. The command is sent - * asynchronously, so callers connect to finished() after calling this. + * through the player into PlayerActions to set it up. The response can + * arrive synchronously (local games), so callers must connect to + * PendingCommand::finished before calling sendZoneDump(). */ - PendingCommand *requestZoneDump(); + PendingCommand *prepareZoneDump(); + + /** + * @brief Sends a command returned by prepareZoneDump(). + * @param pendingCommand The prepared command; nullptr is ignored. + */ + void sendZoneDump(PendingCommand *pendingCommand); protected: void addCardImpl(CardState *card, int x, int y) override; From e08402ac941d932da3fa8368ee88213a461a6123 Mon Sep 17 00:00:00 2001 From: tooomm Date: Sun, 1 Nov 2026 16:22:51 +0100 Subject: [PATCH 082/108] bump vcpkg submodule (#7690) --- vcpkg | 2 +- vcpkg.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/vcpkg b/vcpkg index 9e593bb18..9eec002df 160000 --- a/vcpkg +++ b/vcpkg @@ -1 +1 @@ -Subproject commit 9e593bb18ea69cc5095e012465dcd675a822ed0d +Subproject commit 9eec002df1cd109f8f104cd966a763daf9751489 diff --git a/vcpkg.json b/vcpkg.json index f12227024..8c9c64161 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/microsoft/vcpkg-tool/main/docs/vcpkg.schema.json", - "builtin-baseline": "9e593bb18ea69cc5095e012465dcd675a822ed0d", + "builtin-baseline": "9eec002df1cd109f8f104cd966a763daf9751489", "dependencies": [ "gtest", "liblzma", From ef4574d2b9494d18b99012d34590c36ca655531a Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 2 Nov 2026 07:00:48 +0100 Subject: [PATCH 083/108] [Server] Store a scrypt verifier when an admin resets a password (#7679) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The temporary password was written as a legacy salt+hash row, which makes the next login look like an authentication downgrade. A client holding a saved verifier refuses that downgrade, so the reset locked the account out of every modern client. Store the verifier and, where security/legacy_backup_key is configured, keep a sealed legacy hash of the same temporary password as the downgrade backup, so pre-challenge clients keep the credential path the reset used to provide. Without the key no row shape serves both: the verifier and the legacy hash cannot share one column, so those clients have no path at all. Warn at reset time that the new password has no pre-challenge path, and say so in servatrice.ini.example instead of promising that a reset gets them back in. Co-authored-by: Lukas Brübach --- servatrice/servatrice.ini.example | 8 +- .../src/servatrice_database_interface.cpp | 81 ++++++++++++++++--- .../src/servatrice_database_interface.h | 8 ++ servatrice/src/serversocketinterface.cpp | 10 ++- 4 files changed, 91 insertions(+), 16 deletions(-) diff --git a/servatrice/servatrice.ini.example b/servatrice/servatrice.ini.example index 021f5157f..ac291fd7f 100644 --- a/servatrice/servatrice.ini.example +++ b/servatrice/servatrice.ini.example @@ -455,9 +455,11 @@ authentication_strictness=mixed ; the scrypt verifier, so old clients can still log in during the window below without ; the database holding a fast-crackable hash in the clear. ; Generate with: openssl rand -hex 32 -; If unset, migrated accounts have no downgrade window: old clients must use a password -; reset to get back in. Rotating the key invalidates existing backups (they become -; unusable immediately; expired ones are still purged). +; If unset there is no downgrade path at all: a migrated account keeps only a scrypt +; verifier, and an administrator's password reset stores a verifier too, so pre-challenge +; clients cannot log in with the temporary password either. Set the key if you still serve +; them. Rotating the key invalidates existing backups (they become unusable immediately; +; expired ones are still purged). legacy_backup_key= ; Days a legacy password backup may go unused before it is purged from the database. diff --git a/servatrice/src/servatrice_database_interface.cpp b/servatrice/src/servatrice_database_interface.cpp index b5c45065c..1b49c3a82 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -755,6 +755,20 @@ QString Servatrice_DatabaseInterface::getUserPasswordData(const QString &user) return query->value(0).toString(); } +QString Servatrice_DatabaseInterface::sealLegacyBackup(const QString &storedName, const QString &credential) +{ + const QByteArray key = server->getLegacyBackupKey(); + if (key.isEmpty()) { + return {}; + } + const QByteArray aad = storedName.simplified().toUtf8(); + const QString sealed = CryptoUtil::encryptSecret(credential.toUtf8(), key, aad); + if (sealed.isEmpty() || sealed.length() > MAX_LEGACY_BACKUP_LENGTH) { + return {}; + } + return sealed; +} + bool Servatrice_DatabaseInterface::submitPasswordVerifier(const QString &user, const QString &passwordVerifier) { if (server->getAuthenticationMethod() != Servatrice::AuthenticationSql) { @@ -779,18 +793,13 @@ bool Servatrice_DatabaseInterface::submitPasswordVerifier(const QString &user, c QString sealedBackup; QDateTime backupUsedAt; - if (PasswordHasher::isLegacyFormat(currentCredential)) { - const QByteArray key = server->getLegacyBackupKey(); - if (!key.isEmpty()) { - const QByteArray aad = storedName.simplified().toUtf8(); - sealedBackup = CryptoUtil::encryptSecret(currentCredential.toUtf8(), key, aad); - if (sealedBackup.isEmpty() || sealedBackup.length() > MAX_LEGACY_BACKUP_LENGTH) { - qCWarning(DatabaseInterfaceLog) - << "Failed to seal legacy backup for user" << user << "- account migrates without a downgrade path"; - sealedBackup.clear(); - } else { - backupUsedAt = QDateTime::currentDateTimeUtc(); - } + if (PasswordHasher::isLegacyFormat(currentCredential) && !server->getLegacyBackupKey().isEmpty()) { + sealedBackup = sealLegacyBackup(storedName, currentCredential); + if (sealedBackup.isEmpty()) { + qCWarning(DatabaseInterfaceLog) + << "Failed to seal legacy backup for user" << user << "- account migrates without a downgrade path"; + } else { + backupUsedAt = QDateTime::currentDateTimeUtc(); } } @@ -1644,6 +1653,54 @@ bool Servatrice_DatabaseInterface::changeUserPassword(const QString &user, return passwordQuery->numRowsAffected() > 0; } +bool Servatrice_DatabaseInterface::changeUserPasswordWithLegacyBackup(const QString &user, + const QString &passwordVerifier, + const QString &legacyCredential) +{ + if (server->getAuthenticationMethod() != Servatrice::AuthenticationSql) { + return false; + } + + if (!checkSql()) { + return false; + } + + // Seal against the stored row name, not the caller-supplied one: the name column is + // case-insensitive, so a differently-cased name would fail the tag check on open. + QSqlQuery *nameQuery = prepareQuery("SELECT name FROM {prefix}_users WHERE name = :name"); + nameQuery->bindValue(":name", user); + if (!execSqlQuery(nameQuery) || !nameQuery->next()) { + return false; + } + const QString storedName = nameQuery->value(0).toString(); + + const QString sealedBackup = sealLegacyBackup(storedName, legacyCredential); + const QDateTime backupUsedAt = sealedBackup.isEmpty() ? QDateTime() : QDateTime::currentDateTimeUtc(); + if (sealedBackup.isEmpty()) { + // Without a backup a pre-challenge client has nothing to authenticate against: the + // row holds a verifier and the legacy hash it would send cannot match it. Say so, + // because the symptom - the temporary password appearing not to work - shows up on + // a different machine from the one that reset it. + qCWarning(DatabaseInterfaceLog) << "Stored a verifier without a legacy backup for user" << user + << "- pre-challenge clients cannot use this password" + << "- check security/legacy_backup_key"; + } + + QSqlQuery *query = prepareQuery("update {prefix}_users set password_sha512=:password, " + "password_legacy_backup=:backup, password_legacy_backup_used_at=:used_at, " + "passwordLastChangedDate = NOW() where name = :name"); + query->bindValue(":password", passwordVerifier); + query->bindValue(":backup", sealedBackup); + query->bindValue(":used_at", backupUsedAt.isValid() + ? QVariant(backupUsedAt.toString(QStringLiteral("yyyy-MM-dd HH:mm:ss"))) + : QVariant()); + query->bindValue(":name", storedName); + if (!execSqlQuery(query)) { + return false; + } + return query->numRowsAffected() > 0; +} + void Servatrice_DatabaseInterface::setForcePasswordChange(const QString &user, bool force) { if (!checkSql()) { diff --git a/servatrice/src/servatrice_database_interface.h b/servatrice/src/servatrice_database_interface.h index 6b38384e4..a27bd55b8 100644 --- a/servatrice/src/servatrice_database_interface.h +++ b/servatrice/src/servatrice_database_interface.h @@ -62,6 +62,9 @@ private: bool checkUserIsIpBanned(const QString &ipAddress, QString &banReason, int &banSecondsRemaining); /** Must be called after checkSql and server is known to be in auth mode. */ bool checkUserIsNameBanned(QString const &userName, QString &banReason, int &banSecondsRemaining); + /** Seal a legacy credential for the account's downgrade backup; empty when the + * backup key is unset or the sealed value does not fit the column. */ + QString sealLegacyBackup(const QString &storedName, const QString &credential); bool isStrictModeEnabled(bool &ok) const; protected: @@ -192,6 +195,11 @@ public: const QString &targetName, const QString &channel = QString()) override; bool changeUserPassword(const QString &user, const QString &password, bool passwordNeedsHash) override; + /** Replace a credential with an already-derived verifier, keeping a sealed legacy hash of the + * same password as the downgrade backup for pre-challenge clients. */ + bool changeUserPasswordWithLegacyBackup(const QString &user, + const QString &passwordVerifier, + const QString &legacyCredential); bool changeUserPassword(const QString &user, const QString &oldPassword, bool oldPasswordNeedsHash, diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index 78593f6dc..963b98c9c 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -2641,7 +2641,15 @@ Response::ResponseCode AbstractServerSocketInterface::cmdResetUserPassword(const } const QString tempPassword = PasswordHasher::generateRandomSalt(); - if (!sqlInterface->changeUserPassword(userName, tempPassword, true)) { + // Store a scrypt verifier rather than a legacy salt+hash: a legacy row makes the next + // login look like an authentication downgrade, which modern clients refuse when they + // still hold a verifier for the account. Where security/legacy_backup_key is configured, + // keep a sealed legacy hash of the same temporary password as the downgrade backup so + // pre-challenge clients keep the path the reset used to provide; without the key they + // have none, which changeUserPasswordWithLegacyBackup warns about. + const QString verifier = PasswordHasher::generatePasswordVerifier(tempPassword); + const QString legacyCredential = PasswordHasher::computeHash(tempPassword, PasswordHasher::generateRandomSalt()); + if (!sqlInterface->changeUserPasswordWithLegacyBackup(userName, verifier, legacyCredential)) { return Response::RespInternalError; } sqlInterface->setForcePasswordChange(userName, true); From 50a2ae9cc8d56f2014479e88dcb0b25dfea6bb7d Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 2 Nov 2026 07:01:29 +0100 Subject: [PATCH 084/108] [Server/Client] Enforce and complete the forced password change at login (#7680) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit checkUserPassword ignored force_password_change on the challenge-response path, so a reset password stayed valid. Returning PasswordChangeRequired was not enough on its own: the session was dropped and the client told the user to ask the administrator for the temporary password they had just used. The pending session now keeps its identity without registering a user session or announcing a join, may only ping and change its password, and the client completes the change in-session before reconnecting. Co-authored-by: Lukas Brübach --- .../remote_connection_controller.cpp | 56 ++++- .../remote_connection_controller.h | 4 + .../widgets/server/user/user_info_box.cpp | 13 +- .../network/client/abstract/CMakeLists.txt | 2 +- .../client/abstract/abstract_client.cpp | 21 ++ .../network/client/abstract/abstract_client.h | 14 ++ .../network/client/remote/remote_client.cpp | 41 +++ .../network/client/remote/remote_client.h | 11 + .../network/server/remote/server.cpp | 28 ++- .../server/remote/server_protocolhandler.cpp | 28 ++- .../server/remote/server_protocolhandler.h | 6 + .../src/servatrice_database_interface.cpp | 4 + servatrice/src/serversocketinterface.cpp | 3 + tests/CMakeLists.txt | 22 ++ tests/client_login_status_test.cpp | 116 +++++++++ tests/server_forced_password_change_test.cpp | 234 ++++++++++++++++++ 16 files changed, 575 insertions(+), 28 deletions(-) create mode 100644 tests/client_login_status_test.cpp create mode 100644 tests/server_forced_password_change_test.cpp diff --git a/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp b/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp index 020e53877..c702f91f1 100644 --- a/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp +++ b/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp @@ -3,6 +3,7 @@ #include "../../../interface/pixel_map_generator.h" #include "../../settings/cache_settings.h" #include "../interface/widgets/dialogs/dlg_connect.h" +#include "../interface/widgets/dialogs/dlg_edit_password.h" #include "../interface/widgets/dialogs/dlg_forgot_password_challenge.h" #include "../interface/widgets/dialogs/dlg_forgot_password_request.h" #include "../interface/widgets/dialogs/dlg_forgot_password_reset.h" @@ -99,6 +100,10 @@ void ConnectionController::wireClientSignals() connect(remoteClient, &RemoteClient::sigPasswordVerifierReady, this, &ConnectionController::onPasswordVerifierReady); + + connect(remoteClient, &RemoteClient::sigPasswordChangeSuccess, this, + &ConnectionController::onPasswordChangeSuccess); + connect(remoteClient, &RemoteClient::sigPasswordChangeError, this, &ConnectionController::onPasswordChangeError); } void ConnectionController::connectToServer() @@ -351,10 +356,16 @@ void ConnectionController::onLoginError(int r, } case Response::RespPasswordChangeRequired: { - QMessageBox::information( - dialogParent, tr("Password Change Required"), - tr("An administrator has reset your password. Please contact your server administrator to obtain " - "your temporary password, then log in and change it via Account -> Change Password.")); + // The credentials were right, but the account may not be used until a new password + // is chosen. Let the user complete the change here: sending them off to "ask the + // administrator for your temporary password" sends them in circles, since they just + // logged in with it and the session ends without a way to comply. + DlgEditPassword dlg(dialogParent); + dlg.setWindowTitle(tr("Password Change Required")); + if (dlg.exec()) { + remoteClient->changePasswordToServer(dlg.getOldPassword(), dlg.getNewPassword()); + return; // onPasswordChangeSuccess/onPasswordChangeError decides the next step + } remoteClient->disconnectFromServer(); return; } @@ -517,6 +528,43 @@ void ConnectionController::onNotifyUserAboutUpdate() "To update your client, go to Help -> Check for Updates.")); } +void ConnectionController::onPasswordChangeSuccess() +{ + // The credential saved for this profile is stale now: drop it so the next login asks for + // the new password instead of replaying the old verifier. + if (!pendingSaveName.isEmpty()) { + SettingsCache::instance().servers().setServerPassword(pendingSaveName, ""); + } + + QMessageBox::information(dialogParent, tr("Password Changed"), + tr("Your password has been changed. Please log in again with your new password.")); + remoteClient->disconnectFromServer(); + connectToServer(); +} + +void ConnectionController::onPasswordChangeError(int responseCode) +{ + QString reason; + switch (static_cast(responseCode)) { + case Response::RespWrongPassword: + reason = tr("The current password is incorrect."); + break; + case Response::RespPasswordTooShort: + reason = tr("The new password is too short."); + break; + case Response::RespClientUpdateRequired: + reason = tr("The server does not accept the new password format."); + break; + default: + reason = tr("The password could not be changed (error %1).").arg(responseCode); + break; + } + + QMessageBox::critical(dialogParent, tr("Password Change Failed"), reason); + remoteClient->disconnectFromServer(); + connectToServer(); +} + void ConnectionController::onForgotPasswordSuccess() { QMessageBox::information( diff --git a/cockatrice/src/client/network/connection_controller/remote_connection_controller.h b/cockatrice/src/client/network/connection_controller/remote_connection_controller.h index c8f96a9a9..2987a8d99 100644 --- a/cockatrice/src/client/network/connection_controller/remote_connection_controller.h +++ b/cockatrice/src/client/network/connection_controller/remote_connection_controller.h @@ -94,6 +94,10 @@ private slots: // Persists the derived scrypt verifier after a successful challenge-response login void onPasswordVerifierReady(const QString &verifier); + // Completes the forced password change the server demanded at login + void onPasswordChangeSuccess(); + void onPasswordChangeError(int responseCode); + private: void wireClientSignals(); void updateWindowTitle(); diff --git a/cockatrice/src/interface/widgets/server/user/user_info_box.cpp b/cockatrice/src/interface/widgets/server/user/user_info_box.cpp index 4feb73538..fa7698037 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_box.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_info_box.cpp @@ -32,7 +32,6 @@ #include #include #include -#include #include #include #include @@ -299,17 +298,7 @@ void UserInfoBox::actPassword() void UserInfoBox::changePassword(const QString &oldPassword, const QString &newPassword) { - Command_AccountPassword cmd; - cmd.set_old_password(oldPassword.toStdString()); - if (client->getServerSupportsChallengeResponse()) { - cmd.set_hashed_new_password(PasswordHasher::generatePasswordVerifier(newPassword).toStdString()); - } else if (client->getServerSupportsPasswordHash()) { - auto passwordSalt = PasswordHasher::generateRandomSalt(); - QString hashedPassword = PasswordHasher::computeHash(newPassword, passwordSalt); - cmd.set_hashed_new_password(hashedPassword.toStdString()); - } else { - cmd.set_new_password(newPassword.toStdString()); - } + const Command_AccountPassword cmd = client->makeAccountPasswordCommand(oldPassword, newPassword); PendingCommand *pend = client->prepareSessionCommand(cmd); connect(pend, &PendingCommand::finished, this, &UserInfoBox::processPasswordResponse); diff --git a/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt index 880cef5a1..791db6348 100644 --- a/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt @@ -15,7 +15,7 @@ target_include_directories(libcockatrice_network_client_abstract PUBLIC .) target_link_libraries( libcockatrice_network_client_abstract PUBLIC ${COCKATRICE_QT_VERSION_NAME}::Network ${COCKATRICE_QT_VERSION_NAME}::WebSockets libcockatrice_protocol - libcockatrice_network_server_remote + libcockatrice_network_server_remote libcockatrice_utility ) target_precompile_headers(libcockatrice_network_client_abstract PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h") diff --git a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp index 0c523d776..04e6beda0 100644 --- a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp +++ b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp @@ -10,6 +10,7 @@ #include "libcockatrice/protocol/pb/serverinfo_user.pb.h" #include "libcockatrice/protocol/pb/session_commands.pb.h" #include "libcockatrice/protocol/pb/session_event.pb.h" +#include "libcockatrice/utility/passwordhasher.h" #include #include @@ -80,6 +81,26 @@ AbstractClient::~AbstractClient() { } +Command_AccountPassword AbstractClient::makeAccountPasswordCommand(const QString &oldPassword, + const QString &newPassword) const +{ + Command_AccountPassword cmd; + cmd.set_old_password(oldPassword.toStdString()); + if (getServerSupportsChallengeResponse()) { + // A verifier lets the server upgrade the stored credential without ever seeing the + // password, and it is what a challenge-response server expects for a new password. + cmd.set_hashed_new_password(PasswordHasher::generatePasswordVerifier(newPassword).toStdString()); + } else if (getServerSupportsPasswordHash()) { + // The server hashes the password itself with its own salt; a salted hash is the + // strongest form it accepts, so the plaintext never goes on the wire. + const QString passwordSalt = PasswordHasher::generateRandomSalt(); + cmd.set_hashed_new_password(PasswordHasher::computeHash(newPassword, passwordSalt).toStdString()); + } else { + cmd.set_new_password(newPassword.toStdString()); + } + return cmd; +} + void AbstractClient::processProtocolItem(const ServerMessage &item) { switch (item.message_type()) { diff --git a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.h b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.h index 7efd67972..005587ccc 100644 --- a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.h +++ b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.h @@ -47,6 +47,7 @@ class Event_UserJoined; class Event_UserLeft; class Event_ServerMessage; class Event_ListRooms; +class Command_AccountPassword; class Event_GameJoined; class Event_UserMessage; class Event_NotifyUser; @@ -67,6 +68,9 @@ enum ClientStatus StatusSubmitForgotPasswordReset, StatusSubmitForgotPasswordChallenge, StatusGettingPasswordSalt, + //! The server accepted the credentials but keeps the session open until the account + //! password is replaced; the connection must survive for `Command_AccountPassword`. + StatusPasswordChangeRequired, }; class AbstractClient : public QObject @@ -171,6 +175,16 @@ public: { return serverSupportsChallengeResponse; } + + /** + * @brief Builds the account-password command in the form this server understands. + * + * Challenge-response servers take a scrypt verifier; servers that only advertise + * SupportsPasswordHash take a salted SHA-512 hash; anything older gets the plaintext, + * which is the only form it understands. Both password-change paths go through here so + * the two cannot drift apart and hand a plaintext password to a server that can hash it. + */ + Command_AccountPassword makeAccountPasswordCommand(const QString &oldPassword, const QString &newPassword) const; const QString &getUserName() const { return userName; diff --git a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp index 7eab58668..faacdc9f7 100644 --- a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp +++ b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp @@ -125,6 +125,7 @@ RemoteClient::RemoteClient(QObject *parent, INetworkSettingsProvider *_networkSe &RemoteClient::doSubmitForgotPasswordResetToServer); connect(this, &RemoteClient::sigSubmitForgotPasswordChallengeToServer, this, &RemoteClient::doSubmitForgotPasswordChallengeToServer); + connect(this, &RemoteClient::sigChangePasswordToServer, this, &RemoteClient::doChangePasswordToServer); } RemoteClient::~RemoteClient() @@ -354,6 +355,29 @@ void RemoteClient::submitPasswordVerifierResponse(const Response &response) } } +void RemoteClient::changePasswordToServer(const QString &oldPassword, const QString &newPassword) +{ + emit sigChangePasswordToServer(oldPassword, newPassword); +} + +void RemoteClient::doChangePasswordToServer(const QString &oldPassword, const QString &newPassword) +{ + const Command_AccountPassword cmd = makeAccountPasswordCommand(oldPassword, newPassword); + + PendingCommand *pend = prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &RemoteClient::accountPasswordResponse); + sendCommand(pend); +} + +void RemoteClient::accountPasswordResponse(const Response &response) +{ + if (response.response_code() == Response::RespOk) { + emit sigPasswordChangeSuccess(); + } else { + emit sigPasswordChangeError(response.response_code()); + } +} + void RemoteClient::processConnectionClosedEvent(const Event_ConnectionClosed & /*event*/) { doDisconnectFromServer(); @@ -475,6 +499,23 @@ void RemoteClient::loginResponse(const Response &response) emit notifyUserAboutUpdate(); } + } else if (response.response_code() == Response::RespPasswordChangeRequired) { + // The credentials were accepted: the server keeps this session open and serves + // nothing but ping and Command_AccountPassword until the password is replaced. + // Treating it as a failed login would disconnect before the dialog that collects + // the new password (queued to the UI thread behind this slot) can send anything: + // the command would sit in pendingCommands forever, because the ping timer that + // times it out stops with the connection. Keep the session and let the controller + // report the outcome through accountPasswordResponse(). + QList missingFeatures; + if (resp.missing_features_size() > 0) { + for (int i = 0; i < resp.missing_features_size(); ++i) { + missingFeatures << QString::fromStdString(resp.missing_features(i)); + } + } + setStatus(StatusPasswordChangeRequired); + emit loginError(response.response_code(), QString::fromStdString(resp.denied_reason_str()), + static_cast(resp.denied_end_time()), missingFeatures); } else if (response.response_code() != Response::RespNotConnected) { QList missingFeatures; if (resp.missing_features_size() > 0) { diff --git a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.h b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.h index d0c0a18ce..1cf40491c 100644 --- a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.h +++ b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.h @@ -71,6 +71,11 @@ signals: //! should only store it for the connection it is currently negotiating, so //! the hostname and user name are intentionally not part of the signal. void sigPasswordVerifierReady(const QString &verifier); + //! \brief Outcome of the password change the server demanded at login. + void sigPasswordChangeSuccess(); + void sigPasswordChangeError(int responseCode); + //! \brief Request a password change; queued to the client thread like the other requests. + void sigChangePasswordToServer(const QString &oldPassword, const QString &newPassword); private slots: void slotConnected(); void readData(); @@ -99,6 +104,8 @@ private slots: Command_Login generateCommandLogin(); void doSubmitPasswordVerifier(); void submitPasswordVerifierResponse(const Response &response); + void accountPasswordResponse(const Response &response); + void doChangePasswordToServer(const QString &oldPassword, const QString &newPassword); void doDisconnectFromServer(); void doActivateToServer(const QString &_token); void doRequestForgotPasswordToServer(const QString &hostname, unsigned int port, const QString &_userName); @@ -201,6 +208,10 @@ public: unsigned int port, const QString &_userName, const QString &_email); + //! \brief Change the password of the account that just logged in, following the + //! server's forced-password-change reply. Emits sigPasswordChangeSuccess or + //! sigPasswordChangeError when the server answers. + void changePasswordToServer(const QString &oldPassword, const QString &newPassword); }; #endif diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server.cpp index 63989bbfa..8ea2cd993 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server.cpp @@ -131,6 +131,16 @@ AuthenticationResult Server::loginUser(Server_ProtocolHandler *session, return authState; } + if (authState == PasswordChangeRequired) { + // The credentials were right, but the account must choose a new password before it may + // use the server. Give the session an identity so it can complete that change, without + // registering a user session or announcing the user as logged in. + ServerInfo_User pendingData = databaseInterface->getUserData(name, true); + pendingData.set_address(session->getAddress().toStdString()); + session->setUserInfo(pendingData); + return authState; + } + ServerInfo_User data = databaseInterface->getUserData(name, true); data.set_address(session->getAddress().toStdString()); name = QString::fromStdString(data.name()); // Compensate for case indifference @@ -296,7 +306,9 @@ void Server::removeClient(Server_ProtocolHandler *client) QWriteLocker locker(&clientsLock); clients.removeAt(clientIndex); ServerInfo_User *data = client->getUserInfo(); - if (data) { + // A session that never completed login (for example one that still owes a password change) + // has no session id: it was never announced as joined, so it must not be announced as leaving. + if (data && data->has_session_id()) { Event_UserLeft event; event.set_name(data->name()); SessionEvent *se = Server_ProtocolHandler::prepareSessionEvent(event); @@ -311,12 +323,10 @@ void Server::removeClient(Server_ProtocolHandler *client) users.remove(QString::fromStdString(data->name())); qDebug() << "Server::removeClient: name=" << QString::fromStdString(data->name()); - if (data->has_session_id()) { - const qint64 sessionId = data->session_id(); - usersBySessionId.remove(sessionId); - emit endSession(sessionId); - qDebug() << "closed session id:" << sessionId; - } + const qint64 sessionId = data->session_id(); + usersBySessionId.remove(sessionId); + emit endSession(sessionId); + qDebug() << "closed session id:" << sessionId; } qDebug() << "Server::removeClient: removed" << (void *)client << ";" << clients.size() << "clients; " << users.size() << "users left"; @@ -330,7 +340,9 @@ QList Server::getOnlineModeratorList() const ServerInfo_User *data = client->getUserInfo(); //! \todo This line should be updated in the event there is any type of new user level created. - if (data && + // A session that still owes a forced password change has user info but no session id: + // it never joined, so it must not be reported as an online moderator either. + if (data && data->has_session_id() && (data->user_level() & ServerInfo_User::IsModerator || data->user_level() & ServerInfo_User::IsAdmin)) { results << QString::fromStdString(data->name()).simplified(); } diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp index dd074a517..39c1eb3e6 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp @@ -64,8 +64,9 @@ Server_ProtocolHandler::Server_ProtocolHandler(Server *_server, Server_DatabaseInterface *_databaseInterface, QObject *parent) : QObject(parent), Server_AbstractUserInterface(_server), deleted(false), databaseInterface(_databaseInterface), - authState(NotLoggedIn), usingRealPassword(false), acceptsUserListChanges(false), acceptsRoomListChanges(false), - idleClientWarningSent(false), timeRunning(0), lastDataReceived(0), lastActionReceived(0) + authState(NotLoggedIn), usingRealPassword(false), passwordChangePending(false), acceptsUserListChanges(false), + acceptsRoomListChanges(false), idleClientWarningSent(false), timeRunning(0), lastDataReceived(0), + lastActionReceived(0) { connect(server, &Server::pingClockTimeout, this, &Server_ProtocolHandler::pingClockTimeout); @@ -212,7 +213,10 @@ Response::ResponseCode Server_ProtocolHandler::processSessionCommandContainer(co logDebugMessage(getSafeDebugString(sc)); } const auto commandType = static_cast(num); - if (authState == NotLoggedIn && !isPreAuthSessionCommand(commandType)) { + if (passwordChangePending && commandType != SessionCommand::PING && + commandType != SessionCommand::ACCOUNT_PASSWORD) { + resp = Response::RespFunctionNotAllowed; + } else if (authState == NotLoggedIn && !isPreAuthSessionCommand(commandType)) { resp = Response::RespLoginNeeded; } else { switch (commandType) { @@ -489,6 +493,19 @@ void Server_ProtocolHandler::processCommandContainer(const CommandContainer &con lastDataReceived = timeRunning; + // A session that still owes a forced password change may only reach the session family, + // where processSessionCommandContainer narrows it further to ping and the change itself. + // Every other family is rejected here, including a container that also carries a session + // command: the dispatch below prefers game/room over session, so testing only for the + // absence of a session command would let such a container through. + if (passwordChangePending && + (cont.game_command_size() || cont.room_command_size() || cont.moderator_command_size() || + cont.admin_command_size() || cont.developer_command_size())) { + sendResponseContainer(ResponseContainer(cont.has_cmd_id() ? cont.cmd_id() : -1), + Response::RespFunctionNotAllowed); + return; + } + ResponseContainer responseContainer(cont.has_cmd_id() ? cont.cmd_id() : -1); Response::ResponseCode finalResponseCode; @@ -696,6 +713,11 @@ Response::ResponseCode Server_ProtocolHandler::cmdLogin(const Command_Login &cmd server->recordFailedLogin(getAddress(), userName); return Response::RespAccountNotActivated; case PasswordChangeRequired: + // The credentials were right, but the account must choose a new password first. + // Keep the session authenticated just far enough for the client to do that: + // processCommandContainer refuses everything except the change itself. + authState = PasswordRight; + passwordChangePending = true; return Response::RespPasswordChangeRequired; default: server->clearFailedLogins(getAddress(), userName); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h index 266c4b26c..8ce110354 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h @@ -46,6 +46,12 @@ protected: Server_DatabaseInterface *databaseInterface; AuthenticationResult authState; bool usingRealPassword; + /** + * @brief True when the credentials were right but the account must choose a new password + * before it may use the server. Only ping, the password change and disconnecting + * are served while this is set. + */ + bool passwordChangePending; bool acceptsUserListChanges; bool acceptsRoomListChanges; bool idleClientWarningSent; diff --git a/servatrice/src/servatrice_database_interface.cpp b/servatrice/src/servatrice_database_interface.cpp index 1b49c3a82..45ae9bde8 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -482,6 +482,10 @@ AuthenticationResult Servatrice_DatabaseInterface::checkUserPassword(Server_Prot const QByteArray expected = PasswordHasher::computeResponse(key, nonce); handler->clearAuthNonce(); if (PasswordHasher::constantTimeEquals(expected, response)) { + if (forceChange) { + qCDebug(DatabaseInterfaceLog) << "Login accepted but password change required"; + return PasswordChangeRequired; + } qCDebug(DatabaseInterfaceLog) << "Login accepted: challenge-response password right"; return PasswordRight; } diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index 963b98c9c..f0c0c3536 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -3787,6 +3787,9 @@ Response::ResponseCode AbstractServerSocketInterface::cmdAccountPassword(const C } databaseInterface->setForcePasswordChange(userName, false); + // The forced change is done, but this session never completed login (see + // Server_ProtocolHandler::cmdLogin). It stays restricted to ping and another password + // change; the client reconnects with the new password to actually enter the server. return Response::RespOk; } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index c8d3fbd59..00b39193d 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -328,6 +328,28 @@ cockatrice_add_test( LABELS server ) +cockatrice_add_test( + NAME + server_forced_password_change_test + SOURCES + server_forced_password_change_test.cpp + LIBS + libcockatrice_network_server_remote + libcockatrice_rng + LABELS + server +) +cockatrice_add_test( + NAME + client_login_status_test + SOURCES + ${VERSION_STRING_CPP} + client_login_status_test.cpp + LIBS + libcockatrice_network_client_remote + LABELS + ci +) cockatrice_add_test( NAME server_room_channels_test diff --git a/tests/client_login_status_test.cpp b/tests/client_login_status_test.cpp new file mode 100644 index 000000000..d8ea63550 --- /dev/null +++ b/tests/client_login_status_test.cpp @@ -0,0 +1,116 @@ +/** @file client_login_status_test.cpp + * @brief The login response state machine must keep the session a forced password change needs. + * @ingroup Tests + */ + +#include "abstract_client.h" +#include "libcockatrice/interfaces/interface_network_settings_provider.h" +#include "libcockatrice/protocol/pb/response.pb.h" +#include "libcockatrice/protocol/pb/response_login.pb.h" +#include "remote_client.h" + +#include +#include +#include +#include + +namespace +{ + +class StubNetworkSettingsProvider : public INetworkSettingsProvider +{ +public: + QString getClientID() override + { + return {}; + } + [[nodiscard]] int getTimeOut() const override + { + return 1000; + } + [[nodiscard]] int getKeepAlive() const override + { + return 60; + } + [[nodiscard]] bool getNotifyAboutUpdates() const override + { + return false; + } + void setKnownMissingFeatures(const QString &) override + { + } + QString getKnownMissingFeatures() override + { + return {}; + } +}; + +/** + * @brief Feeds a login response to RemoteClient without a socket. + * + * `loginResponse` is the slot the socket thread runs for the login command's answer, so + * invoking it directly exercises the status transition that decides whether `readData()` + * tears the connection down right after it. + */ +class ClientLoginStatusTest : public ::testing::Test +{ +protected: + void SetUp() override + { + client = new RemoteClient(nullptr, &provider); + } + + void TearDown() override + { + delete client; + client = nullptr; + } + + void deliverLoginResponse(Response::ResponseCode code) + { + Response response; + response.set_response_code(code); + response.MutableExtension(Response_Login::ext); + ASSERT_TRUE( + QMetaObject::invokeMethod(client, "loginResponse", Qt::DirectConnection, Q_ARG(Response, response))); + } + + StubNetworkSettingsProvider provider; + RemoteClient *client = nullptr; +}; + +/** + * @brief A required password change is not a failed login. + * + * The server keeps the session open and accepts only ping and Command_AccountPassword until + * the password is replaced. StatusDisconnecting would make readData() close the socket right + * after this slot returns - before the dialog that collects the new password (queued to the + * UI thread) can send anything - and the pending command would never be answered because the + * ping timer that times it out dies with the connection. StatusLoggedIn would start the + * normal post-login flow for a session that cannot serve it. + */ +TEST_F(ClientLoginStatusTest, PasswordChangeRequiredKeepsTheSessionOpen) +{ + deliverLoginResponse(Response::RespPasswordChangeRequired); + + EXPECT_EQ(client->getStatus(), StatusPasswordChangeRequired); + EXPECT_NE(client->getStatus(), StatusDisconnecting); + EXPECT_NE(client->getStatus(), StatusLoggedIn); +} + +/** @brief Every other login error still ends the connection, as it did before. */ +TEST_F(ClientLoginStatusTest, OtherLoginErrorsDisconnect) +{ + deliverLoginResponse(Response::RespWrongPassword); + + EXPECT_EQ(client->getStatus(), StatusDisconnecting); +} + +} // namespace + +int main(int argc, char **argv) +{ + QCoreApplication app(argc, argv); + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/server_forced_password_change_test.cpp b/tests/server_forced_password_change_test.cpp new file mode 100644 index 000000000..9717b610b --- /dev/null +++ b/tests/server_forced_password_change_test.cpp @@ -0,0 +1,234 @@ +/** @file server_forced_password_change_test.cpp + * @brief Tests for the restricted session a forced password change leaves behind at login. + * @ingroup Tests + */ + +#include "libcockatrice/protocol/pb/response.pb.h" +#include "libcockatrice/protocol/pb/server_message.pb.h" +#include "server.h" +#include "server_database_interface.h" +#include "server_protocolhandler.h" + +#include +#include +#include +#include +#include +#include +#include + +RNG_Abstract *rng = nullptr; // referenced by the server_remote library + +namespace +{ + +class MockDatabaseInterface : public Server_DatabaseInterface +{ +public: + AuthenticationResult checkUserPassword(Server_ProtocolHandler *, + const QString &, + const QString &, + const QString &, + QString &, + int &, + bool) override + { + return PasswordChangeRequired; + } + ServerInfo_User getUserData(const QString &name, bool withId) override + { + ServerInfo_User user; + user.set_name(name.toStdString()); + user.set_user_level(ServerInfo_User::IsUser | ServerInfo_User::IsRegistered | ServerInfo_User::IsModerator); + if (withId) { + user.set_id(1); + } + return user; + } + int getNextReplayId() override + { + return 1; + } + int getNextGameId() override + { + return 1; + } + int getActiveUserCount(QString) override + { + return 0; + } +}; + +class FakeServer : public Server +{ +public: + FakeServer() + { + setDatabaseInterface(new MockDatabaseInterface()); + } +}; + +class TestHandler : public Server_ProtocolHandler +{ +public: + TestHandler(Server *_server, Server_DatabaseInterface *_db) : Server_ProtocolHandler(_server, _db) + { + } + + QString getAddress() const override + { + return {}; + } + QString getConnectionType() const override + { + return {}; + } + + Response::ResponseCode lastResponseCode = Response::RespNothing; + int lastExtendedSessionCommand = -1; + +protected: + Response::ResponseCode + processExtendedSessionCommand(int cmdType, const SessionCommand & /*cmd*/, ResponseContainer & /*rc*/) override + { + lastExtendedSessionCommand = cmdType; + return Response::RespOk; + } + + void transmitProtocolItem(const ServerMessage &item) override + { + if (item.message_type() == ServerMessage::RESPONSE) { + lastResponseCode = item.response().response_code(); + } + } +}; + +class ForcedPasswordChangeTest : public ::testing::Test +{ +protected: + FakeServer server; + TestHandler handler{&server, server.getDatabaseInterface()}; + + void login() + { + CommandContainer cont; + cont.set_cmd_id(1); + Command_Login cmd; + cmd.set_user_name("mod"); + cont.add_session_command()->MutableExtension(Command_Login::ext)->CopyFrom(cmd); + handler.processCommandContainer(cont); + } + + Response::ResponseCode sendPing() + { + CommandContainer cont; + cont.set_cmd_id(2); + cont.add_session_command()->MutableExtension(Command_Ping::ext); + handler.processCommandContainer(cont); + return handler.lastResponseCode; + } + + Response::ResponseCode sendSessionCommand() + { + CommandContainer cont; + cont.set_cmd_id(3); + cont.add_session_command()->MutableExtension(Command_ListRooms::ext); + handler.processCommandContainer(cont); + return handler.lastResponseCode; + } + + Response::ResponseCode sendRoomCommand() + { + CommandContainer cont; + cont.set_cmd_id(4); + cont.add_room_command()->MutableExtension(Command_RoomSay::ext)->set_message("hello"); + handler.processCommandContainer(cont); + return handler.lastResponseCode; + } + + Response::ResponseCode sendMixedSessionAndRoomCommand() + { + CommandContainer cont; + cont.set_cmd_id(5); + cont.add_session_command()->MutableExtension(Command_Ping::ext); + cont.add_room_command()->MutableExtension(Command_RoomSay::ext)->set_message("hello"); + handler.processCommandContainer(cont); + return handler.lastResponseCode; + } + + Response::ResponseCode sendAccountPassword() + { + CommandContainer cont; + cont.set_cmd_id(6); + Command_AccountPassword cmd; + cmd.set_old_password("temporary-password"); + cmd.set_new_password("replacement-password"); + cont.add_session_command()->MutableExtension(Command_AccountPassword::ext)->CopyFrom(cmd); + handler.processCommandContainer(cont); + return handler.lastResponseCode; + } +}; + +TEST_F(ForcedPasswordChangeTest, LoginReportsTheRequiredChange) +{ + login(); + + EXPECT_EQ(Response::RespPasswordChangeRequired, handler.lastResponseCode); + ASSERT_NE(nullptr, handler.getUserInfo()); + EXPECT_EQ("mod", QString::fromStdString(handler.getUserInfo()->name())); +} + +TEST_F(ForcedPasswordChangeTest, PendingSessionIsNotRegisteredAsAUser) +{ + login(); + // The session may change its password, but it must not take a user slot or appear in + // the session table before the client reconnects with the new password. + EXPECT_FALSE(handler.getUserInfo()->has_session_id()); + EXPECT_EQ(0, server.getUsersCount()); +} + +TEST_F(ForcedPasswordChangeTest, PendingModeratorIsNotReportedOnline) +{ + login(); + server.addClient(&handler); + + EXPECT_TRUE(server.getOnlineModeratorList().isEmpty()); +} + +TEST_F(ForcedPasswordChangeTest, OnlyPingAndPasswordChangeAreServed) +{ + login(); + + EXPECT_EQ(Response::RespOk, sendPing()); + EXPECT_EQ(Response::RespFunctionNotAllowed, sendSessionCommand()); + EXPECT_EQ(Response::RespFunctionNotAllowed, sendRoomCommand()); + // Dispatch prefers game/room over session, so a container carrying both must not slip past + // the restriction by carrying a session command. + EXPECT_EQ(Response::RespFunctionNotAllowed, sendMixedSessionAndRoomCommand()); +} + +TEST_F(ForcedPasswordChangeTest, AccountPasswordReachesTheCommand) +{ + login(); + // The forced change is useless if the gate refuses the command it needs. ACCOUNT_PASSWORD is + // served by the extended-command hook, so it must arrive there instead of being filtered. + EXPECT_EQ(Response::RespOk, sendAccountPassword()); + EXPECT_EQ(SessionCommand::ACCOUNT_PASSWORD, handler.lastExtendedSessionCommand); +} + +TEST_F(ForcedPasswordChangeTest, CompletedLoginIsStillReportedOnline) +{ + TestHandler loggedIn{&server, server.getDatabaseInterface()}; + ServerInfo_User user; + user.set_name("online-mod"); + user.set_user_level(ServerInfo_User::IsUser | ServerInfo_User::IsRegistered | ServerInfo_User::IsModerator); + user.set_session_id(7); + loggedIn.setUserInfo(user); + server.addClient(&loggedIn); + + const QList moderators = server.getOnlineModeratorList(); + ASSERT_EQ(1, moderators.size()); + EXPECT_EQ("online-mod", moderators.first()); +} + +} // namespace From 5fe7f186d39ddd0db7210f7f5bf48ea2e6021162 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 2 Nov 2026 07:01:55 +0100 Subject: [PATCH 085/108] [CI] Make the hdiutil retry wrapper report failure (#7643) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [CI] Make the hdiutil retry wrapper report failure CPack reads the wrapper's exit status to decide whether the disk image was created, so a zero exit turns a failed hdiutil into a later "Problem copying the package" error that hides hdiutil's own message. - Propagate hdiutil's real status instead of collapsing every failure to 1. - Name the failing subcommand (create/attach/detach/convert) so the message says which CPack step gave up. - Count attempts rather than failures in the retry line. * Update .ci/compile.sh Co-authored-by: tooomm --------- Co-authored-by: Lukas Brübach Co-authored-by: tooomm --- .ci/compile.sh | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/.ci/compile.sh b/.ci/compile.sh index bf5a169af..9be8ba741 100755 --- a/.ci/compile.sh +++ b/.ci/compile.sh @@ -311,16 +311,24 @@ if [[ $RUNNER_OS == macOS ]]; then if [[ $MAKE_PACKAGE ]]; then # Workaround https://github.com/actions/runner-images/issues/7522 - # have hdiutil repeat the command 10 times in hope of success + # have hdiutil repeat the command 10 times in hope of success. + # The wrapper has to exit non-zero once it gives up: CPack reads that status to + # decide whether the disk image was created, so a zero exit turns a failed hdiutil + # into a much later, misleading "Problem copying the package" error that hides + # hdiutil's own message. hdiutil_script="/tmp/hdiutil.sh" # shellcheck disable=SC2016 echo '#!/bin/bash + attempts=10 i=0 - while ! hdiutil "$@"; do - if (( ++i >= 10 )); then - echo "Error: hdiutil failed $i times!" >&2 - break + while true; do + hdiutil "$@" && exit 0 + rc=$? + if (( ++i >= attempts )); then + echo "::error::hdiutil $1 failed $i times (last status $rc), giving up" >&2 + exit "$rc" fi + echo "hdiutil $1 attempt $i/$attempts failed (status $rc), retrying" >&2 sleep 1 done' >"$hdiutil_script" chmod +x "$hdiutil_script" From a74dfeef6ec30c29e9f6550fe43cf1ed24cb58a9 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 2 Nov 2026 07:06:47 +0100 Subject: [PATCH 086/108] [Build] Configure AUTOMOC, AUTOUIC and AUTORCC once (#7637) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The root already set CMAKE_AUTOMOC; add CMAKE_AUTOUIC and CMAKE_AUTORCC next to it and drop the per-directory copies from 18 CMakeLists files. These variables are inherited by the subdirectories, so the duplicates never changed anything. qt_standard_project_setup is not used: besides the AUTO* variables it sets CMAKE_RUNTIME_OUTPUT_DIRECTORY on Windows, which breaks the install(DIRECTORY "$") copies and the per-app symbol paths, and appends to CMAKE_INSTALL_RPATH on Linux. Co-authored-by: Lukas Brübach --- CMakeLists.txt | 6 ++++++ libcockatrice_card/CMakeLists.txt | 4 ---- libcockatrice_deck_list/CMakeLists.txt | 4 ---- libcockatrice_filters/CMakeLists.txt | 4 ---- libcockatrice_game/CMakeLists.txt | 4 ---- libcockatrice_interfaces/CMakeLists.txt | 4 ---- libcockatrice_models/CMakeLists.txt | 4 ---- .../libcockatrice/models/database/CMakeLists.txt | 4 ---- .../libcockatrice/models/deck_list/CMakeLists.txt | 4 ---- libcockatrice_network/CMakeLists.txt | 4 ---- .../libcockatrice/network/client/CMakeLists.txt | 4 ---- .../libcockatrice/network/client/abstract/CMakeLists.txt | 4 ---- .../libcockatrice/network/client/local/CMakeLists.txt | 4 ---- .../libcockatrice/network/client/remote/CMakeLists.txt | 4 ---- .../libcockatrice/network/server/CMakeLists.txt | 4 ---- .../libcockatrice/network/server/local/CMakeLists.txt | 4 ---- .../libcockatrice/network/server/remote/CMakeLists.txt | 4 ---- libcockatrice_rng/CMakeLists.txt | 4 ---- libcockatrice_settings/CMakeLists.txt | 4 ---- libcockatrice_utility/CMakeLists.txt | 4 ---- 20 files changed, 6 insertions(+), 76 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index d9acabc5e..b00e927a4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -259,7 +259,13 @@ else() add_compile_definitions(QT_DISABLE_DEPRECATED_BEFORE=0x060400) endif() +# Set once here for every target: these variables are inherited by the +# subdirectories, so the per-directory copies were redundant. Only the +# variables are set; qt_standard_project_setup would also change the runtime +# output directories and the install RPATH. set(CMAKE_AUTOMOC TRUE) +set(CMAKE_AUTOUIC TRUE) +set(CMAKE_AUTORCC TRUE) # Find other needed libraries find_package(Protobuf CONFIG) diff --git a/libcockatrice_card/CMakeLists.txt b/libcockatrice_card/CMakeLists.txt index e2d5029ed..d2e6ee83a 100644 --- a/libcockatrice_card/CMakeLists.txt +++ b/libcockatrice_card/CMakeLists.txt @@ -1,7 +1,3 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - set(HEADERS libcockatrice/card/card_info.h libcockatrice/card/card_info_comparator.h diff --git a/libcockatrice_deck_list/CMakeLists.txt b/libcockatrice_deck_list/CMakeLists.txt index ffd4c28a1..3e7d522c2 100644 --- a/libcockatrice_deck_list/CMakeLists.txt +++ b/libcockatrice_deck_list/CMakeLists.txt @@ -1,7 +1,3 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - set(HEADERS libcockatrice/deck_list/tree/abstract_deck_list_card_node.h libcockatrice/deck_list/tree/abstract_deck_list_node.h diff --git a/libcockatrice_filters/CMakeLists.txt b/libcockatrice_filters/CMakeLists.txt index df2ead83f..070c04637 100644 --- a/libcockatrice_filters/CMakeLists.txt +++ b/libcockatrice_filters/CMakeLists.txt @@ -1,7 +1,3 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - set(HEADERS libcockatrice/filters/filter_card.h libcockatrice/filters/filter_string.h libcockatrice/filters/filter_tree.h ) diff --git a/libcockatrice_game/CMakeLists.txt b/libcockatrice_game/CMakeLists.txt index e7d362ca2..ce68c5ea6 100644 --- a/libcockatrice_game/CMakeLists.txt +++ b/libcockatrice_game/CMakeLists.txt @@ -1,7 +1,3 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - set(HEADERS libcockatrice/game/abstract_game.h libcockatrice/game/board/arrow_data.h diff --git a/libcockatrice_interfaces/CMakeLists.txt b/libcockatrice_interfaces/CMakeLists.txt index 16ea686b5..69ed13b0e 100644 --- a/libcockatrice_interfaces/CMakeLists.txt +++ b/libcockatrice_interfaces/CMakeLists.txt @@ -1,7 +1,3 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - set(HEADERS libcockatrice/interfaces/interface_cache_storage_settings_provider.h libcockatrice/interfaces/interface_card_database_path_provider.h diff --git a/libcockatrice_models/CMakeLists.txt b/libcockatrice_models/CMakeLists.txt index 7af35b2d5..5186eff13 100644 --- a/libcockatrice_models/CMakeLists.txt +++ b/libcockatrice_models/CMakeLists.txt @@ -1,7 +1,3 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - add_subdirectory(libcockatrice/models/database) add_subdirectory(libcockatrice/models/deck_list) diff --git a/libcockatrice_models/libcockatrice/models/database/CMakeLists.txt b/libcockatrice_models/libcockatrice/models/database/CMakeLists.txt index 2471adacf..5cf3caa52 100644 --- a/libcockatrice_models/libcockatrice/models/database/CMakeLists.txt +++ b/libcockatrice_models/libcockatrice/models/database/CMakeLists.txt @@ -1,7 +1,3 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - set(HEADERS card_database_model.h card_database_display_model.h diff --git a/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt b/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt index b46275437..ce5c4d54b 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt +++ b/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt @@ -1,7 +1,3 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - set(HEADERS deck_list_model.h deck_list_sort_filter_proxy_model.h) add_library( diff --git a/libcockatrice_network/CMakeLists.txt b/libcockatrice_network/CMakeLists.txt index 3069c0db4..925e4d445 100644 --- a/libcockatrice_network/CMakeLists.txt +++ b/libcockatrice_network/CMakeLists.txt @@ -1,7 +1,3 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - add_subdirectory(libcockatrice/network/client) add_subdirectory(libcockatrice/network/server) diff --git a/libcockatrice_network/libcockatrice/network/client/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/client/CMakeLists.txt index bcf62463c..cf8d9fc4d 100644 --- a/libcockatrice_network/libcockatrice/network/client/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/client/CMakeLists.txt @@ -1,7 +1,3 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - add_subdirectory(abstract) add_subdirectory(local) add_subdirectory(remote) diff --git a/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt index 791db6348..33812e0bf 100644 --- a/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt @@ -1,7 +1,3 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - set(HEADERS abstract_client.h latency_tracker.h) set(SOURCES abstract_client.cpp latency_tracker.cpp) diff --git a/libcockatrice_network/libcockatrice/network/client/local/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/client/local/CMakeLists.txt index 4d5676fc0..3eff7943a 100644 --- a/libcockatrice_network/libcockatrice/network/client/local/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/client/local/CMakeLists.txt @@ -1,7 +1,3 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - set(HEADERS local_client.h) set(SOURCES local_client.cpp) diff --git a/libcockatrice_network/libcockatrice/network/client/remote/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/client/remote/CMakeLists.txt index ecc822b7c..006c18d42 100644 --- a/libcockatrice_network/libcockatrice/network/client/remote/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/client/remote/CMakeLists.txt @@ -1,7 +1,3 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - set(HEADERS remote_client.h) set(SOURCES remote_client.cpp) diff --git a/libcockatrice_network/libcockatrice/network/server/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/server/CMakeLists.txt index cbb717ad8..b59d61572 100644 --- a/libcockatrice_network/libcockatrice/network/server/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/server/CMakeLists.txt @@ -1,7 +1,3 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - add_subdirectory(local) add_subdirectory(remote) diff --git a/libcockatrice_network/libcockatrice/network/server/local/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/server/local/CMakeLists.txt index c5f8ee432..4055efa64 100644 --- a/libcockatrice_network/libcockatrice/network/server/local/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/server/local/CMakeLists.txt @@ -1,7 +1,3 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - set(HEADERS local_server.h local_server_interface.h) set(SOURCES local_server.cpp local_server_interface.cpp) diff --git a/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt index 26c2be69a..8586a7bc8 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt @@ -1,7 +1,3 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - set(HEADERS game/dump_zone_range.h game/server_abstract_participant.h diff --git a/libcockatrice_rng/CMakeLists.txt b/libcockatrice_rng/CMakeLists.txt index c5b19ae85..77165122c 100644 --- a/libcockatrice_rng/CMakeLists.txt +++ b/libcockatrice_rng/CMakeLists.txt @@ -1,7 +1,3 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - set(HEADERS libcockatrice/rng/rng_abstract.h libcockatrice/rng/rng_sfmt.h libcockatrice/rng/sfmt/SFMT.h) add_library( diff --git a/libcockatrice_settings/CMakeLists.txt b/libcockatrice_settings/CMakeLists.txt index d23fb3350..a3b9a68db 100644 --- a/libcockatrice_settings/CMakeLists.txt +++ b/libcockatrice_settings/CMakeLists.txt @@ -1,7 +1,3 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - set(HEADERS libcockatrice/settings/appearance_settings.h libcockatrice/settings/cache_storage_settings.h diff --git a/libcockatrice_utility/CMakeLists.txt b/libcockatrice_utility/CMakeLists.txt index 55f1daf24..8d2cb5b92 100644 --- a/libcockatrice_utility/CMakeLists.txt +++ b/libcockatrice_utility/CMakeLists.txt @@ -1,10 +1,6 @@ cmake_minimum_required(VERSION 3.16) project(Utility VERSION "${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}.${PROJECT_VERSION_PATCH}") -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - set(UTILITY_SOURCES libcockatrice/utility/cryptoutil.cpp libcockatrice/utility/expression.cpp From 429eb2a1fd68de89714db224ac2af4f7ea3c04dc Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 2 Nov 2026 07:13:21 +0100 Subject: [PATCH 087/108] [Servatrice] Use compile-time checked connections (#7631) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Servatrice] Use compile-time checked connections Replace the remaining SIGNAL/SLOT string connects in servatrice.cpp, smtpclient.cpp, serversocketinterface.cpp and isl_interface.cpp with pointer-to-member or lambda connects, and convert the string invokeMethod calls to the functor overload. QxtSmtp has overloaded signals, so those need qOverload. Slots inherited as protected members and the QWebSocket error connection owned by the ISL fix are left on lambdas/strings as appropriate; the vendored qxtsmtp.cpp is untouched. * Fix remaining review threads on the connection syntax - Connect the IslInterface signals straight to the inherited Server slots instead of forwarding lambdas. - Capture servatrice explicitly in the queued admin lambdas so they do not read this->servatrice after teardown. - Move the websocket newConnection connect out of the per-pool loop. - Terminate the logging category and restore pendingKey's indentation. --------- Co-authored-by: Lukas Brübach --- servatrice/src/isl_interface.cpp | 4 +- servatrice/src/servatrice.cpp | 79 ++++++++++++------------ servatrice/src/serversocketinterface.cpp | 31 +++++----- servatrice/src/smtpclient.cpp | 35 +++++------ 4 files changed, 76 insertions(+), 73 deletions(-) diff --git a/servatrice/src/isl_interface.cpp b/servatrice/src/isl_interface.cpp index 8635808ac..1898cd5fa 100644 --- a/servatrice/src/isl_interface.cpp +++ b/servatrice/src/isl_interface.cpp @@ -58,9 +58,9 @@ void IslInterface::sharedCtor(const QSslCertificate &cert, const QSslKey &privat socket->setLocalCertificate(cert); socket->setPrivateKey(privateKey); - connect(socket, SIGNAL(readyRead()), this, SLOT(readClient()), Qt::QueuedConnection); + connect(socket, &QSslSocket::readyRead, this, &IslInterface::readClient, Qt::QueuedConnection); connect(socket, &QAbstractSocket::errorOccurred, this, &IslInterface::catchSocketError); - connect(this, SIGNAL(outputBufferChanged()), this, SLOT(flushOutputBuffer()), Qt::QueuedConnection); + connect(this, &IslInterface::outputBufferChanged, this, &IslInterface::flushOutputBuffer, Qt::QueuedConnection); } IslInterface::IslInterface(int _socketDescriptor, diff --git a/servatrice/src/servatrice.cpp b/servatrice/src/servatrice.cpp index 49d8e1e4c..afe028f32 100644 --- a/servatrice/src/servatrice.cpp +++ b/servatrice/src/servatrice.cpp @@ -100,8 +100,10 @@ Servatrice_GameServer::Servatrice_GameServer(Servatrice *_server, newThread->start(); server->watchWorkerThread(newThread); - QMetaObject::invokeMethod(newDatabaseInterface, "initDatabase", Qt::BlockingQueuedConnection, - Q_ARG(QSqlDatabase, _sqlDatabase)); + QMetaObject::invokeMethod( + newDatabaseInterface, + [newDatabaseInterface, _sqlDatabase] { newDatabaseInterface->initDatabase(_sqlDatabase); }, + Qt::BlockingQueuedConnection); connectionPools.append(newPool); } @@ -125,9 +127,10 @@ void Servatrice_GameServer::incomingConnection(qintptr socketDescriptor) auto ssi = new TcpServerSocketInterface(server, pool->getDatabaseInterface()); ssi->moveToThread(pool->thread()); pool->addClient(); - connect(ssi, SIGNAL(destroyed()), pool, SLOT(removeClient())); + connect(ssi, &QObject::destroyed, pool, &Servatrice_ConnectionPool::removeClient); - QMetaObject::invokeMethod(ssi, "initConnection", Qt::QueuedConnection, Q_ARG(int, socketDescriptor)); + QMetaObject::invokeMethod( + ssi, [ssi, socketDescriptor] { ssi->initConnection(socketDescriptor); }, Qt::QueuedConnection); } Servatrice_ConnectionPool *Servatrice_GameServer::findLeastUsedConnectionPool() @@ -168,13 +171,15 @@ Servatrice_WebsocketGameServer::Servatrice_WebsocketGameServer(Servatrice *_serv newThread->start(); server->watchWorkerThread(newThread); - QMetaObject::invokeMethod(newDatabaseInterface, "initDatabase", Qt::BlockingQueuedConnection, - Q_ARG(QSqlDatabase, _sqlDatabase)); + QMetaObject::invokeMethod( + newDatabaseInterface, + [newDatabaseInterface, _sqlDatabase] { newDatabaseInterface->initDatabase(_sqlDatabase); }, + Qt::BlockingQueuedConnection); connectionPools.append(newPool); - - connect(this, SIGNAL(newConnection()), this, SLOT(onNewConnection())); } + + connect(this, &QWebSocketServer::newConnection, this, &Servatrice_WebsocketGameServer::onNewConnection); } Servatrice_WebsocketGameServer::~Servatrice_WebsocketGameServer() @@ -199,9 +204,10 @@ void Servatrice_WebsocketGameServer::onNewConnection() */ // ssi->moveToThread(pool->thread()); pool->addClient(); - connect(ssi, SIGNAL(destroyed()), pool, SLOT(removeClient())); + connect(ssi, &QObject::destroyed, pool, &Servatrice_ConnectionPool::removeClient); - QMetaObject::invokeMethod(ssi, "initConnection", Qt::QueuedConnection, Q_ARG(void *, nextPendingConnection())); + void *socket = nextPendingConnection(); + QMetaObject::invokeMethod(ssi, [ssi, socket] { ssi->initConnection(socket); }, Qt::QueuedConnection); } Servatrice_ConnectionPool *Servatrice_WebsocketGameServer::findLeastUsedConnectionPool() @@ -224,14 +230,14 @@ Servatrice_ConnectionPool *Servatrice_WebsocketGameServer::findLeastUsedConnecti void Servatrice_IslServer::incomingConnection(qintptr socketDescriptor) { auto thread = new QThread; - connect(thread, SIGNAL(finished()), thread, SLOT(deleteLater())); + connect(thread, &QThread::finished, thread, &QObject::deleteLater); auto interface = new IslInterface(static_cast(socketDescriptor), cert, privateKey, server); interface->moveToThread(thread); - connect(interface, SIGNAL(destroyed()), thread, SLOT(quit())); + connect(interface, &QObject::destroyed, thread, &QThread::quit); thread->start(); - QMetaObject::invokeMethod(interface, "initServer", Qt::QueuedConnection); + QMetaObject::invokeMethod(interface, [interface] { interface->initServer(); }, Qt::QueuedConnection); } Servatrice::Servatrice(QObject *parent) @@ -585,15 +591,16 @@ bool Servatrice::initServer() auto *thread = new QThread; thread->setObjectName("isl_" + QString::number(prop.id)); - connect(thread, SIGNAL(finished()), thread, SLOT(deleteLater())); + connect(thread, &QThread::finished, thread, &QObject::deleteLater); IslInterface *interface = new IslInterface(prop.id, prop.hostname, prop.address.toString(), prop.controlPort, prop.cert, cert, key, this); interface->moveToThread(thread); - connect(interface, SIGNAL(destroyed()), thread, SLOT(quit())); + connect(interface, &QObject::destroyed, thread, &QThread::quit); thread->start(); - QMetaObject::invokeMethod(interface, "initClient", Qt::BlockingQueuedConnection); + QMetaObject::invokeMethod( + interface, [interface] { interface->initClient(); }, Qt::BlockingQueuedConnection); } qDebug() << "Starting ISL server on port" << getISLNetworkPort(); @@ -610,18 +617,18 @@ bool Servatrice::initServer() } pingClock = new QTimer(this); - connect(pingClock, SIGNAL(timeout()), this, SIGNAL(pingClockTimeout())); + connect(pingClock, &QTimer::timeout, this, &Servatrice::pingClockTimeout); pingClock->start(getClientKeepAlive() * 1000); statusUpdateClock = new QTimer(this); - connect(statusUpdateClock, SIGNAL(timeout()), this, SLOT(statusUpdate())); + connect(statusUpdateClock, &QTimer::timeout, this, &Servatrice::statusUpdate); if (getServerStatusUpdateTime() != 0) { qDebug() << "Starting status update clock, interval" << getServerStatusUpdateTime() << "ms"; statusUpdateClock->start(getServerStatusUpdateTime()); } deckShareCleanupClock = new QTimer(this); - connect(deckShareCleanupClock, SIGNAL(timeout()), this, SLOT(cleanupExpiredDeckShares())); + connect(deckShareCleanupClock, &QTimer::timeout, this, &Servatrice::cleanupExpiredDeckShares); const int deckShareCleanupInterval = getDeckShareCleanupInterval(); if (deckShareCleanupInterval > 0) { qDebug() << "Starting deck share cleanup clock, interval" << deckShareCleanupInterval << "ms"; @@ -1038,7 +1045,7 @@ void Servatrice::scheduleShutdown(const QString &reason, int minutes) nextShutdownMessageMinutes = shutdownMinutes; if (minutes > 0) { shutdownTimer = new QTimer; - connect(shutdownTimer, SIGNAL(timeout()), this, SLOT(shutdownTimeout())); + connect(shutdownTimer, &QTimer::timeout, this, &Servatrice::shutdownTimeout); shutdownTimer->start(60000); } shutdownStateMutex.unlock(); @@ -1101,25 +1108,19 @@ void Servatrice::addIslInterface(int _serverId, IslInterface *interface) { // Only call with islLock locked for writing islInterfaces.insert(_serverId, interface); - connect(interface, SIGNAL(externalUserJoined(ServerInfo_User)), this, SLOT(externalUserJoined(ServerInfo_User))); - connect(interface, SIGNAL(externalUserLeft(QString)), this, SLOT(externalUserLeft(QString))); - connect(interface, SIGNAL(externalRoomUserJoined(int, ServerInfo_User)), this, - SLOT(externalRoomUserJoined(int, ServerInfo_User))); - connect(interface, SIGNAL(externalRoomUserLeft(int, QString)), this, SLOT(externalRoomUserLeft(int, QString))); - connect(interface, SIGNAL(externalRoomSay(int, QString, QString, QString)), this, - SLOT(externalRoomSay(int, QString, QString, QString))); - connect(interface, SIGNAL(externalRoomRemoveMessages(int, QString, int)), this, - SLOT(externalRoomRemoveMessages(int, QString, int))); - connect(interface, SIGNAL(externalRoomGameListChanged(int, ServerInfo_Game)), this, - SLOT(externalRoomGameListChanged(int, ServerInfo_Game))); - connect(interface, SIGNAL(joinGameCommandReceived(Command_JoinGame, int, int, int, qint64)), this, - SLOT(externalJoinGameCommandReceived(Command_JoinGame, int, int, int, qint64))); - connect(interface, SIGNAL(gameCommandContainerReceived(CommandContainer, int, int, qint64)), this, - SLOT(externalGameCommandContainerReceived(CommandContainer, int, int, qint64))); - connect(interface, SIGNAL(responseReceived(Response, qint64)), this, - SLOT(externalResponseReceived(Response, qint64))); - connect(interface, SIGNAL(gameEventContainerReceived(GameEventContainer, qint64)), this, - SLOT(externalGameEventContainerReceived(GameEventContainer, qint64))); + connect(interface, &IslInterface::externalUserJoined, this, &Servatrice::externalUserJoined); + connect(interface, &IslInterface::externalUserLeft, this, &Servatrice::externalUserLeft); + connect(interface, &IslInterface::externalRoomUserJoined, this, &Servatrice::externalRoomUserJoined); + connect(interface, &IslInterface::externalRoomUserLeft, this, &Servatrice::externalRoomUserLeft); + connect(interface, &IslInterface::externalRoomSay, this, &Servatrice::externalRoomSay); + connect(interface, &IslInterface::externalRoomRemoveMessages, this, &Servatrice::externalRoomRemoveMessages); + connect(interface, &IslInterface::externalRoomGameListChanged, this, &Servatrice::externalRoomGameListChanged); + connect(interface, &IslInterface::joinGameCommandReceived, this, &Servatrice::externalJoinGameCommandReceived); + connect(interface, &IslInterface::gameCommandContainerReceived, this, + &Servatrice::externalGameCommandContainerReceived); + connect(interface, &IslInterface::responseReceived, this, &Servatrice::externalResponseReceived); + connect(interface, &IslInterface::gameEventContainerReceived, this, + &Servatrice::externalGameEventContainerReceived); } void Servatrice::removeIslInterface(int _serverId) diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index f0c0c3536..67e807380 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -199,7 +199,8 @@ AbstractServerSocketInterface::AbstractServerSocketInterface(Servatrice *_server // Never call flushOutputQueue directly from outputQueueChanged. In case of a socket error, // it could lead to this object being destroyed while another function is still on the call stack. -> mutex // deadlocks etc. - connect(this, SIGNAL(outputQueueChanged()), this, SLOT(flushOutputQueue()), Qt::QueuedConnection); + connect(this, &AbstractServerSocketInterface::outputQueueChanged, this, + &AbstractServerSocketInterface::flushOutputQueue, Qt::QueuedConnection); } bool AbstractServerSocketInterface::initSession() @@ -1982,7 +1983,8 @@ Response::ResponseCode AbstractServerSocketInterface::cmdBanFromServer(const Com SessionEvent *se = userList[i]->prepareSessionEvent(event); userList[i]->sendProtocolItem(*se); delete se; - QMetaObject::invokeMethod(userList[i], "prepareDestroy", Qt::QueuedConnection); + QMetaObject::invokeMethod( + userList[i], [user = userList[i]] { user->prepareDestroy(); }, Qt::QueuedConnection); } } @@ -4191,15 +4193,15 @@ Response::ResponseCode AbstractServerSocketInterface::cmdUpdateServerMessage(const Command_UpdateServerMessage & /*cmd*/, ResponseContainer & /*rc*/) { - QMetaObject::invokeMethod(server, "updateLoginMessage"); + QMetaObject::invokeMethod(servatrice, [srv = servatrice] { srv->updateLoginMessage(); }); return Response::RespOk; } Response::ResponseCode AbstractServerSocketInterface::cmdShutdownServer(const Command_ShutdownServer &cmd, ResponseContainer & /*rc*/) { - QMetaObject::invokeMethod(server, "scheduleShutdown", Q_ARG(QString, textFromStdString(cmd.reason())), - Q_ARG(int, cmd.minutes())); + QMetaObject::invokeMethod(servatrice, [srv = servatrice, reason = textFromStdString(cmd.reason()), + minutes = cmd.minutes()] { srv->scheduleShutdown(reason, minutes); }); return Response::RespOk; } @@ -4208,7 +4210,9 @@ Response::ResponseCode AbstractServerSocketInterface::cmdReloadConfig(const Comm { logDebugMessage("Received admin command: reloading configuration"); settingsCache->sync(); - QMetaObject::invokeMethod(server, "setRequiredFeatures", Q_ARG(QString, server->getRequiredFeatures())); + QMetaObject::invokeMethod(servatrice, [srv = servatrice, features = servatrice->getRequiredFeatures()] { + srv->setRequiredFeatures(features); + }); return Response::RespOk; } @@ -4260,7 +4264,7 @@ bool AbstractServerSocketInterface::removeAdminFlagFromUser(const QString &userN delete se; } - QMetaObject::invokeMethod(user, "prepareDestroy", Qt::QueuedConnection); + QMetaObject::invokeMethod(user, [user] { user->prepareDestroy(); }, Qt::QueuedConnection); return true; } @@ -4435,10 +4439,10 @@ TcpServerSocketInterface::TcpServerSocketInterface(Servatrice *_server, { socket = new QTcpSocket(this); socket->setSocketOption(QAbstractSocket::LowDelayOption, 1); - connect(socket, SIGNAL(readyRead()), this, SLOT(readClient())); - connect(socket, SIGNAL(disconnected()), this, SLOT(catchSocketDisconnected())); - connect(socket, SIGNAL(errorOccurred(QAbstractSocket::SocketError)), this, - SLOT(catchSocketError(QAbstractSocket::SocketError))); + connect(socket, &QTcpSocket::readyRead, this, [this] { readClient(); }); + connect(socket, &QTcpSocket::disconnected, this, [this] { catchSocketDisconnected(); }); + connect(socket, &QTcpSocket::errorOccurred, this, + [this](QAbstractSocket::SocketError error) { catchSocketError(error); }); } TcpServerSocketInterface::~TcpServerSocketInterface() @@ -4660,8 +4664,7 @@ void WebsocketServerSocketInterface::initConnection(void *_socket) } } - connect(socket, SIGNAL(binaryMessageReceived(const QByteArray &)), this, - SLOT(binaryMessageReceived(const QByteArray &))); + connect(socket, &QWebSocket::binaryMessageReceived, this, &WebsocketServerSocketInterface::binaryMessageReceived); // QWebSocket::errorOccurred was added in Qt 6.5 (error() is deprecated from 6.5 on), // so a typed connect cannot compile against the 6.4 floor. #if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0) @@ -4671,7 +4674,7 @@ void WebsocketServerSocketInterface::initConnection(void *_socket) connect(socket, SIGNAL(error(QAbstractSocket::SocketError)), this, SLOT(catchSocketError(QAbstractSocket::SocketError))); #endif - connect(socket, SIGNAL(disconnected()), this, SLOT(catchSocketDisconnected())); + connect(socket, &QWebSocket::disconnected, this, [this] { catchSocketDisconnected(); }); // Add this object to the server's list of connections before it can receive socket events. // Otherwise, in case of a socket error, it could be removed from the list before it is added. diff --git a/servatrice/src/smtpclient.cpp b/servatrice/src/smtpclient.cpp index 82824e496..bfdfae905 100644 --- a/servatrice/src/smtpclient.cpp +++ b/servatrice/src/smtpclient.cpp @@ -25,7 +25,7 @@ static const qint64 IDLE_TIMEOUT_MS = 60000; // 60 seconds // MAX_RETRY_DELAY cap is applied, preventing unbounded intermediate values. static const int MAX_BACKOFF_SHIFT = 10; -inline Q_LOGGING_CATEGORY(SmtpClientLog, "smtp_client") +inline Q_LOGGING_CATEGORY(SmtpClientLog, "smtp_client"); static QString pendingKey(const QString &userName, EmailType type, const QString &token) { @@ -55,24 +55,23 @@ SmtpClient::SmtpClient(QObject *parent) : QObject(parent) idleTimer = new QTimer(this); idleTimer->setSingleShot(true); - connect(idleTimer, SIGNAL(timeout()), this, SLOT(idleTimeout())); + connect(idleTimer, &QTimer::timeout, this, &SmtpClient::idleTimeout); - connect(smtp, SIGNAL(authenticated()), this, SLOT(authenticated())); - connect(smtp, SIGNAL(authenticationFailed(const QByteArray &)), this, - SLOT(authenticationFailed(const QByteArray &))); - connect(smtp, SIGNAL(connected()), this, SLOT(connected())); - connect(smtp, SIGNAL(connectionFailed(const QByteArray &)), this, SLOT(connectionFailed(const QByteArray &))); - connect(smtp, SIGNAL(disconnected()), this, SLOT(disconnected())); - connect(smtp, SIGNAL(encrypted()), this, SLOT(encrypted())); - connect(smtp, SIGNAL(encryptionFailed(const QByteArray &)), this, SLOT(encryptionFailed(const QByteArray &))); - connect(smtp, SIGNAL(finished()), this, SLOT(finished())); - connect(smtp, SIGNAL(mailFailed(int, int, const QByteArray &)), this, - SLOT(mailFailed(int, int, const QByteArray &))); - connect(smtp, SIGNAL(mailSent(int)), this, SLOT(mailSent(int))); - connect(smtp, SIGNAL(recipientRejected(int, const QString &, const QByteArray &)), this, - SLOT(recipientRejected(int, const QString &, const QByteArray &))); - connect(smtp, SIGNAL(senderRejected(int, const QString &, const QByteArray &)), this, - SLOT(senderRejected(int, const QString &, const QByteArray &))); + connect(smtp, &QxtSmtp::authenticated, this, &SmtpClient::authenticated); + connect(smtp, qOverload(&QxtSmtp::authenticationFailed), this, + &SmtpClient::authenticationFailed); + connect(smtp, &QxtSmtp::connected, this, &SmtpClient::connected); + connect(smtp, qOverload(&QxtSmtp::connectionFailed), this, &SmtpClient::connectionFailed); + connect(smtp, &QxtSmtp::disconnected, this, &SmtpClient::disconnected); + connect(smtp, &QxtSmtp::encrypted, this, &SmtpClient::encrypted); + connect(smtp, qOverload(&QxtSmtp::encryptionFailed), this, &SmtpClient::encryptionFailed); + connect(smtp, &QxtSmtp::finished, this, &SmtpClient::finished); + connect(smtp, qOverload(&QxtSmtp::mailFailed), this, &SmtpClient::mailFailed); + connect(smtp, &QxtSmtp::mailSent, this, &SmtpClient::mailSent); + connect(smtp, qOverload(&QxtSmtp::recipientRejected), this, + &SmtpClient::recipientRejected); + connect(smtp, qOverload(&QxtSmtp::senderRejected), this, + &SmtpClient::senderRejected); } SmtpClient::~SmtpClient() From ce543896f535e98273934436dfcde8ee603a1368 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 2 Nov 2026 07:27:43 +0100 Subject: [PATCH 088/108] [Tests] Use QtTest for model contracts and signal spies (#7630) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Qt6::Test was linked into every test but never used. Add a small RAII QAbstractItemModelTester helper and apply it to the deck list model, the card database model and display proxy, and the sets model. Add QSignalSpy coverage for the card-added and download-settings signals, and assert explicitly that a card row is a leaf with no child columns (the tester does not check that). The contract checker immediately found four model bugs, fixed here: - DeckListModel allowed children below non-zero columns. - CardDatabaseModel reported every card as a child of every card row. - CardDatabaseModel reported six columns for a valid (leaf) card index. - CardDatabaseDisplayModel claimed more rows were fetchable while rowCount already exposed them all, so fetchMore() inserted duplicates. Co-authored-by: Lukas Brübach --- .../visual_database_display_widget.cpp | 5 - .../database/card_database_display_model.cpp | 33 +---- .../database/card_database_display_model.h | 4 - .../models/database/card_database_model.cpp | 12 +- .../models/deck_list/deck_list_model.cpp | 9 +- tests/CMakeLists.txt | 1 + tests/model_contract/CMakeLists.txt | 17 +++ tests/model_contract/model_contract_test.cpp | 137 ++++++++++++++++++ tests/support/model_contract_checker.h | 35 +++++ 9 files changed, 207 insertions(+), 46 deletions(-) create mode 100644 tests/model_contract/CMakeLists.txt create mode 100644 tests/model_contract/model_contract_test.cpp create mode 100644 tests/support/model_contract_checker.h diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp index 5a1097b0d..30e0af66b 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp @@ -417,11 +417,6 @@ void VisualDatabaseDisplayWidget::loadNextPage() int start = currentPage * cardsPerPage; int end = qMin(start + cardsPerPage, rowCount); - // Load more cards if we are at the end of the current list and can fetch more - if (end >= rowCount && databaseDisplayModel->canFetchMore(QModelIndex())) { - databaseDisplayModel->fetchMore(QModelIndex()); - } - // Load the next page of cards and add them to the flow widget flowWidget->setUpdatesEnabled(false); loadPage(start, end); diff --git a/libcockatrice_models/libcockatrice/models/database/card_database_display_model.cpp b/libcockatrice_models/libcockatrice/models/database/card_database_display_model.cpp index 5573deb18..6ed9d8827 100644 --- a/libcockatrice_models/libcockatrice/models/database/card_database_display_model.cpp +++ b/libcockatrice_models/libcockatrice/models/database/card_database_display_model.cpp @@ -33,8 +33,6 @@ CardDatabaseDisplayModel::CardDatabaseDisplayModel(QObject *parent) invalidate(); emit modelDirty(); }); - - loadedRowCount = 0; } void CardDatabaseDisplayModel::setSourceModel(QAbstractItemModel *model) @@ -45,10 +43,7 @@ void CardDatabaseDisplayModel::setSourceModel(QAbstractItemModel *model) connect(model, &QAbstractItemModel::rowsRemoved, this, [this]() { dirty(); }); - connect(model, &QAbstractItemModel::modelReset, this, [this]() { - loadedRowCount = 0; - dirty(); - }); + connect(model, &QAbstractItemModel::modelReset, this, [this]() { dirty(); }); } QMap CardDatabaseDisplayModel::characterTranslation = {{L'“', L'\"'}, @@ -56,32 +51,6 @@ QMap CardDatabaseDisplayModel::characterTranslation = {{L'“' {L'‘', L'\''}, {L'’', L'\''}}; -bool CardDatabaseDisplayModel::canFetchMore(const QModelIndex &index) const -{ - return loadedRowCount < sourceModel()->rowCount(index); -} - -void CardDatabaseDisplayModel::fetchMore(const QModelIndex &index) -{ - int remainder = sourceModel()->rowCount(index) - loadedRowCount; - int itemsToFetch = qMin(100, remainder); - - if (itemsToFetch == 0) { - return; - } - - const auto startIndex = qMin(rowCount(QModelIndex()), loadedRowCount); - beginInsertRows(QModelIndex(), startIndex, startIndex + itemsToFetch - 1); - - loadedRowCount += itemsToFetch; - endInsertRows(); -} - -int CardDatabaseDisplayModel::rowCount(const QModelIndex &parent) const -{ - return QSortFilterProxyModel::rowCount(parent); -} - bool CardDatabaseDisplayModel::lessThan(const QModelIndex &left, const QModelIndex &right) const { const QString leftString = sourceModel()->data(left, CardDatabaseModel::SortRole).toString(); diff --git a/libcockatrice_models/libcockatrice/models/database/card_database_display_model.h b/libcockatrice_models/libcockatrice/models/database/card_database_display_model.h index 343a7482e..be074bba5 100644 --- a/libcockatrice_models/libcockatrice/models/database/card_database_display_model.h +++ b/libcockatrice_models/libcockatrice/models/database/card_database_display_model.h @@ -42,7 +42,6 @@ private: QSet cardNameSet, cardTypes, cardColors; FilterTree *filterTree; FilterString *filterString; - int loadedRowCount; QTimer dirtyTimer; CardSearchLanguage searchLanguage; QString searchText; @@ -85,9 +84,6 @@ public: dirtyTimer.start(20); } void clearFilterAll(); - [[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override; - [[nodiscard]] bool canFetchMore(const QModelIndex &parent) const override; - void fetchMore(const QModelIndex &parent) override; signals: void modelDirty(); diff --git a/libcockatrice_models/libcockatrice/models/database/card_database_model.cpp b/libcockatrice_models/libcockatrice/models/database/card_database_model.cpp index 1ede554f8..a83b7c83d 100644 --- a/libcockatrice_models/libcockatrice/models/database/card_database_model.cpp +++ b/libcockatrice_models/libcockatrice/models/database/card_database_model.cpp @@ -27,14 +27,18 @@ CardDatabaseModel::CardDatabaseModel(CardDatabase *_db, bool _showOnlyCardsFromE CardDatabaseModel::~CardDatabaseModel() = default; -int CardDatabaseModel::rowCount(const QModelIndex & /*parent*/) const +int CardDatabaseModel::rowCount(const QModelIndex &parent) const { - return cardList.size(); + // A list model's card rows are leaves; returning the full card count for a + // valid parent makes every row claim all cards as children. + return parent.isValid() ? 0 : cardList.size(); } -int CardDatabaseModel::columnCount(const QModelIndex & /*parent*/) const +int CardDatabaseModel::columnCount(const QModelIndex &parent) const { - return CARDDBMODEL_COLUMNS; + // Card rows are leaves, so a valid parent holds no columns. Reporting the + // row width for a card index would claim child cells that do not exist. + return parent.isValid() ? 0 : CARDDBMODEL_COLUMNS; } QVariant CardDatabaseModel::data(const QModelIndex &index, int role) const diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp index 4b4088972..617324fe3 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp @@ -134,6 +134,13 @@ void DeckListModel::rebuildTree() int DeckListModel::rowCount(const QModelIndex &parent) const { + // Only column 0 carries children. Column 1 and later indexes are siblings + // used by views for the remaining cells of a row, and a model that hangs + // children off them breaks QAbstractItemModelTester and proxy models. + if (parent.isValid() && parent.column() != 0) { + return 0; + } + // debugIndexInfo("rowCount", parent); auto *node = getNode(parent); if (node) { @@ -282,7 +289,7 @@ QVariant DeckListModel::headerData(const int section, const Qt::Orientation orie QModelIndex DeckListModel::index(int row, int column, const QModelIndex &parent) const { // debugIndexInfo("index", parent); - if (!hasIndex(row, column, parent)) { + if ((parent.isValid() && parent.column() != 0) || !hasIndex(row, column, parent)) { return {}; } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 00b39193d..261900c01 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -516,6 +516,7 @@ add_subdirectory(carddatabase) add_subdirectory(deck_list_model) add_subdirectory(deck_list_zones) add_subdirectory(loading_from_clipboard) +add_subdirectory(model_contract) add_subdirectory(movecard_tests) add_subdirectory(oracle) add_subdirectory(settings) diff --git a/tests/model_contract/CMakeLists.txt b/tests/model_contract/CMakeLists.txt new file mode 100644 index 000000000..3251c1443 --- /dev/null +++ b/tests/model_contract/CMakeLists.txt @@ -0,0 +1,17 @@ +cockatrice_add_test( + NAME + model_contract_test + SOURCES + ${VERSION_STRING_CPP} + model_contract_test.cpp + LIBS + libcockatrice_models + libcockatrice_card + libcockatrice_deck_list + libcockatrice_settings + INCLUDE_DIRS + ${CMAKE_CURRENT_SOURCE_DIR}/../support + ${CMAKE_CURRENT_SOURCE_DIR}/../carddatabase + DEFINITIONS + CARDDB_DATADIR="${CMAKE_SOURCE_DIR}/tests/carddatabase/data/" +) diff --git a/tests/model_contract/model_contract_test.cpp b/tests/model_contract/model_contract_test.cpp new file mode 100644 index 000000000..aefa26f3d --- /dev/null +++ b/tests/model_contract/model_contract_test.cpp @@ -0,0 +1,137 @@ +#include "libcockatrice/deck_list/deck_list.h" +#include "libcockatrice/deck_list/tree/inner_deck_list_node.h" +#include "libcockatrice/models/database/card_database_display_model.h" +#include "libcockatrice/models/database/card_database_model.h" +#include "libcockatrice/models/database/card_set/card_sets_model.h" +#include "libcockatrice/models/deck_list/deck_list_model.h" +#include "model_contract_checker.h" +#include "test_card_database_path_provider.h" + +#include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +QSharedPointer makeDeck() +{ + return QSharedPointer(new DeckList()); +} + +void loadCardDatabase(CardDatabase &database) +{ + database.loadCardDatabases(); + ASSERT_EQ(database.getLoadStatus(), Ok); +} + +TEST(ModelContract, DeckListModelKeepsContractWhileZonesAndCardsChange) +{ + const QSharedPointer deck = makeDeck(); + DeckListModel model(nullptr, deck); + + test_support::ModelContractChecker checker(&model); + + ASSERT_NE(deck->getTree()->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + model.rebuildTree(); + + CardInfoPtr bolt = CardInfo::newInstance("Lightning Bolt"); + const QModelIndex added = model.addCard(ExactCard(bolt), DECK_ZONE_MAIN); + ASSERT_TRUE(added.isValid()); + + CardInfoPtr bears = CardInfo::newInstance("Grizzly Bears"); + bears->setProperty(Mtg::MainCardType, "Creature"); + const QModelIndex grouped = model.addCard(ExactCard(bears), "Removal"); + ASSERT_TRUE(grouped.isValid()); + + ASSERT_TRUE(model.removeRows(grouped.row(), 1, grouped.parent())); +} + +TEST(ModelContract, DeckListModelEmitsCardAddedSignal) +{ + const QSharedPointer deck = makeDeck(); + DeckListModel model(nullptr, deck); + QSignalSpy spy(&model, &DeckListModel::cardAddedAt); + + const QModelIndex added = model.addCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_MAIN); + + ASSERT_TRUE(added.isValid()); + EXPECT_EQ(spy.count(), 1); +} + +TEST(ModelContract, CardDatabaseModelAndDisplayProxyKeepContract) +{ + CardDatabase database(nullptr, new NoopCardPreferenceProvider(), new TestCardDatabasePathProvider(), + new NoopCardSetPriorityController()); + ASSERT_NO_FATAL_FAILURE(loadCardDatabase(database)); + + CardDatabaseModel source(&database, false); + test_support::ModelContractChecker sourceChecker(&source); + + CardDatabaseDisplayModel display; + display.setSourceModel(&source); + test_support::ModelContractChecker displayChecker(&display); + + ASSERT_GT(source.rowCount(), 0); + EXPECT_EQ(display.rowCount(), source.rowCount()); + + // A card row is a leaf, so its index must not claim child columns. The + // tester does not check this, hence the explicit assertion. + const QModelIndex sourceCard = source.index(0, 0); + ASSERT_TRUE(sourceCard.isValid()); + EXPECT_EQ(source.columnCount(sourceCard), 0); + const QModelIndex displayCard = display.index(0, 0); + ASSERT_TRUE(displayCard.isValid()); + EXPECT_EQ(display.columnCount(displayCard), 0); + + QSignalSpy dirtySpy(&display, &CardDatabaseDisplayModel::modelDirty); + display.setCardName("Do"); + ASSERT_TRUE(dirtySpy.wait()); + EXPECT_GT(display.rowCount(), 0); + EXPECT_LT(display.rowCount(), source.rowCount()); +} + +TEST(ModelContract, SetsModelKeepsContract) +{ + CardDatabase database(nullptr, new NoopCardPreferenceProvider(), new TestCardDatabasePathProvider(), + new NoopCardSetPriorityController()); + ASSERT_NO_FATAL_FAILURE(loadCardDatabase(database)); + + SetsModel model(&database); + test_support::ModelContractChecker checker(&model); + + ASSERT_GT(model.rowCount(), 0); + ASSERT_GT(model.columnCount(), 0); +} + +TEST(ModelContract, SettingsSignalIsObservableWithQSignalSpy) +{ + QTemporaryDir tempDir; + ASSERT_TRUE(tempDir.isValid()); + DownloadSettings settings(tempDir.path() + "/", nullptr); + QSignalSpy spy(&settings, &DownloadSettings::downloadSpoilerStatusChanged); + + settings.setDownloadSpoilerStatus(true); + + ASSERT_EQ(spy.count(), 1); +} +} // namespace + +int main(int argc, char **argv) +{ + QCoreApplication app(argc, argv); + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/support/model_contract_checker.h b/tests/support/model_contract_checker.h new file mode 100644 index 000000000..9b493ff6e --- /dev/null +++ b/tests/support/model_contract_checker.h @@ -0,0 +1,35 @@ +#ifndef COCKATRICE_TEST_MODEL_CONTRACT_CHECKER_H +#define COCKATRICE_TEST_MODEL_CONTRACT_CHECKER_H + +#include +#include +#include + +namespace test_support +{ +/** + * @brief Keeps a QAbstractItemModelTester alive for the lifetime of a scope. + * + * The tester only observes the model while it exists, so construct this before + * the mutations a test wants to validate and keep it in scope until the end. + * Fatal reporting mode aborts on the first contract violation; a warning would + * let the test pass while printing a line nobody reads in CI. + */ +class ModelContractChecker +{ +public: + explicit ModelContractChecker(QAbstractItemModel *model) + : tester( + std::make_unique(model, QAbstractItemModelTester::FailureReportingMode::Fatal)) + { + } + + ModelContractChecker(const ModelContractChecker &) = delete; + ModelContractChecker &operator=(const ModelContractChecker &) = delete; + +private: + std::unique_ptr tester; +}; +} // namespace test_support + +#endif // COCKATRICE_TEST_MODEL_CONTRACT_CHECKER_H From d021f20d149ea9fd522b6b403f501be92a1f8cc6 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 2 Nov 2026 07:28:14 +0100 Subject: [PATCH 089/108] [Version] Bump to 3.2.0 (#7762) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index b00e927a4..8a25fe2c6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -90,7 +90,7 @@ endif() # A project name is needed for CPack # Version can be overriden by git tags, see cmake/getversion.cmake -project("Cockatrice" VERSION 3.1.2) +project("Cockatrice" VERSION 3.2.0) # Set release name if not provided via env/cmake var if(NOT DEFINED GIT_TAG_RELEASENAME) From 719863e43d1ecf1747dcd254cef7a234fd20cc4e Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 2 Nov 2026 07:29:05 +0100 Subject: [PATCH 090/108] [CI] Lint only .sh files in the shell formatting pass (#7715) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The changed-file matcher reused the `*.sh` glob as a regular expression, where the leading `*` applies to the `(^|/)` group instead of the file name. Every path ending in "sh" matched, so NSIS include files (.nsh) were handed to shellcheck and failed the format leg. Match the suffix explicitly and keep the glob for the find(1) branch. Co-authored-by: Lukas Brübach --- format.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/format.sh b/format.sh index b4c78543b..fba76e93a 100755 --- a/format.sh +++ b/format.sh @@ -33,6 +33,7 @@ cmakefile="CMakeLists.txt" cmakedir="cmake/.*\\.cmake" cmakeinclude=("cmake/gtest-CMakeLists.txt.in") scripts="*.sh" +scriptsrx="[^/]*\\.sh$" color="--" verbosity=0 sep="----------" @@ -254,7 +255,7 @@ if [[ $branch ]]; then if [[ $do_shell ]]; then shell_names=() for name in "${basenames[@]}"; do - filerx="(^|/)$scripts$" + filerx="(^|/)$scriptsrx" if [[ $name =~ $filerx ]]; then shell_names+=("$name") fi From b37c8b828f382af67380019a804efa2825d6a0c0 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 2 Nov 2026 07:29:15 +0100 Subject: [PATCH 091/108] [Utility] Budget scrypt's XY array in the derivation memory limit (#7712) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deriveKey passes maxmem = 128nr + 128rp + 4096 to EVP_PBE_scrypt, but scrypt also allocates XY at 256r bytes. costParamsAreSane accepts r up to 32, so any server asking for r >= 17 made derivation fail and the client report a login abort it could never recover from. Cover the XY array and add a regression test at the accepted-range boundary. Co-authored-by: Lukas Brübach --- .../libcockatrice/utility/passwordhasher.cpp | 8 +++++--- tests/password_hash_test.cpp | 11 +++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/libcockatrice_utility/libcockatrice/utility/passwordhasher.cpp b/libcockatrice_utility/libcockatrice/utility/passwordhasher.cpp index a7106aadc..8e876d5bc 100644 --- a/libcockatrice_utility/libcockatrice/utility/passwordhasher.cpp +++ b/libcockatrice_utility/libcockatrice/utility/passwordhasher.cpp @@ -62,9 +62,11 @@ QByteArray PasswordHasher::deriveKey(const QString &password, const QByteArray & { QByteArray key(SCRYPT_VERIFIER_LENGTH, '\0'); const QByteArray passwordUtf8 = password.toUtf8(); - // EVP_PBE_scrypt aborts unless maxmem covers the required working memory, - // which is roughly 128 * n * r bytes (plus the small Salsa20/8 block array). - const auto maxmem = static_cast(128) * n * r + static_cast(128) * r * p + 4096; + // EVP_PBE_scrypt aborts unless maxmem covers the required working memory: V is 128 * n * + // r bytes, B is 128 * r * p, and XY is 256 * r. Under-budgeting XY makes OpenSSL fail + // for r >= 17, which costParamsAreSane still accepts. + const auto maxmem = + static_cast(128) * n * r + static_cast(128) * r * p + static_cast(256) * r + 4096; if (EVP_PBE_scrypt(passwordUtf8.constData(), passwordUtf8.size(), reinterpret_cast(salt.constData()), salt.size(), n, r, p, maxmem, reinterpret_cast(key.data()), key.size()) != 1) { diff --git a/tests/password_hash_test.cpp b/tests/password_hash_test.cpp index 99f2e1400..8e7eded72 100644 --- a/tests/password_hash_test.cpp +++ b/tests/password_hash_test.cpp @@ -52,6 +52,17 @@ TEST(PasswordHashTest, DeriveKeyMatchesKnownVector) ASSERT_EQ(derived.toHex(), expected.toHex()); } +TEST(PasswordHashTest, DeriveKeyCoversTheWholeAcceptedRangeOfR) +{ + // costParamsAreSane accepts r up to 32. The memory budget handed to OpenSSL has to cover + // scrypt's XY array (256 * r bytes) on top of V and B, or derivation fails for r >= 17. + for (const int r : {17, 32}) { + const QByteArray derived = PasswordHasher::deriveKey("password", QByteArray("NaCl"), 1024, r, 1); + ASSERT_FALSE(derived.isEmpty()) << "deriveKey failed for accepted r=" << r; + ASSERT_EQ(derived.size(), SCRYPT_VERIFIER_LENGTH); + } +} + TEST(PasswordHashTest, PasswordVerifierRoundTrip) { const QString stored = PasswordHasher::generatePasswordVerifier("hunter2"); From 5134e72eb4da451e3065ed659d8fda1b9fe3eac9 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 2 Nov 2026 07:30:29 +0100 Subject: [PATCH 092/108] [Settings] Store server profiles with QSettings arrays (#7629) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server list used hand-numbered saveName%1/server%1 keys and an O(n) shift loop to delete entries. Store the profiles with beginReadArray and beginWriteArray instead, and migrate the legacy keys once in the constructor so existing profiles and saved passwords survive. SettingsManager::batchWrite no longer disables atomic sync, so a batch is still written through QSettings' atomic save. Add tests for add, update, remove, password updates and the legacy migration. Co-authored-by: Lukas Brübach --- .../src/interface/intents/intent_login.cpp | 11 +- .../src/interface/intents/url_parser.cpp | 20 +- .../server/user/user_info_connection.cpp | 45 +- .../settings/servers_settings.cpp | 437 ++++++++++++------ .../libcockatrice/settings/servers_settings.h | 23 +- .../settings/settings_manager.cpp | 5 +- .../libcockatrice/settings/settings_manager.h | 4 +- tests/settings/CMakeLists.txt | 2 + tests/settings/servers_settings_test.cpp | 228 +++++++++ 9 files changed, 566 insertions(+), 209 deletions(-) create mode 100644 tests/settings/servers_settings_test.cpp diff --git a/cockatrice/src/interface/intents/intent_login.cpp b/cockatrice/src/interface/intents/intent_login.cpp index 0d8390015..8cc574cb0 100644 --- a/cockatrice/src/interface/intents/intent_login.cpp +++ b/cockatrice/src/interface/intents/intent_login.cpp @@ -27,9 +27,9 @@ void IntentGetLoginCredentials::onPreconditionSatisfied() ServersSettings &servers = SettingsCache::instance().servers(); const int index = servers.findServerIndex(context->hostname, context->port); - if (index >= 0) { - context->username = servers.getValue(QString("username%1").arg(index), "server", "server_details").toString(); - context->password = servers.getValue(QString("password%1").arg(index), "server", "server_details").toString(); + if (const auto server = servers.getServer(index)) { + context->username = server->username; + context->password = server->password; emitFinished(); } else { emitFailed(tr("No saved credentials for this server")); @@ -69,9 +69,8 @@ void IntentGetLoginCredentials::onPreconditionNotSatisfied() // it with the raw hostname when addNewServer updates the entry in place. QString saveName = context->hostname; const int existingIndex = servers.findServerIndex(context->hostname, context->port); - if (existingIndex >= 0) { - saveName = - servers.getValue(QString("saveName%1").arg(existingIndex), "server", "server_details").toString(); + if (const auto existing = servers.getServer(existingIndex)) { + saveName = existing->saveName; if (saveName.isEmpty()) { saveName = context->hostname; } diff --git a/cockatrice/src/interface/intents/url_parser.cpp b/cockatrice/src/interface/intents/url_parser.cpp index 1e482a4f1..e0b7bb2fb 100644 --- a/cockatrice/src/interface/intents/url_parser.cpp +++ b/cockatrice/src/interface/intents/url_parser.cpp @@ -418,12 +418,10 @@ void IntentUrlParser::restoreToPreviousServer(const PendingIntentChain &chain) ServersSettings &servers = SettingsCache::instance().servers(); const int index = servers.findServerIndex(chain.previousServerHost, chain.previousServerPort); - if (index >= 0 && servers.hasLoginData(chain.previousServerHost, chain.previousServerPort)) { - const QString username = - servers.getValue(QString("username%1").arg(index), "server", "server_details").toString(); - const QString password = - servers.getValue(QString("password%1").arg(index), "server", "server_details").toString(); - client->connectToServer(chain.previousServerHost, chain.previousServerPort.toUInt(), username, password); + const auto server = servers.getServer(index); + if (server && servers.hasLoginData(chain.previousServerHost, chain.previousServerPort)) { + client->connectToServer(chain.previousServerHost, chain.previousServerPort.toUInt(), server->username, + server->password); return; } client->disconnectFromServer(); @@ -438,11 +436,9 @@ void IntentUrlParser::restoreToPreviousServer(const PendingIntentChain &chain) // when credentials are saved, otherwise stay offline. ServersSettings &servers = SettingsCache::instance().servers(); const int index = servers.findServerIndex(chain.previousServerHost, chain.previousServerPort); - if (index >= 0 && servers.hasLoginData(chain.previousServerHost, chain.previousServerPort)) { - const QString username = - servers.getValue(QString("username%1").arg(index), "server", "server_details").toString(); - const QString password = - servers.getValue(QString("password%1").arg(index), "server", "server_details").toString(); - client->connectToServer(chain.previousServerHost, chain.previousServerPort.toUInt(), username, password); + const auto server = servers.getServer(index); + if (server && servers.hasLoginData(chain.previousServerHost, chain.previousServerPort)) { + client->connectToServer(chain.previousServerHost, chain.previousServerPort.toUInt(), server->username, + server->password); } } diff --git a/cockatrice/src/interface/widgets/server/user/user_info_connection.cpp b/cockatrice/src/interface/widgets/server/user/user_info_connection.cpp index d23ac63ba..2e864824c 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_connection.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_info_connection.cpp @@ -29,19 +29,10 @@ QMap> UserConnection_Inf ServersSettings &servers = SettingsCache::instance().servers(); - int size = servers.getValue("totalServers", "server", "server_details").toInt() + 1; - - for (int i = 0; i < size; i++) { - QString _saveName = servers.getValue(QString("saveName%1").arg(i), "server", "server_details").toString(); - QString serverName = servers.getValue(QString("server%1").arg(i), "server", "server_details").toString(); - QString portNum = servers.getValue(QString("port%1").arg(i), "server", "server_details").toString(); - QString userName = servers.getValue(QString("username%1").arg(i), "server", "server_details").toString(); - QString pass = servers.getValue(QString("password%1").arg(i), "server", "server_details").toString(); - bool savePass = servers.getValue(QString("savePassword%1").arg(i), "server", "server_details").toBool(); - QString _site = servers.getValue(QString("site%1").arg(i), "server", "server_details").toString(); - - UserConnection_Information userInfo(_saveName, serverName, portNum, userName, pass, savePass, _site); - serverList.insert(_saveName, std::make_pair(serverName, userInfo)); + for (const auto &entry : servers.getServers()) { + UserConnection_Information userInfo(entry.saveName, entry.host, entry.port, entry.username, entry.password, + entry.savePassword, entry.site); + serverList.insert(entry.saveName, std::make_pair(entry.host, userInfo)); } return serverList; @@ -53,28 +44,18 @@ QStringList UserConnection_Information::getServerInfo(const QString &find) ServersSettings &servers = SettingsCache::instance().servers(); - int size = servers.getValue("totalServers", "server", "server_details").toInt() + 1; - for (int i = 0; i < size; i++) { - QString _saveName = servers.getValue(QString("saveName%1").arg(i), "server", "server_details").toString(); - - if (find != _saveName) { + for (const auto &entry : servers.getServers()) { + if (find != entry.saveName) { continue; } - QString serverName = servers.getValue(QString("server%1").arg(i), "server", "server_details").toString(); - QString portNum = servers.getValue(QString("port%1").arg(i), "server", "server_details").toString(); - QString userName = servers.getValue(QString("username%1").arg(i), "server", "server_details").toString(); - QString pass = servers.getValue(QString("password%1").arg(i), "server", "server_details").toString(); - bool savePass = servers.getValue(QString("savePassword%1").arg(i), "server", "server_details").toBool(); - QString _site = servers.getValue(QString("site%1").arg(i), "server", "server_details").toString(); - - _server.append(_saveName); - _server.append(serverName); - _server.append(portNum); - _server.append(userName); - _server.append(pass); - _server.append(savePass ? "1" : "0"); - _server.append(_site); + _server.append(entry.saveName); + _server.append(entry.host); + _server.append(entry.port); + _server.append(entry.username); + _server.append(entry.password); + _server.append(entry.savePassword ? "1" : "0"); + _server.append(entry.site); break; } diff --git a/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp b/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp index 4595da2a2..38b87b97c 100644 --- a/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp @@ -5,16 +5,118 @@ #include #include #include -#include +#include +#include #include -#include +#include +#include #include -class QObject; +namespace +{ +constexpr auto SERVER_GROUP = "server"; +constexpr auto SERVER_DETAILS_GROUP = "server_details"; +constexpr auto SERVERS_ARRAY = "servers"; + +QString legacyKey(const char *field, int index) +{ + return QStringLiteral("%1/%2%3") + .arg(QString::fromLatin1(SERVER_DETAILS_GROUP), QString::fromLatin1(field)) + .arg(index); +} + +//! Expects \a settings to be positioned on the server group. +QList readServerArray(QSettings &settings) +{ + QList servers; + + settings.beginGroup(SERVER_DETAILS_GROUP); + const int size = settings.beginReadArray(SERVERS_ARRAY); + servers.reserve(size); + for (int i = 0; i < size; ++i) { + settings.setArrayIndex(i); + ServersSettings::ServerEntry entry; + entry.saveName = settings.value("saveName").toString(); + entry.host = settings.value("server").toString(); + entry.port = settings.value("port").toString(); + entry.username = settings.value("username").toString(); + entry.password = settings.value("password").toString(); + entry.savePassword = settings.value("savePassword").toBool(); + entry.site = settings.value("site").toString(); + servers.append(entry); + } + settings.endArray(); + settings.endGroup(); + + return servers; +} + +//! Expects \a settings to be positioned on the server group. +QList readLegacyServers(QSettings &settings, int lastIndex) +{ + QList servers; + + for (int i = 0; i <= lastIndex; ++i) { + ServersSettings::ServerEntry entry; + entry.saveName = settings.value(legacyKey("saveName", i)).toString(); + entry.host = settings.value(legacyKey("server", i)).toString(); + entry.port = settings.value(legacyKey("port", i)).toString(); + entry.username = settings.value(legacyKey("username", i)).toString(); + entry.password = settings.value(legacyKey("password", i)).toString(); + entry.savePassword = settings.value(legacyKey("savePassword", i)).toBool(); + entry.site = settings.value(legacyKey("site", i)).toString(); + + // Older builds could leave the unused index 0 behind. + if (!entry.saveName.isEmpty() || !entry.host.isEmpty()) { + servers.append(entry); + } + } + + return servers; +} + +void writeServerArray(QSettings &settings, const QList &servers) +{ + settings.beginGroup(SERVER_DETAILS_GROUP); + // beginWriteArray() never drops entries past the new size, so without this + // a removed profile would keep its slot - including its saved password - + // in servers.ini forever. The legacy keys live beside the array, not in it. + settings.remove(SERVERS_ARRAY); + settings.beginWriteArray(SERVERS_ARRAY, servers.size()); + for (int i = 0; i < servers.size(); ++i) { + settings.setArrayIndex(i); + const ServersSettings::ServerEntry &entry = servers.at(i); + settings.setValue("saveName", entry.saveName); + settings.setValue("server", entry.host); + settings.setValue("port", entry.port); + settings.setValue("username", entry.username); + settings.setValue("password", entry.password); + settings.setValue("savePassword", entry.savePassword); + settings.setValue("site", entry.site); + } + settings.endArray(); + settings.endGroup(); +} +} // namespace ServersSettings::ServersSettings(const QString &settingPath, QObject *parent) - : SettingsManager(settingPath + "servers.ini", "server", QString(), parent) + : SettingsManager(settingPath + "servers.ini", SERVER_GROUP, QString(), parent) { + migrateLegacyServers(); +} + +QList ServersSettings::getServers() const +{ + return loadServers(); +} + +std::optional ServersSettings::getServer(int index) const +{ + const QList servers = loadServers(); + if (index < 0 || index >= servers.size()) { + return std::nullopt; + } + return servers.at(index); } void ServersSettings::setPreviousHostLogin(int previous) @@ -45,16 +147,14 @@ void ServersSettings::setPrevioushostName(const QString &name) QString ServersSettings::getSaveName(QString defaultname) { - int index = getPrevioushostindex(getPrevioushostName()); - QVariant saveName = getValue(QString("saveName%1").arg(index), "server", "server_details"); - return saveName == QVariant() ? std::move(defaultname) : saveName.toString(); + const auto server = getServer(getPrevioushostindex(getPrevioushostName())); + return server ? server->saveName : std::move(defaultname); } QString ServersSettings::getSite(QString defaultSite) { - int index = getPrevioushostindex(getPrevioushostName()); - QVariant site = getValue(QString("site%1").arg(index), "server", "server_details"); - return site == QVariant() ? std::move(defaultSite) : site.toString(); + const auto server = getServer(getPrevioushostindex(getPrevioushostName())); + return server ? server->site : std::move(defaultSite); } QString ServersSettings::getPrevioushostName() const @@ -65,10 +165,9 @@ QString ServersSettings::getPrevioushostName() const int ServersSettings::getPrevioushostindex(const QString &saveName) const { - int size = getValue("totalServers", "server", "server_details").toInt(); - - for (int i = 0; i <= size; ++i) { - if (saveName == getValue(QString("saveName%1").arg(i), "server", "server_details").toString()) { + const QList servers = loadServers(); + for (int i = 0; i < servers.size(); ++i) { + if (servers.at(i).saveName == saveName) { return i; } } @@ -78,43 +177,38 @@ int ServersSettings::getPrevioushostindex(const QString &saveName) const QString ServersSettings::getHostname(QString defaultHost) const { - int index = getPrevioushostindex(getPrevioushostName()); - QVariant hostname = getValue(QString("server%1").arg(index), "server", "server_details"); - return hostname == QVariant() ? std::move(defaultHost) : hostname.toString(); + const auto server = getServer(getPrevioushostindex(getPrevioushostName())); + return server ? server->host : std::move(defaultHost); } QString ServersSettings::getPort(QString defaultPort) const { - int index = getPrevioushostindex(getPrevioushostName()); - QVariant port = getValue(QString("port%1").arg(index), "server", "server_details"); - qCDebug(ServersSettingsLog) << "getPort() index = " << index << " port.val = " << port.toString(); - return port == QVariant() ? std::move(defaultPort) : port.toString(); + const auto server = getServer(getPrevioushostindex(getPrevioushostName())); + qCDebug(ServersSettingsLog) << "getPort() found server = " << server.has_value(); + return server ? server->port : std::move(defaultPort); } QString ServersSettings::getPlayerName(QString defaultName) const { - int index = getPrevioushostindex(getPrevioushostName()); - QVariant name = getValue(QString("username%1").arg(index), "server", "server_details"); - qCDebug(ServersSettingsLog) << "getPlayerName() index = " << index << " name.val = " << name.toString(); - return name == QVariant() ? std::move(defaultName) : name.toString(); + const auto server = getServer(getPrevioushostindex(getPrevioushostName())); + qCDebug(ServersSettingsLog) << "getPlayerName() found server = " << server.has_value(); + return server ? server->username : std::move(defaultName); } QString ServersSettings::getPassword() { - int index = getPrevioushostindex(getPrevioushostName()); - - if (getSavePassword()) { - return getValue(QString("password%1").arg(index), "server", "server_details").toString(); + const auto server = getServer(getPrevioushostindex(getPrevioushostName())); + if (!server || !server->savePassword) { + return QString(); } - return QString(); + return server->password; } bool ServersSettings::getSavePassword() const { - int index = getPrevioushostindex(getPrevioushostName()); - bool save = getValue(QString("savePassword%1").arg(index), "server", "server_details").toBool(); - return save; + const auto server = getServer(getPrevioushostindex(getPrevioushostName())); + return server && server->savePassword; } void ServersSettings::setAutoConnect(int autoconnect) @@ -157,15 +251,19 @@ void ServersSettings::setFPPlayerName(QString playerName) void ServersSettings::setServerPassword(const QString &saveName, const QString &password) { - const int index = getPrevioushostindex(saveName); - if (index >= 0) { - setValue(password, QString("password%1").arg(index), "server", "server_details"); - } else { - // A credential write that silently no-ops is hard to diagnose from a - // bug report; surface a mismatched profile name instead. - qCWarning(ServersSettingsLog) << "setServerPassword() could not find profile:" << saveName - << "- password not saved"; + QList servers = loadServers(); + for (ServerEntry &server : servers) { + if (server.saveName == saveName) { + server.password = password; + storeServers(servers); + return; + } } + + // A credential write that silently no-ops is hard to diagnose from a bug + // report; surface a mismatched profile name instead. + qCWarning(ServersSettingsLog) << "setServerPassword() could not find profile:" << saveName + << "- password not saved"; } QString ServersSettings::getFPPlayerName(QString defaultName) const @@ -202,62 +300,29 @@ void ServersSettings::addNewServer(const QString &saveName, return; } - int index = getValue("totalServers", "server", "server_details").toInt() + 1; - - setValue(saveName, QString("saveName%1").arg(index), "server", "server_details"); - setValue(serv, QString("server%1").arg(index), "server", "server_details"); - setValue(port, QString("port%1").arg(index), "server", "server_details"); - setValue(username, QString("username%1").arg(index), "server", "server_details"); - setValue(savePassword, QString("savePassword%1").arg(index), "server", "server_details"); - setValue(index, "totalServers", "server", "server_details"); - setValue(password, QString("password%1").arg(index), "server", "server_details"); - setValue(site, QString("site%1").arg(index), "server", "server_details"); + QList servers = loadServers(); + ServerEntry entry; + entry.saveName = saveName; + entry.host = serv; + entry.port = port; + entry.username = username; + entry.password = password; + entry.savePassword = savePassword; + entry.site = site; + servers.append(entry); + storeServers(servers); } void ServersSettings::removeServer(QString servAddr) { - int size = getValue("totalServers", "server", "server_details").toInt(); - - bool found = false; - for (int i = 0; i <= size; ++i) { - if (!found) { - // find entry and overwrite it - if (servAddr == getValue(QString("server%1").arg(i), "server", "server_details").toString()) { - found = true; - } - } else { - // move all other entries after it one back, overwriting the previous one - int previous = i - 1; // we delete only one entry - setValue(getValue(QString("server%1").arg(i), "server", "server_details"), - QString("server%1").arg(previous), "server", "server_details"); - setValue(getValue(QString("port%1").arg(i), "server", "server_details"), QString("port%1").arg(previous), - "server", "server_details"); - setValue(getValue(QString("username%1").arg(i), "server", "server_details"), - QString("username%1").arg(previous), "server", "server_details"); - setValue(getValue(QString("savePassword%1").arg(i), "server", "server_details"), - QString("savePassword%1").arg(previous), "server", "server_details"); - setValue(getValue(QString("password%1").arg(i), "server", "server_details"), - QString("password%1").arg(previous), "server", "server_details"); - setValue(getValue(QString("saveName%1").arg(i), "server", "server_details"), - QString("saveName%1").arg(previous), "server", "server_details"); - setValue(getValue(QString("site%1").arg(i), "server", "server_details"), QString("site%1").arg(previous), - "server", "server_details"); + QList servers = loadServers(); + for (int i = 0; i < servers.size(); ++i) { + if (servers.at(i).host == servAddr) { + servers.removeAt(i); + storeServers(servers); + return; } } - - // if we have deleted an entry, adjust the total - if (found) { - setValue(size - 1, "totalServers", "server", "server_details"); - - // delete last value - deleteValue(QString("server%1").arg(size), "server", "server_details"); - deleteValue(QString("port%1").arg(size), "server", "server_details"); - deleteValue(QString("username%1").arg(size), "server", "server_details"); - deleteValue(QString("savePassword%1").arg(size), "server", "server_details"); - deleteValue(QString("password%1").arg(size), "server", "server_details"); - deleteValue(QString("saveName%1").arg(size), "server", "server_details"); - deleteValue(QString("site%1").arg(size), "server", "server_details"); - } } /** @@ -265,23 +330,23 @@ void ServersSettings::removeServer(QString servAddr) */ bool ServersSettings::updateExistingServerWithoutLoss(QString saveName, QString serv, QString port, QString site) { - int size = getValue("totalServers", "server", "server_details").toInt(); - - for (int i = 0; i <= size; ++i) { - if (serv == getValue(QString("server%1").arg(i), "server", "server_details").toString()) { - if (!port.isEmpty()) { - setValue(port, QString("port%1").arg(i), "server", "server_details"); - } - - if (!site.isEmpty()) { - setValue(site, QString("site%1").arg(i), "server", "server_details"); - } - - setValue(saveName, QString("saveName%1").arg(i), "server", "server_details"); - - return true; + QList servers = loadServers(); + for (ServerEntry &server : servers) { + if (server.host != serv) { + continue; } + + if (!port.isEmpty()) { + server.port = port; + } + if (!site.isEmpty()) { + server.site = site; + } + server.saveName = saveName; + storeServers(servers); + return true; } + return false; } @@ -293,17 +358,13 @@ bool ServersSettings::updateExistingServer(QString saveName, bool savePassword, QString site) { - int size = getValue("totalServers", "server", "server_details").toInt(); - - for (int i = 0; i <= size; ++i) { - if (serv == getValue(QString("server%1").arg(i), "server", "server_details").toString()) { - setValue(port, QString("port%1").arg(i), "server", "server_details"); - updateServerFields(i, saveName, username, password, savePassword, site); - - return true; - } + const int index = findServerIndex(serv, port); + if (index < 0) { + return false; } - return false; + + updateServerFields(index, saveName, username, password, savePassword, site); + return true; } void ServersSettings::updateServerFields(int index, @@ -313,33 +374,33 @@ void ServersSettings::updateServerFields(int index, bool savePassword, const QString &site) { + QList servers = loadServers(); + if (index < 0 || index >= servers.size()) { + return; + } + + ServerEntry &server = servers[index]; if (!username.isEmpty()) { - setValue(username, QString("username%1").arg(index), "server", "server_details"); + server.username = username; } - if (savePassword && !password.isEmpty()) { - setValue(password, QString("password%1").arg(index), "server", "server_details"); + server.password = password; } else { - setValue(QString(), QString("password%1").arg(index), "server", "server_details"); + server.password.clear(); } - if (!site.isEmpty()) { - setValue(site, QString("site%1").arg(index), "server", "server_details"); + server.site = site; } - - setValue(savePassword, QString("savePassword%1").arg(index), "server", "server_details"); - setValue(saveName, QString("saveName%1").arg(index), "server", "server_details"); + server.savePassword = savePassword; + server.saveName = saveName; + storeServers(servers); } int ServersSettings::findServerIndex(const QString &host, const QString &port) const { - int size = getValue("totalServers", "server", "server_details").toInt(); - - for (int i = 0; i <= size; ++i) { - QString storedHost = getValue(QString("server%1").arg(i), "server", "server_details").toString(); - QString storedPort = getValue(QString("port%1").arg(i), "server", "server_details").toString(); - - if (storedHost == host && storedPort == port) { + const QList servers = loadServers(); + for (int i = 0; i < servers.size(); ++i) { + if (servers.at(i).host == host && servers.at(i).port == port) { return i; } } @@ -349,12 +410,9 @@ int ServersSettings::findServerIndex(const QString &host, const QString &port) c int ServersSettings::findHostIndex(const QString &host) const { - int size = getValue("totalServers", "server", "server_details").toInt(); - - for (int i = 0; i <= size; ++i) { - QString storedHost = getValue(QString("server%1").arg(i), "server", "server_details").toString(); - - if (storedHost.compare(host, Qt::CaseInsensitive) == 0) { + const QList servers = loadServers(); + for (int i = 0; i < servers.size(); ++i) { + if (servers.at(i).host.compare(host, Qt::CaseInsensitive) == 0) { return i; } } @@ -364,29 +422,100 @@ int ServersSettings::findHostIndex(const QString &host) const bool ServersSettings::hasUsername(const QString &host, const QString &port) const { - int index = findServerIndex(host, port); - if (index < 0) { - return false; - } - - QString user = getValue(QString("username%1").arg(index), "server", "server_details").toString(); - return !user.isEmpty(); + const auto server = getServer(findServerIndex(host, port)); + return server && !server->username.isEmpty(); } bool ServersSettings::hasCredentials(const QString &host, const QString &port) const { - int index = findServerIndex(host, port); - if (index < 0) { - return false; - } - - bool save = getValue(QString("savePassword%1").arg(index), "server", "server_details").toBool(); - QString password = getValue(QString("password%1").arg(index), "server", "server_details").toString(); - - return save && !password.isEmpty(); + const auto server = getServer(findServerIndex(host, port)); + return server && server->savePassword && !server->password.isEmpty(); } bool ServersSettings::hasLoginData(const QString &host, const QString &port) const { return hasUsername(host, port) && hasCredentials(host, port); } + +QList ServersSettings::loadServers() const +{ + QSettings settings = getSettings(); + settings.beginGroup(SERVER_GROUP); + const QList servers = readServerArray(settings); + settings.endGroup(); + + return servers; +} + +void ServersSettings::storeServers(const QList &servers) +{ + batchWrite([&servers](QSettings &settings) { + settings.beginGroup(SERVER_GROUP); + writeServerArray(settings, servers); + settings.endGroup(); + }); +} + +void ServersSettings::migrateLegacyServers() +{ + { + QSettings settings = getSettings(); + settings.beginGroup(SERVER_GROUP); + // Only builds that predate the array format write this marker. Once it + // is gone there is nothing left to import and no reason to touch the + // file (a sync on every start is a needless open-and-write). + const bool hasLegacy = settings.contains(QStringLiteral("%1/totalServers").arg(SERVER_DETAILS_GROUP)); + settings.endGroup(); + + if (!hasLegacy) { + return; + } + } + + const QSettings::Status status = batchWrite([](QSettings &settings) { + settings.beginGroup(SERVER_GROUP); + + const int lastLegacyIndex = + settings.value(QStringLiteral("%1/totalServers").arg(SERVER_DETAILS_GROUP), -1).toInt(); + + QList servers = readServerArray(settings); + + // An older build can write the legacy keys again after the array + // migration, so the array is not automatically complete: append every + // legacy profile the array does not already hold instead of dropping it. + int imported = 0; + const QList legacyServers = readLegacyServers(settings, lastLegacyIndex); + for (const ServersSettings::ServerEntry &entry : legacyServers) { + const bool known = + std::any_of(servers.cbegin(), servers.cend(), [&entry](const ServersSettings::ServerEntry &existing) { + return existing.host == entry.host && existing.port == entry.port; + }); + if (!known) { + servers.append(entry); + ++imported; + } + } + + writeServerArray(settings, servers); + if (imported > 0) { + qCDebug(ServersSettingsLog) << "Migrated" << imported << "server profiles to the array format"; + } + + // The array (freshly written or already present) is authoritative now. + for (int i = 0; i <= lastLegacyIndex; ++i) { + settings.remove(legacyKey("saveName", i)); + settings.remove(legacyKey("server", i)); + settings.remove(legacyKey("port", i)); + settings.remove(legacyKey("username", i)); + settings.remove(legacyKey("password", i)); + settings.remove(legacyKey("savePassword", i)); + settings.remove(legacyKey("site", i)); + } + settings.remove(QStringLiteral("%1/totalServers").arg(SERVER_DETAILS_GROUP)); + settings.endGroup(); + }); + + if (status != QSettings::NoError) { + qCWarning(ServersSettingsLog) << "Failed to migrate server profiles:" << status; + } +} diff --git a/libcockatrice_settings/libcockatrice/settings/servers_settings.h b/libcockatrice_settings/libcockatrice/settings/servers_settings.h index 38bb02876..b403b1cea 100644 --- a/libcockatrice_settings/libcockatrice/settings/servers_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/servers_settings.h @@ -9,9 +9,11 @@ #include "settings_manager.h" +#include #include #include #include +#include #include class QObject; @@ -27,6 +29,22 @@ class ServersSettings : public SettingsManager friend class SettingsCache; public: + struct ServerEntry + { + QString saveName; + QString host; + QString port; + QString username; + QString password; + bool savePassword = false; + QString site; + }; + + explicit ServersSettings(const QString &settingPath, QObject *parent = nullptr); + + QList getServers() const; + std::optional getServer(int index) const; + int getPreviousHostLogin() const; int getPrevioushostindex(const QString &) const; QStringList getPreviousHostList() const; @@ -88,8 +106,11 @@ public: bool getClearDebugLogStatus(bool abDefaultValue) const; private: - explicit ServersSettings(const QString &settingPath, QObject *parent = nullptr); ServersSettings(const ServersSettings & /*other*/); + + QList loadServers() const; + void storeServers(const QList &servers); + void migrateLegacyServers(); }; #endif // SERVERSSETTINGS_H diff --git a/libcockatrice_settings/libcockatrice/settings/settings_manager.cpp b/libcockatrice_settings/libcockatrice/settings/settings_manager.cpp index f98778ca0..7cc1fd7be 100644 --- a/libcockatrice_settings/libcockatrice/settings/settings_manager.cpp +++ b/libcockatrice_settings/libcockatrice/settings/settings_manager.cpp @@ -193,13 +193,12 @@ QVariant SettingsManager::getValue(const QString &name, return value; } -void SettingsManager::batchWrite(std::function batchWriteFunction) +QSettings::Status SettingsManager::batchWrite(std::function batchWriteFunction) { auto settings = getSettings(); - settings.setAtomicSyncRequired(false); batchWriteFunction(settings); settings.sync(); // single flush - settings.setAtomicSyncRequired(true); + return settings.status(); } /** diff --git a/libcockatrice_settings/libcockatrice/settings/settings_manager.h b/libcockatrice_settings/libcockatrice/settings/settings_manager.h index 8d3110106..760d095e6 100644 --- a/libcockatrice_settings/libcockatrice/settings/settings_manager.h +++ b/libcockatrice_settings/libcockatrice/settings/settings_manager.h @@ -28,7 +28,9 @@ public: QVariant getValue(const QString &name, const QString &group, const QString &subGroup = QString()) const; QVariant getValue(const QString &name, const QString &group, const QString &subGroup, const QVariant &defaultValue) const; - void batchWrite(std::function batchWriteFunction); + //! \brief Runs \a batchWriteFunction on a temporary QSettings and syncs once. + //! \return the status of the underlying file after the final sync. + QSettings::Status batchWrite(std::function batchWriteFunction); void sync(); diff --git a/tests/settings/CMakeLists.txt b/tests/settings/CMakeLists.txt index 98bed1f4b..9dbda9d6b 100644 --- a/tests/settings/CMakeLists.txt +++ b/tests/settings/CMakeLists.txt @@ -1,5 +1,7 @@ cockatrice_add_test(NAME settings_manager_test SOURCES settings_manager_test.cpp LIBS libcockatrice_settings) +cockatrice_add_test(NAME servers_settings_test SOURCES servers_settings_test.cpp LIBS libcockatrice_settings) + cockatrice_add_test( NAME settings_defaults_test diff --git a/tests/settings/servers_settings_test.cpp b/tests/settings/servers_settings_test.cpp new file mode 100644 index 000000000..5d5953999 --- /dev/null +++ b/tests/settings/servers_settings_test.cpp @@ -0,0 +1,228 @@ +#include "gtest/gtest.h" +#include +#include +#include +#include + +namespace +{ +class ServersSettingsTest : public ::testing::Test +{ +protected: + QTemporaryDir tempDir; + QString settingsPath; + + void SetUp() override + { + settingsPath = tempDir.path() + "/"; + } + + QString settingsFile() const + { + return settingsPath + "servers.ini"; + } + + void writeLegacyServer(int index, + const QString &saveName, + const QString &host, + const QString &port, + const QString &username, + const QString &password, + bool savePassword, + const QString &site) + { + QSettings settings(settingsFile(), QSettings::IniFormat); + const QString prefix = QStringLiteral("server/server_details/"); + settings.setValue(prefix + QStringLiteral("saveName%1").arg(index), saveName); + settings.setValue(prefix + QStringLiteral("server%1").arg(index), host); + settings.setValue(prefix + QStringLiteral("port%1").arg(index), port); + settings.setValue(prefix + QStringLiteral("username%1").arg(index), username); + settings.setValue(prefix + QStringLiteral("password%1").arg(index), password); + settings.setValue(prefix + QStringLiteral("savePassword%1").arg(index), savePassword); + settings.setValue(prefix + QStringLiteral("site%1").arg(index), site); + } +}; + +TEST_F(ServersSettingsTest, AddServerStoresAllFields) +{ + ServersSettings settings(settingsPath, nullptr); + settings.addNewServer("Alpha", "alpha.example", "4748", "alice", "secret", true, "https://alpha.example"); + + const auto servers = settings.getServers(); + ASSERT_EQ(servers.size(), 1); + EXPECT_EQ(servers.first().saveName, QString("Alpha")); + EXPECT_EQ(servers.first().host, QString("alpha.example")); + EXPECT_EQ(servers.first().port, QString("4748")); + EXPECT_EQ(servers.first().username, QString("alice")); + EXPECT_EQ(servers.first().password, QString("secret")); + EXPECT_TRUE(servers.first().savePassword); + EXPECT_EQ(servers.first().site, QString("https://alpha.example")); +} + +TEST_F(ServersSettingsTest, AddExistingHostAndPortUpdatesInsteadOfDuplicating) +{ + ServersSettings settings(settingsPath, nullptr); + settings.addNewServer("Alpha", "alpha.example", "4748", "alice", "secret", true, "site-a"); + settings.addNewServer("Renamed", "alpha.example", "4748", "bob", "new-secret", true, "site-b"); + + const auto servers = settings.getServers(); + ASSERT_EQ(servers.size(), 1); + EXPECT_EQ(servers.first().saveName, QString("Renamed")); + EXPECT_EQ(servers.first().username, QString("bob")); + EXPECT_EQ(servers.first().password, QString("new-secret")); + EXPECT_EQ(servers.first().site, QString("site-b")); +} + +TEST_F(ServersSettingsTest, RemoveServerRemovesOnlyMatchingHostAndKeepsOrder) +{ + ServersSettings settings(settingsPath, nullptr); + settings.addNewServer("Alpha", "alpha.example", "4748", "alice", "a", true, ""); + settings.addNewServer("Beta", "beta.example", "4748", "bob", "b", true, ""); + settings.addNewServer("Gamma", "gamma.example", "4748", "carol", "c", true, ""); + + settings.removeServer("beta.example"); + + const auto servers = settings.getServers(); + ASSERT_EQ(servers.size(), 2); + EXPECT_EQ(servers.at(0).saveName, QString("Alpha")); + EXPECT_EQ(servers.at(1).saveName, QString("Gamma")); +} + +TEST_F(ServersSettingsTest, RemovingLastServerDropsItsStaleArraySlot) +{ + ServersSettings settings(settingsPath, nullptr); + settings.addNewServer("Alpha", "alpha.example", "4748", "alice", "a", true, ""); + settings.addNewServer("Beta", "beta.example", "4748", "bob", "b", true, ""); + settings.addNewServer("Gamma", "gamma.example", "4748", "carol", "gamma-secret", true, ""); + + settings.removeServer("gamma.example"); + + QSettings raw(settingsFile(), QSettings::IniFormat); + EXPECT_FALSE(raw.contains("server/server_details/servers/3/password")); + EXPECT_FALSE(raw.contains("server/server_details/servers/3/saveName")); + ASSERT_EQ(settings.getServers().size(), 2); +} + +TEST_F(ServersSettingsTest, UpdateWithoutLossKeepsCredentials) +{ + ServersSettings settings(settingsPath, nullptr); + settings.addNewServer("Alpha", "alpha.example", "4748", "alice", "secret", true, ""); + + EXPECT_TRUE(settings.updateExistingServerWithoutLoss("Renamed", "alpha.example", "1234", "site")); + + const auto server = settings.getServer(settings.findServerIndex("alpha.example", "1234")); + ASSERT_TRUE(server.has_value()); + EXPECT_EQ(server->saveName, QString("Renamed")); + EXPECT_EQ(server->username, QString("alice")); + EXPECT_EQ(server->password, QString("secret")); + EXPECT_TRUE(server->savePassword); + EXPECT_EQ(server->site, QString("site")); +} + +TEST_F(ServersSettingsTest, SetServerPasswordUpdatesOnlyTheNamedProfile) +{ + ServersSettings settings(settingsPath, nullptr); + settings.addNewServer("Alpha", "alpha.example", "4748", "alice", "old", true, ""); + settings.addNewServer("Beta", "beta.example", "4748", "bob", "keep", true, ""); + + settings.setServerPassword("Beta", "new"); + + EXPECT_EQ(settings.getServer(0)->password, QString("old")); + EXPECT_EQ(settings.getServer(1)->password, QString("new")); +} + +TEST_F(ServersSettingsTest, LegacyProfilesAreMigrated) +{ + { + QSettings settings(settingsFile(), QSettings::IniFormat); + settings.setValue("server/server_details/totalServers", 2); + } + writeLegacyServer(1, "Alpha", "alpha.example", "4748", "alice", "secret", true, "site-a"); + writeLegacyServer(2, "Beta", "beta.example", "4748", "bob", "other", false, "site-b"); + + ServersSettings settings(settingsPath, nullptr); + + const auto servers = settings.getServers(); + ASSERT_EQ(servers.size(), 2); + EXPECT_EQ(servers.at(0).saveName, QString("Alpha")); + EXPECT_EQ(servers.at(0).host, QString("alpha.example")); + EXPECT_TRUE(servers.at(0).savePassword); + EXPECT_EQ(servers.at(1).saveName, QString("Beta")); + EXPECT_FALSE(servers.at(1).savePassword); + + QSettings raw(settingsFile(), QSettings::IniFormat); + EXPECT_FALSE(raw.contains("server/server_details/totalServers")); + EXPECT_FALSE(raw.contains("server/server_details/saveName1")); + EXPECT_FALSE(raw.contains("server/server_details/server1")); +} + +TEST_F(ServersSettingsTest, MigrationRunsOnce) +{ + writeLegacyServer(1, "Alpha", "alpha.example", "4748", "alice", "secret", true, ""); + { + QSettings settings(settingsFile(), QSettings::IniFormat); + settings.setValue("server/server_details/totalServers", 1); + } + + { + ServersSettings first(settingsPath, nullptr); + ASSERT_EQ(first.getServers().size(), 1); + } + + ServersSettings second(settingsPath, nullptr); + ASSERT_EQ(second.getServers().size(), 1); +} + +TEST_F(ServersSettingsTest, LegacyProfileAddedAfterMigrationIsMerged) +{ + { + ServersSettings first(settingsPath, nullptr); + first.addNewServer("Alpha", "alpha.example", "4748", "alice", "secret", true, ""); + } + + // An older build writes its own profile back in the legacy format after the + // array migration already happened. + writeLegacyServer(1, "Beta", "beta.example", "4748", "bob", "other", true, "site-b"); + // A duplicate of a profile the array already holds must not be added twice. + writeLegacyServer(2, "Alpha duplicate", "alpha.example", "4748", "eve", "dupe", true, ""); + { + QSettings settings(settingsFile(), QSettings::IniFormat); + settings.setValue("server/server_details/totalServers", 2); + } + + ServersSettings settings(settingsPath, nullptr); + + const auto servers = settings.getServers(); + ASSERT_EQ(servers.size(), 2); + EXPECT_EQ(servers.at(0).saveName, QString("Alpha")); + EXPECT_EQ(servers.at(0).password, QString("secret")); + EXPECT_EQ(servers.at(1).saveName, QString("Beta")); + EXPECT_EQ(servers.at(1).host, QString("beta.example")); + EXPECT_EQ(servers.at(1).username, QString("bob")); + EXPECT_EQ(servers.at(1).password, QString("other")); + EXPECT_TRUE(servers.at(1).savePassword); + EXPECT_EQ(servers.at(1).site, QString("site-b")); + + QSettings raw(settingsFile(), QSettings::IniFormat); + EXPECT_FALSE(raw.contains("server/server_details/totalServers")); + EXPECT_FALSE(raw.contains("server/server_details/saveName1")); + EXPECT_FALSE(raw.contains("server/server_details/server2")); + + // The merge is persisted, so a further start adds nothing. + ServersSettings again(settingsPath, nullptr); + ASSERT_EQ(again.getServers().size(), 2); +} + +TEST_F(ServersSettingsTest, OutOfRangeIndexReturnsNullopt) +{ + ServersSettings settings(settingsPath, nullptr); + EXPECT_FALSE(settings.getServer(-1).has_value()); + EXPECT_FALSE(settings.getServer(0).has_value()); +} +} // namespace + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From 3cf8bdb00e124e272ce3caa5f372d967dd758630 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 2 Nov 2026 07:35:41 +0100 Subject: [PATCH 093/108] [Tests] Add a headless game logic regression suite (#7702) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Tests] Add headless game logic regression suite Cover CardState, CardZoneLogic and the Command_MoveCard protobufs PlayerActions emits, against libcockatrice_game with a recording AbstractClient and no graphics. The old game_smoke_test cases are migrated into the new suite. * [Tests] Cover game event application through GameEventHandler Feeds GameEventContainers through the real dispatcher and asserts the resulting zone and card state: move (including face down), the set-card-attr attributes plus the all-cards and does-not-untap branches, card counters, attach/detach, zone reveal properties, and drawing named cards from the deck. * [Tests] Replay a synthetic game state through the dispatcher Builds a GameReplay in code, feeds its containers through the same GameEventHandler path the client uses, and asserts the materialized players, zones, cards and attachments plus event ordering for move, attributes and counters. A small headless zone view stands in for the graphics side where processPlayerInfo asks for card items. * [Tests] Cover card flip, destroy, token and turn events Adds the remaining state-mutating event handlers: flip (face down keeps the ref, face up adopts the printing), destroy (removes the card and un-attaches its attached cards), create token (database P/T unless provided or face down) and player properties changed under the concede/unconcede contexts, plus active player and phase. * [Tests] Cover player lifecycle and counter events Adds the player-level events that mutate session state: leave removes the player from the manager, kicked emits gameClosed, game closed flips the closed flag and clears the started flag, host changed moves the host role to another player, and create/set/delete update the player counters with unknown ids ignored. * [Tests] Drive the zone view request through addCard The test emitted createCardRequested itself and then asserted it was emitted. Registering a ZoneViewZoneLogic on the zone and adding a card exercises the real prepareAddCard -> signal path, so the assertion now fails if addCard stops asking the graphics side to build the card. * [Tests] Skip the card database when listing tests gtest_discover_tests runs the binary with --gtest_list_tests during the build. That process built the card database and replaced its default path provider, leaving allocations that the sanitizer leg's default LeakSanitizer check reports at exit, so discovery failed on Ubuntu 26.04. No test runs in list mode, so return before the database setup. * [Tests] Fix fixture comment nit Wraps the RecordingClient::sent member name in backticks in the comment. --------- Co-authored-by: Lukas Brübach --- tests/game/CMakeLists.txt | 42 +- tests/game/card_state_test.cpp | 110 +++++ tests/game/card_zone_logic_test.cpp | 107 +++++ tests/game/data/cards.xml | 64 +++ tests/game/data/spoilers.xml | 4 + tests/game/data/tokens.xml | 21 + tests/game/game_event_handler_test.cpp | 566 +++++++++++++++++++++++++ tests/game/game_smoke_test.cpp | 106 ----- tests/game/game_test_fixture.h | 129 ++++++ tests/game/game_test_main.cpp | 126 ++++++ tests/game/player_actions_test.cpp | 119 ++++++ tests/game/replay_test.cpp | 349 +++++++++++++++ 12 files changed, 1611 insertions(+), 132 deletions(-) create mode 100644 tests/game/card_state_test.cpp create mode 100644 tests/game/card_zone_logic_test.cpp create mode 100644 tests/game/data/cards.xml create mode 100644 tests/game/data/spoilers.xml create mode 100644 tests/game/data/tokens.xml create mode 100644 tests/game/game_event_handler_test.cpp delete mode 100644 tests/game/game_smoke_test.cpp create mode 100644 tests/game/game_test_fixture.h create mode 100644 tests/game/game_test_main.cpp create mode 100644 tests/game/player_actions_test.cpp create mode 100644 tests/game/replay_test.cpp diff --git a/tests/game/CMakeLists.txt b/tests/game/CMakeLists.txt index 4a7492c71..42c935c8f 100644 --- a/tests/game/CMakeLists.txt +++ b/tests/game/CMakeLists.txt @@ -1,28 +1,18 @@ -add_executable( - game_smoke_test - ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/cache_settings.cpp - ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/card_counter_settings.cpp - ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/shortcuts_settings.cpp - ${CMAKE_SOURCE_DIR}/cockatrice/src/client/network/update/client/release_channel.cpp +cockatrice_add_test( + NAME + game_logic_test + SOURCES + game_test_main.cpp + card_state_test.cpp + card_zone_logic_test.cpp + game_event_handler_test.cpp + player_actions_test.cpp + replay_test.cpp ${VERSION_STRING_CPP} - game_smoke_test.cpp + LIBS + libcockatrice_game + INCLUDE_DIRS + ${CMAKE_CURRENT_SOURCE_DIR} + DEFINITIONS + GAME_TEST_DATA_DIR="${CMAKE_CURRENT_SOURCE_DIR}/data" ) - -# SettingsCache is still reached from the game logic, but it lives in the application sources -# rather than in libcockatrice_settings, so the client settings sources are compiled in here -# exactly like loader_local_matching_test and theme_migration_test do. -target_include_directories(game_smoke_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src) - -target_link_libraries( - game_smoke_test - PRIVATE libcockatrice_game - PRIVATE Threads::Threads - PRIVATE ${GTEST_BOTH_LIBRARIES} - PRIVATE ${TEST_QT_MODULES} -) - -add_test(NAME game_smoke_test COMMAND game_smoke_test) - -if(NOT GTEST_FOUND) - add_dependencies(game_smoke_test gtest) -endif() diff --git a/tests/game/card_state_test.cpp b/tests/game/card_state_test.cpp new file mode 100644 index 000000000..2f2c43bf4 --- /dev/null +++ b/tests/game/card_state_test.cpp @@ -0,0 +1,110 @@ +#include "gtest/gtest.h" +#include +#include +#include +#include +#include + +namespace +{ + +CardRef cardRefFor(const QString &name) +{ + CardRef cardRef; + cardRef.name = name; + return cardRef; +} + +} // namespace + +TEST(CardStateTest, AttachmentGraphKeepsBothEndsConsistent) +{ + CardState host(nullptr, nullptr, cardRefFor(QStringLiteral("Host")), 1); + CardState guest(nullptr, nullptr, cardRefFor(QStringLiteral("Guest")), 2); + + guest.setAttachedTo(&host); + host.addAttachedCard(&guest); + + EXPECT_EQ(guest.getAttachedTo(), &host); + ASSERT_EQ(host.getAttachedCards().size(), 1); + EXPECT_EQ(host.getAttachedCards().first(), &guest); + + host.removeAttachedCard(&guest); + guest.setAttachedTo(nullptr); + + EXPECT_TRUE(host.getAttachedCards().isEmpty()); + EXPECT_EQ(guest.getAttachedTo(), nullptr); +} + +TEST(CardStateTest, CountersAddUpdateAndClear) +{ + CardState card(nullptr, nullptr, cardRefFor(QStringLiteral("Cat")), 1); + int changes = 0; + QObject::connect(&card, &CardState::countersChanged, [&changes]() { ++changes; }); + + card.insertCounter(1, 2); + EXPECT_EQ(card.getCounters().value(1), 2); + + card.setCounter(1, 3); + EXPECT_EQ(card.getCounters().value(1), 3); + + card.setCounter(1, 0); + EXPECT_FALSE(card.getCounters().contains(1)); + + card.setCounter(2, 1); + card.clearCounters(); + EXPECT_TRUE(card.getCounters().isEmpty()); + + EXPECT_EQ(changes, 5); +} + +TEST(CardStateTest, TapFlipAttackingAndReset) +{ + CardState card(nullptr, nullptr, cardRefFor(QStringLiteral("Cat")), 1); + card.setTapped(true); + card.setFaceDown(true); + card.setAttacking(true); + card.setAnnotation(QStringLiteral("note")); + card.setPT(QStringLiteral("4/4")); + card.setDoesntUntap(true); + card.setDestroyOnZoneChange(true); + card.setColor(QStringLiteral("red")); + card.insertCounter(1, 2); + + card.resetState(true); + + EXPECT_FALSE(card.getTapped()); + EXPECT_FALSE(card.getAttacking()); + EXPECT_TRUE(card.getCounters().isEmpty()); + EXPECT_TRUE(card.getPT().isEmpty()); + EXPECT_FALSE(card.getDoesntUntap()); + EXPECT_EQ(card.getAnnotation(), QStringLiteral("note")); + // resetState() deliberately leaves these three alone. + EXPECT_TRUE(card.getFaceDown()); + EXPECT_EQ(card.getColor(), QStringLiteral("red")); + EXPECT_TRUE(card.getDestroyOnZoneChange()); + + card.setAnnotation(QStringLiteral("other")); + card.resetState(false); + EXPECT_TRUE(card.getAnnotation().isEmpty()); +} + +TEST(CardStateTest, ParsePT) +{ + const QVariantList plain = CardState::parsePT(QStringLiteral("3/3")); + ASSERT_EQ(plain.size(), 2); + EXPECT_EQ(plain.at(0).toString(), QStringLiteral("3")); + EXPECT_EQ(plain.at(1).toString(), QStringLiteral("3")); + + const QVariantList negative = CardState::parsePT(QStringLiteral("-1/2")); + ASSERT_EQ(negative.size(), 2); + EXPECT_EQ(negative.at(0).typeId(), QMetaType::Int); + EXPECT_EQ(negative.at(0).toInt(), -1); + EXPECT_EQ(negative.at(1).toString(), QStringLiteral("2")); + + const QVariantList star = CardState::parsePT(QStringLiteral("*")); + ASSERT_EQ(star.size(), 1); + EXPECT_EQ(star.at(0).toString(), QStringLiteral("*")); + + EXPECT_TRUE(CardState::parsePT(QString()).isEmpty()); +} diff --git a/tests/game/card_zone_logic_test.cpp b/tests/game/card_zone_logic_test.cpp new file mode 100644 index 000000000..7f0c39b47 --- /dev/null +++ b/tests/game/card_zone_logic_test.cpp @@ -0,0 +1,107 @@ +#include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include + +namespace +{ + +CardRef cardRefFor(const QString &name) +{ + CardRef cardRef; + cardRef.name = name; + return cardRef; +} + +} // namespace + +TEST(CardZoneLogicTest, AddsAndTakesCardsWithoutAGraphicsSide) +{ + HandZoneLogic zone(nullptr, QStringLiteral("hand"), false, false, true); + CardState card(nullptr, nullptr, cardRefFor(QStringLiteral("Lightning Bolt")), 7); + + QSignalSpy cardAddedSpy(&zone, &CardZoneLogic::cardAdded); + QSignalSpy cardCountSpy(&zone, &CardZoneLogic::cardCountChanged); + + zone.addCard(&card, false, 0); + + ASSERT_EQ(zone.getCards().size(), 1); + EXPECT_EQ(zone.getCards().first(), &card); + EXPECT_EQ(card.getZone(), &zone); + EXPECT_EQ(cardAddedSpy.count(), 1); + EXPECT_GE(cardCountSpy.count(), 1); + + EXPECT_EQ(zone.takeCard(0, 7), &card); + EXPECT_TRUE(zone.getCards().isEmpty()); + EXPECT_EQ(card.getId(), 7); +} + +TEST(CardZoneLogicTest, AsksTheGraphicsSideToCreateCards) +{ + HandZoneLogic zone(nullptr, QStringLiteral("library"), false, true, true); + // The graphics side owns the view logic and registers it on the zone it + // shows; adding a card then goes through prepareAddCard and the request + // signal instead of the test emitting the signal itself. + ZoneViewZoneLogic view(nullptr, &zone, 1, true, false, false); + zone.getViews().append(&view); + + QSignalSpy requestSpy(&view, &CardZoneLogic::createCardRequested); + + CardState card(nullptr, nullptr, cardRefFor(QStringLiteral("Lightning Bolt")), 7); + zone.addCard(&card, true, 0, 0); + + ASSERT_EQ(requestSpy.count(), 1); + const QList arguments = requestSpy.takeFirst(); + const ServerInfo_Card cardInfo = arguments.at(0).value(); + EXPECT_EQ(cardInfo.id(), 7); + EXPECT_EQ(cardInfo.name(), "Lightning Bolt"); + EXPECT_TRUE(arguments.at(1).toBool()); + EXPECT_EQ(card.getZone(), &zone); + EXPECT_EQ(zone.getCards().size(), 1); +} + +TEST(CardZoneLogicTest, MoveBetweenZonesUpdatesCardZone) +{ + HandZoneLogic first(nullptr, QStringLiteral("hand"), false, false, true); + HandZoneLogic second(nullptr, QStringLiteral("grave"), false, false, true); + CardState card(nullptr, nullptr, cardRefFor(QStringLiteral("Cat")), 7); + + first.addCard(&card, false, 0); + ASSERT_EQ(first.getCards().size(), 1); + + CardState *taken = first.takeCard(0, 7); + ASSERT_EQ(taken, &card); + EXPECT_TRUE(first.getCards().isEmpty()); + + second.addCard(taken, false, 0); + EXPECT_EQ(second.getCards().size(), 1); + EXPECT_EQ(card.getZone(), &second); +} + +TEST(CardZoneLogicTest, GetCardFindsById) +{ + HandZoneLogic zone(nullptr, QStringLiteral("hand"), false, false, true); + CardState first(nullptr, nullptr, cardRefFor(QStringLiteral("Cat")), 7); + CardState second(nullptr, nullptr, cardRefFor(QStringLiteral("Dog")), 8); + + zone.addCard(&first, false, 0); + zone.addCard(&second, false, 0); + + EXPECT_EQ(zone.getCard(7), &first); + EXPECT_EQ(zone.getCard(8), &second); + EXPECT_EQ(zone.getCard(99), nullptr); +} + +TEST(CardZoneLogicTest, ReorganizeSignalFiresOnAdd) +{ + HandZoneLogic zone(nullptr, QStringLiteral("hand"), false, false, true); + CardState card(nullptr, nullptr, cardRefFor(QStringLiteral("Cat")), 7); + QSignalSpy reorganizeSpy(&zone, &CardZoneLogic::reorganizeCards); + + zone.addCard(&card, true, 0); + + EXPECT_EQ(reorganizeSpy.count(), 1); +} diff --git a/tests/game/data/cards.xml b/tests/game/data/cards.xml new file mode 100644 index 000000000..e315dbf48 --- /dev/null +++ b/tests/game/data/cards.xml @@ -0,0 +1,64 @@ + + + + + Cat + CAT + 0 + Meow! + + 111 + G + 2G + 2 + Creature — Cat + Creature + 3/3 + + + + Grizzly Bears + LEA + 0 + 1 + + + 112 + G + 1G + 2 + Creature — Bear + Creature + 2/2 + + + + Lightning Bolt + LEA + 3 + Lightning Bolt deals 3 damage to any target. + + 113 + R + R + 1 + Instant + Instant + + + + Forest + LEA + 2 + (Add G.) + + 114 + G + + 0 + Land + Land + + + + diff --git a/tests/game/data/spoilers.xml b/tests/game/data/spoilers.xml new file mode 100644 index 000000000..414ae10d4 --- /dev/null +++ b/tests/game/data/spoilers.xml @@ -0,0 +1,4 @@ + + + + diff --git a/tests/game/data/tokens.xml b/tests/game/data/tokens.xml new file mode 100644 index 000000000..30edbb24c --- /dev/null +++ b/tests/game/data/tokens.xml @@ -0,0 +1,21 @@ + + + + + Kitten + CAT + 0 + + 1 + + 211 + G + + + Token + Token + 1/1 + + + + diff --git a/tests/game/game_event_handler_test.cpp b/tests/game/game_event_handler_test.cpp new file mode 100644 index 000000000..2db6a1660 --- /dev/null +++ b/tests/game/game_event_handler_test.cpp @@ -0,0 +1,566 @@ +#include "game_test_fixture.h" + +#include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +/** + * Wraps one player-scoped event in the container the server would send and + * pushes it through the real dispatcher, so the test exercises the same + * GameEventHandler -> PlayerEventHandler path as a live client. + */ +template +void sendGameEvent(GameHarness &harness, + int playerId, + const EventType &message, + const GameEventContext *context = nullptr) +{ + GameEventContainer container; + container.set_game_id(42); + if (context != nullptr) { + container.mutable_context()->CopyFrom(*context); + } + + GameEvent *event = container.add_event_list(); + event->set_player_id(playerId); + event->MutableExtension(EventType::ext)->CopyFrom(message); + + harness.game()->getGameEventHandler()->processGameEventContainer(container, harness.client(), + EventProcessingOptions()); +} + +Event_SetCardAttr cardAttrEvent(const char *zoneName, int cardId, CardAttribute attribute, const char *value) +{ + Event_SetCardAttr event; + event.set_zone_name(zoneName); + event.set_card_id(cardId); + event.set_attribute(attribute); + event.set_attr_value(value); + return event; +} + +} // namespace + +TEST(GameEventHandlerTest, MoveCardMovesBetweenZones) +{ + GameHarness harness; + CardState *cat = harness.addCardToZone(ZoneNames::HAND, QStringLiteral("Cat"), 7); + ASSERT_NE(cat, nullptr); + + Event_MoveCard event; + event.set_card_id(7); + event.set_start_player_id(1); + event.set_start_zone(ZoneNames::HAND); + event.set_position(0); + event.set_target_player_id(1); + event.set_target_zone(ZoneNames::TABLE); + event.set_x(0); + event.set_y(TableZoneLogic::tableRowToGridY(0)); + event.set_new_card_id(7); + event.set_face_down(false); + + sendGameEvent(harness, 1, event); + + EXPECT_TRUE(harness.zone(ZoneNames::HAND)->getCards().isEmpty()); + CardZoneLogic *table = harness.zone(ZoneNames::TABLE); + ASSERT_EQ(table->getCards().size(), 1); + EXPECT_EQ(table->getCards().first(), cat); + EXPECT_EQ(cat->getZone(), table); + EXPECT_EQ(cat->getId(), 7); + EXPECT_EQ(cat->getCardRef().name, QStringLiteral("Cat")); + EXPECT_FALSE(cat->getFaceDown()); + EXPECT_EQ(cat->getPT(), QStringLiteral("3/3")); + EXPECT_EQ(cat->getGridPoint(), QPoint(0, TableZoneLogic::tableRowToGridY(0))); +} + +TEST(GameEventHandlerTest, MoveCardFaceDownKeepsTheCardHidden) +{ + GameHarness harness; + CardState *cat = harness.addCardToZone(ZoneNames::HAND, QStringLiteral("Cat"), 7); + ASSERT_NE(cat, nullptr); + + Event_MoveCard event; + event.set_card_id(7); + event.set_start_player_id(1); + event.set_start_zone(ZoneNames::HAND); + event.set_position(0); + event.set_target_player_id(1); + event.set_target_zone(ZoneNames::TABLE); + event.set_x(1); + event.set_y(TableZoneLogic::tableRowToGridY(2)); + event.set_new_card_id(7); + event.set_face_down(true); + + sendGameEvent(harness, 1, event); + + ASSERT_EQ(harness.zone(ZoneNames::TABLE)->getCards().size(), 1); + EXPECT_TRUE(cat->getFaceDown()); + // A face-down card keeps whatever P/T it had instead of picking up the + // printed one from the card database. + EXPECT_TRUE(cat->getPT().isEmpty()); +} + +TEST(GameEventHandlerTest, SetCardAttrUpdatesSingleCard) +{ + GameHarness harness; + CardState *cat = harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Cat"), 7); + ASSERT_NE(cat, nullptr); + + sendGameEvent(harness, 1, cardAttrEvent(ZoneNames::TABLE, 7, AttrTapped, "1")); + sendGameEvent(harness, 1, cardAttrEvent(ZoneNames::TABLE, 7, AttrAttacking, "1")); + sendGameEvent(harness, 1, cardAttrEvent(ZoneNames::TABLE, 7, AttrPT, "5/5")); + sendGameEvent(harness, 1, cardAttrEvent(ZoneNames::TABLE, 7, AttrAnnotation, "note")); + sendGameEvent(harness, 1, cardAttrEvent(ZoneNames::TABLE, 7, AttrColor, "red")); + sendGameEvent(harness, 1, cardAttrEvent(ZoneNames::TABLE, 7, AttrDoesntUntap, "1")); + sendGameEvent(harness, 1, cardAttrEvent(ZoneNames::TABLE, 7, AttrFaceDown, "1")); + + EXPECT_TRUE(cat->getTapped()); + EXPECT_TRUE(cat->getAttacking()); + EXPECT_EQ(cat->getPT(), QStringLiteral("5/5")); + EXPECT_EQ(cat->getAnnotation(), QStringLiteral("note")); + EXPECT_EQ(cat->getColor(), QStringLiteral("red")); + EXPECT_TRUE(cat->getDoesntUntap()); + EXPECT_TRUE(cat->getFaceDown()); +} + +TEST(GameEventHandlerTest, SetCardAttrWithoutCardIdTouchesEveryCardInZone) +{ + GameHarness harness; + CardState *cat = harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Cat"), 7); + CardState *bears = harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Grizzly Bears"), 9); + ASSERT_NE(cat, nullptr); + ASSERT_NE(bears, nullptr); + + Event_SetCardAttr event; + event.set_zone_name(ZoneNames::TABLE); + event.set_attribute(AttrAnnotation); + event.set_attr_value("marked"); + + sendGameEvent(harness, 1, event); + + EXPECT_EQ(cat->getAnnotation(), QStringLiteral("marked")); + EXPECT_EQ(bears->getAnnotation(), QStringLiteral("marked")); +} + +TEST(GameEventHandlerTest, UntapAllLeavesDoesntUntapCardsTapped) +{ + GameHarness harness; + CardState *cat = harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Cat"), 7); + CardState *bears = harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Grizzly Bears"), 9); + ASSERT_NE(cat, nullptr); + ASSERT_NE(bears, nullptr); + cat->setDoesntUntap(true); + + sendGameEvent(harness, 1, cardAttrEvent(ZoneNames::TABLE, 7, AttrTapped, "1")); + sendGameEvent(harness, 1, cardAttrEvent(ZoneNames::TABLE, 9, AttrTapped, "1")); + + Event_SetCardAttr untapAll; + untapAll.set_zone_name(ZoneNames::TABLE); + untapAll.set_attribute(AttrTapped); + untapAll.set_attr_value("0"); + + sendGameEvent(harness, 1, untapAll); + + EXPECT_TRUE(cat->getTapped()); + EXPECT_FALSE(bears->getTapped()); +} + +TEST(GameEventHandlerTest, SetCardCounterAddsAndRemoves) +{ + GameHarness harness; + CardState *cat = harness.addCardToZone(ZoneNames::HAND, QStringLiteral("Cat"), 7); + ASSERT_NE(cat, nullptr); + + Event_SetCardCounter add; + add.set_zone_name(ZoneNames::HAND); + add.set_card_id(7); + add.set_counter_id(1); + add.set_counter_value(2); + + sendGameEvent(harness, 1, add); + + EXPECT_EQ(cat->getCounters().value(1), 2); + + Event_SetCardCounter remove = add; + remove.set_counter_value(0); + + sendGameEvent(harness, 1, remove); + + EXPECT_FALSE(cat->getCounters().contains(1)); +} + +TEST(GameEventHandlerTest, AttachCardLinksBothEndsAndUnattachClearsThem) +{ + GameHarness harness; + CardState *host = harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Cat"), 7); + CardState *guest = harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Grizzly Bears"), 9); + ASSERT_NE(host, nullptr); + ASSERT_NE(guest, nullptr); + + Event_AttachCard attach; + attach.set_start_zone(ZoneNames::TABLE); + attach.set_card_id(9); + attach.set_target_player_id(1); + attach.set_target_zone(ZoneNames::TABLE); + attach.set_target_card_id(7); + + sendGameEvent(harness, 1, attach); + + EXPECT_EQ(guest->getAttachedTo(), host); + ASSERT_EQ(host->getAttachedCards().size(), 1); + EXPECT_EQ(host->getAttachedCards().first(), guest); + + Event_AttachCard detach; + detach.set_start_zone(ZoneNames::TABLE); + detach.set_card_id(9); + + sendGameEvent(harness, 1, detach); + + EXPECT_EQ(guest->getAttachedTo(), nullptr); + EXPECT_TRUE(host->getAttachedCards().isEmpty()); +} + +TEST(GameEventHandlerTest, ChangeZonePropertiesUpdatesReveal) +{ + GameHarness harness; + CardZoneLogic *deck = harness.zone(ZoneNames::DECK); + ASSERT_NE(deck, nullptr); + + Event_ChangeZoneProperties reveal; + reveal.set_zone_name(ZoneNames::DECK); + reveal.set_always_reveal_top_card(true); + + sendGameEvent(harness, 1, reveal); + EXPECT_TRUE(deck->getAlwaysRevealTopCard()); + + reveal.set_always_reveal_top_card(false); + sendGameEvent(harness, 1, reveal); + EXPECT_FALSE(deck->getAlwaysRevealTopCard()); +} + +TEST(GameEventHandlerTest, DrawCardsMovesNamedCardsFromDeckToHand) +{ + GameHarness harness; + ASSERT_NE(harness.addCardToZone(ZoneNames::DECK, QStringLiteral("Cat"), 7), nullptr); + + Event_DrawCards draw; + draw.set_number(1); + ServerInfo_Card *cardInfo = draw.add_cards(); + cardInfo->set_id(7); + cardInfo->set_name("Cat"); + + sendGameEvent(harness, 1, draw); + + EXPECT_TRUE(harness.zone(ZoneNames::DECK)->getCards().isEmpty()); + ASSERT_EQ(harness.zone(ZoneNames::HAND)->getCards().size(), 1); + CardState *drawn = harness.zone(ZoneNames::HAND)->getCards().first(); + EXPECT_EQ(drawn->getId(), 7); + EXPECT_EQ(drawn->getName(), QStringLiteral("Cat")); +} + +TEST(GameEventHandlerTest, FlipCardHidesAndRevealsTheCard) +{ + GameHarness harness; + CardState *cat = harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Cat"), 7); + ASSERT_NE(cat, nullptr); + + Event_FlipCard flip; + flip.set_zone_name(ZoneNames::TABLE); + flip.set_card_id(7); + flip.set_face_down(true); + + sendGameEvent(harness, 1, flip); + + EXPECT_TRUE(cat->getFaceDown()); + // Flipping face down does not touch the card reference: the client must not + // learn (or overwrite) the name of a card it is not allowed to see. + EXPECT_EQ(cat->getName(), QStringLiteral("Cat")); + + // Turning it face up carries the real printing, so the state picks it up. + flip.set_face_down(false); + flip.set_card_name("Grizzly Bears"); + + sendGameEvent(harness, 1, flip); + + EXPECT_FALSE(cat->getFaceDown()); + EXPECT_EQ(cat->getName(), QStringLiteral("Grizzly Bears")); +} + +TEST(GameEventHandlerTest, DestroyCardRemovesItAndDetachesAttachedCards) +{ + GameHarness harness; + CardState *host = harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Cat"), 7); + CardState *guest = harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Grizzly Bears"), 9); + ASSERT_NE(host, nullptr); + ASSERT_NE(guest, nullptr); + + guest->setAttachedTo(host); + ASSERT_EQ(guest->getAttachedTo(), host); + ASSERT_EQ(host->getAttachedCards().size(), 1); + + Event_DestroyCard destroy; + destroy.set_zone_name(ZoneNames::TABLE); + destroy.set_card_id(7); + + sendGameEvent(harness, 1, destroy); + + CardZoneLogic *table = harness.zone(ZoneNames::TABLE); + ASSERT_EQ(table->getCards().size(), 1); + EXPECT_EQ(table->getCards().first(), guest); + EXPECT_FALSE(table->getCards().contains(host)); + + // The attached card survives the host and is left un-attached, ready to be + // laid out again by the zone. + EXPECT_EQ(guest->getAttachedTo(), nullptr); + EXPECT_TRUE(host->getAttachedCards().isEmpty()); + EXPECT_EQ(guest->getZone(), table); +} + +TEST(GameEventHandlerTest, CreateTokenUsesDatabasePTUnlessFaceDown) +{ + GameHarness harness; + CardZoneLogic *table = harness.zone(ZoneNames::TABLE); + ASSERT_NE(table, nullptr); + + QList created; + QObject::connect(table, &CardZoneLogic::createCardRequested, table, + [&created](const ServerInfo_Card &cardInfo, bool) { created.append(cardInfo); }); + + Event_CreateToken token; + token.set_zone_name(ZoneNames::TABLE); + token.set_card_id(11); + token.set_card_name("Kitten"); + token.set_x(2); + token.set_y(1); + token.set_color("green"); + token.set_annotation("a kitten"); + token.set_destroy_on_zone_change(true); + + sendGameEvent(harness, 1, token); + + ASSERT_EQ(created.size(), 1); + // The event carries no P/T, so the token takes the printed one. + EXPECT_EQ(created.first().pt(), "1/1"); + EXPECT_EQ(created.first().name(), "Kitten"); + EXPECT_EQ(created.first().x(), 2); + EXPECT_EQ(created.first().y(), 1); + EXPECT_EQ(created.first().color(), "green"); + EXPECT_EQ(created.first().annotation(), "a kitten"); + EXPECT_TRUE(created.first().destroy_on_zone_change()); + EXPECT_FALSE(created.first().face_down()); + + // An explicit P/T always wins over the database. + Event_CreateToken explicitPt = token; + explicitPt.set_card_id(12); + explicitPt.set_pt("3/3"); + + sendGameEvent(harness, 1, explicitPt); + + ASSERT_EQ(created.size(), 2); + EXPECT_EQ(created.last().pt(), "3/3"); + + // A face-down token must not leak the printed P/T. + Event_CreateToken faceDown = token; + faceDown.set_card_id(13); + faceDown.set_face_down(true); + + sendGameEvent(harness, 1, faceDown); + + ASSERT_EQ(created.size(), 3); + EXPECT_TRUE(created.last().face_down()); + EXPECT_TRUE(created.last().pt().empty()); +} + +TEST(GameEventHandlerTest, ConcedeAndUnconcedeUpdatePlayerState) +{ + GameHarness harness; + ASSERT_NE(harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Cat"), 7), nullptr); + EXPECT_FALSE(harness.player()->getConceded()); + + Event_PlayerPropertiesChanged properties; + properties.mutable_player_properties()->set_player_id(1); + properties.mutable_player_properties()->set_conceded(true); + + GameEventContext concedeContext; + concedeContext.MutableExtension(Context_Concede::ext); + + sendGameEvent(harness, 1, properties, &concedeContext); + + EXPECT_TRUE(harness.player()->getConceded()); + // Conceding tears the board down for that player. + EXPECT_TRUE(harness.zone(ZoneNames::TABLE)->getCards().isEmpty()); + + Event_PlayerPropertiesChanged back = properties; + back.mutable_player_properties()->set_conceded(false); + + GameEventContext unconcedeContext; + unconcedeContext.MutableExtension(Context_Unconcede::ext); + + sendGameEvent(harness, 1, back, &unconcedeContext); + + EXPECT_FALSE(harness.player()->getConceded()); +} + +TEST(GameEventHandlerTest, SetActivePlayerAndPhaseUpdateGameState) +{ + GameHarness harness; + GameState *state = harness.game()->getGameState(); + ASSERT_NE(state, nullptr); + EXPECT_EQ(state->getActivePlayer(), -1); + EXPECT_EQ(state->getCurrentPhase(), -1); + + Event_SetActivePlayer setPlayer; + setPlayer.set_active_player_id(1); + + sendGameEvent(harness, 1, setPlayer); + + EXPECT_EQ(state->getActivePlayer(), 1); + + Event_SetActivePhase setPhase; + setPhase.set_phase(3); + + sendGameEvent(harness, 1, setPhase); + + EXPECT_EQ(state->getCurrentPhase(), 3); +} + +TEST(GameEventHandlerTest, CreateSetAndDeletePlayerCounter) +{ + GameHarness harness; + + ServerInfo_Counter counter; + counter.set_id(1); + counter.set_name("life"); + counter.set_radius(1); + counter.set_count(20); + + Event_CreateCounter create; + create.mutable_counter_info()->CopyFrom(counter); + + sendGameEvent(harness, 1, create); + + CounterState *state = harness.player()->getCounters().value(1); + ASSERT_NE(state, nullptr); + EXPECT_EQ(state->getName(), QStringLiteral("life")); + EXPECT_EQ(state->getValue(), 20); + EXPECT_EQ(harness.player()->getLifeCounter(), state); + + Event_SetCounter set; + set.set_counter_id(1); + set.set_value(15); + + sendGameEvent(harness, 1, set); + + EXPECT_EQ(state->getValue(), 15); + + // Updating a counter that does not exist is a no-op, not a crash. + Event_SetCounter unknown = set; + unknown.set_counter_id(99); + sendGameEvent(harness, 1, unknown); + + EXPECT_FALSE(harness.player()->getCounters().contains(99)); + + Event_DelCounter remove; + remove.set_counter_id(1); + + sendGameEvent(harness, 1, remove); + + EXPECT_FALSE(harness.player()->getCounters().contains(1)); + EXPECT_EQ(harness.player()->getLifeCounter(), nullptr); +} + +TEST(GameEventHandlerTest, LeaveRemovesThePlayer) +{ + GameHarness harness; + ASSERT_NE(harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Cat"), 7), nullptr); + ASSERT_NE(harness.game()->getPlayerManager()->getPlayer(1), nullptr); + + Event_Leave leave; + leave.set_reason(Event_Leave::USER_LEFT); + + sendGameEvent(harness, 1, leave); + + EXPECT_EQ(harness.game()->getPlayerManager()->getPlayer(1), nullptr); + EXPECT_TRUE(harness.game()->getPlayerManager()->getPlayers().isEmpty()); +} + +TEST(GameEventHandlerTest, KickedSignalsGameClosed) +{ + GameHarness harness; + + bool closed = false; + QObject::connect(harness.game()->getGameEventHandler(), &GameEventHandler::gameClosed, + [&closed] { closed = true; }); + + sendGameEvent(harness, 1, Event_Kicked()); + + EXPECT_TRUE(closed); +} + +TEST(GameEventHandlerTest, GameClosedMarksTheSessionClosed) +{ + GameHarness harness; + ASSERT_FALSE(harness.game()->getGameState()->isGameClosed()); + + Event_GameStateChanged started; + started.set_game_started(true); + sendGameEvent(harness, 1, started); + ASSERT_TRUE(harness.game()->getGameMetaInfo()->started()); + + sendGameEvent(harness, 1, Event_GameClosed()); + + EXPECT_TRUE(harness.game()->getGameState()->isGameClosed()); + EXPECT_FALSE(harness.game()->getGameMetaInfo()->started()); +} + +TEST(GameEventHandlerTest, GameHostChangedMovesTheHostRole) +{ + GameHarness harness; + + ServerInfo_User bob; + bob.set_name("Bob"); + bob.set_user_level(ServerInfo_User::IsUser); + ASSERT_NE(harness.game()->getPlayerManager()->addPlayer(2, bob), nullptr); + + sendGameEvent(harness, 2, Event_GameHostChanged()); + + EXPECT_EQ(harness.game()->getGameState()->getHostId(), 2); +} diff --git a/tests/game/game_smoke_test.cpp b/tests/game/game_smoke_test.cpp deleted file mode 100644 index 356d544bf..000000000 --- a/tests/game/game_smoke_test.cpp +++ /dev/null @@ -1,106 +0,0 @@ -/** - * @file game_smoke_test.cpp - * - * Links the game logic library into a target that contains no graphics code at all, and drives - * the two seams the graphics layer used to own: the attachment graph on CardState and the - * card-creation request a zone makes so the graphics side can build the matching card item. - */ - -#include "gtest/gtest.h" -#include -#include -#include -#include -#include -#include -#include - -namespace -{ - -CardRef cardRefFor(const QString &name) -{ - CardRef cardRef; - cardRef.name = name; - return cardRef; -} - -} // namespace - -TEST(CardStateTest, AttachmentGraphKeepsBothEndsConsistent) -{ - CardState host(nullptr, nullptr, cardRefFor(QStringLiteral("Host")), 1); - CardState guest(nullptr, nullptr, cardRefFor(QStringLiteral("Guest")), 2); - - guest.setAttachedTo(&host); - host.addAttachedCard(&guest); - - EXPECT_EQ(guest.getAttachedTo(), &host); - ASSERT_EQ(host.getAttachedCards().size(), 1); - EXPECT_EQ(host.getAttachedCards().first(), &guest); - - host.removeAttachedCard(&guest); - guest.setAttachedTo(nullptr); - - EXPECT_TRUE(host.getAttachedCards().isEmpty()); - EXPECT_EQ(guest.getAttachedTo(), nullptr); -} - -TEST(CardZoneLogicTest, AddsAndTakesCardsWithoutAGraphicsSide) -{ - HandZoneLogic zone(nullptr, QStringLiteral("hand"), false, false, true); - CardState card(nullptr, nullptr, cardRefFor(QStringLiteral("Lightning Bolt")), 7); - - QSignalSpy cardAddedSpy(&zone, &CardZoneLogic::cardAdded); - QSignalSpy cardCountSpy(&zone, &CardZoneLogic::cardCountChanged); - - zone.addCard(&card, false, 0); - - ASSERT_EQ(zone.getCards().size(), 1); - EXPECT_EQ(zone.getCards().first(), &card); - EXPECT_EQ(card.getZone(), &zone); - EXPECT_EQ(cardAddedSpy.count(), 1); - EXPECT_GE(cardCountSpy.count(), 1); - - EXPECT_EQ(zone.takeCard(0, 7), &card); - EXPECT_TRUE(zone.getCards().isEmpty()); - EXPECT_EQ(card.getId(), 7); -} - -TEST(CardZoneLogicTest, AsksTheGraphicsSideToCreateCards) -{ - HandZoneLogic zone(nullptr, QStringLiteral("library"), false, true, true); - QSignalSpy requestSpy(&zone, &CardZoneLogic::createCardRequested); - - ServerInfo_Card cardInfo; - cardInfo.set_name("Lightning Bolt"); - cardInfo.set_id(7); - cardInfo.set_x(0); - cardInfo.set_y(1); - - zone.createCardRequested(cardInfo, true); - - ASSERT_EQ(requestSpy.count(), 1); - const QList arguments = requestSpy.takeFirst(); - EXPECT_EQ(arguments.at(0).value().id(), 7); - EXPECT_TRUE(arguments.at(1).toBool()); -} - -int main(int argc, char **argv) -{ - // Keep SettingsCache reads and writes away from the real user profile: the game logic still - // reaches for a handful of settings, and the client settings sources linked into this test - // would otherwise touch the developer's own configuration. - QStandardPaths::setTestModeEnabled(true); - - QTemporaryDir home; - if (home.isValid()) { - qputenv("HOME", home.path().toLocal8Bit()); - } - - QCoreApplication app(argc, argv); - QLoggingCategory::setFilterRules("settings_cache.*=false"); - - ::testing::InitGoogleTest(&argc, argv); - return RUN_ALL_TESTS(); -} diff --git a/tests/game/game_test_fixture.h b/tests/game/game_test_fixture.h new file mode 100644 index 000000000..e4703c917 --- /dev/null +++ b/tests/game/game_test_fixture.h @@ -0,0 +1,129 @@ +/** + * @file game_test_fixture.h + * + * Fixtures for tests that need a complete game/player/zone graph without the + * client, a server or any graphics object. Commands are captured on a recording + * AbstractClient, which is the same seam the real client uses. + */ + +#ifndef COCKATRICE_TESTS_GAME_TEST_FIXTURE_H +#define COCKATRICE_TESTS_GAME_TEST_FIXTURE_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/** + * Captures every command the game logic sends, instead of writing it to a socket. + * + * AbstractClient::sendCommand uses a direct signal/slot connection in the same + * thread, so a command is in `sent` as soon as sendCommand returns. + */ +class RecordingClient : public AbstractClient +{ +public: + QList sent; + +protected: + void sendCommandContainer(const CommandContainer &cont) override + { + sent.append(cont); + } +}; + +/** + * Owns a local, single-client Game with one local player, plus the cards added + * to its zones. + */ +class GameHarness +{ +public: + explicit GameHarness(int playerId = 1) : playerId_(playerId), client_(std::make_unique()) + { + Event_GameJoined event; + event.set_host_id(playerId_); + event.set_player_id(playerId_); + event.mutable_game_info()->set_game_id(42); + event.mutable_game_info()->set_started(true); + + QList clients{client_.get()}; + QMap roomGameTypes; + game_ = std::make_unique(nullptr, true, clients, event, roomGameTypes); + + ServerInfo_User user; + user.set_name("Alice"); + user.set_user_level(ServerInfo_User::IsUser | ServerInfo_User::IsRegistered); + game_->getPlayerManager()->addPlayer(playerId_, user); + } + + ~GameHarness() + { + qDeleteAll(ownedCards_); + ownedCards_.clear(); + } + + GameHarness(const GameHarness &) = delete; + GameHarness &operator=(const GameHarness &) = delete; + + [[nodiscard]] Game *game() const + { + return game_.get(); + } + + [[nodiscard]] RecordingClient *client() const + { + return client_.get(); + } + + [[nodiscard]] PlayerLogic *player() const + { + return game_->getPlayerManager()->getPlayer(playerId_); + } + + [[nodiscard]] CardZoneLogic *zone(const QString &zoneName) const + { + return player()->getZones().value(zoneName); + } + + CardState *addCardToZone(const QString &zoneName, const QString &cardName, int cardId) + { + CardZoneLogic *targetZone = zone(zoneName); + if (!targetZone) { + return nullptr; + } + + auto *card = new CardState(nullptr, targetZone, CardRef{cardName, QString()}, cardId); + // The constructor only stores the ref; this is what fills in the card + // data (table row, P/T, ...) the actions read. + card->refreshCardInfo(); + targetZone->addCard(card, false, 0); + ownedCards_.append(card); + return card; + } + + [[nodiscard]] const CommandContainer &lastCommand() const + { + return client_->sent.last(); + } + +private: + int playerId_; + std::unique_ptr client_; + std::unique_ptr game_; + QList ownedCards_; +}; + +#endif // COCKATRICE_TESTS_GAME_TEST_FIXTURE_H diff --git a/tests/game/game_test_main.cpp b/tests/game/game_test_main.cpp new file mode 100644 index 000000000..47b9a6b8e --- /dev/null +++ b/tests/game/game_test_main.cpp @@ -0,0 +1,126 @@ +/** + * @file game_test_main.cpp + * + * Test entry point for the headless game-logic suite. The game library has no + * graphics dependency, so a QCoreApplication is enough; the only process-wide + * setup left is the card database, which the game logic still reaches through + * CardDatabaseManager and which therefore has to exist before the first zone is + * constructed. + */ + +#include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +class GameTestPathProvider : public ICardDatabasePathProvider +{ +public: + explicit GameTestPathProvider(QString baseDir) : baseDir_(std::move(baseDir)) + { + } + + QString getCardDatabasePath() const override + { + return baseDir_ + QStringLiteral("/cards.xml"); + } + + QString getCustomCardDatabasePath() const override + { + return baseDir_ + QStringLiteral("/customsets/"); + } + + QString getTokenDatabasePath() const override + { + return baseDir_ + QStringLiteral("/tokens.xml"); + } + + QString getSpoilerCardDatabasePath() const override + { + return baseDir_ + QStringLiteral("/spoilers.xml"); + } + +private: + QString baseDir_; +}; + +bool copyFixture(const QString &fileName, const QString &targetDir) +{ + const QString source = QString::fromUtf8(GAME_TEST_DATA_DIR) + QLatin1Char('/') + fileName; + const QString target = targetDir + QLatin1Char('/') + fileName; + if (QFile::copy(source, target)) { + return true; + } + + std::fprintf(stderr, "Failed to copy test fixture %s to %s\n", qPrintable(source), qPrintable(target)); + return false; +} + +} // namespace + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + + // gtest_discover_tests runs the binary with --gtest_list_tests while the + // build is still in progress. No test runs there, and building the card + // database would leave process-lifetime allocations that the sanitizer + // leg's default leak detection reports at exit, failing discovery. + if (::testing::GTEST_FLAG(list_tests)) { + return RUN_ALL_TESTS(); + } + + QStandardPaths::setTestModeEnabled(true); + + QTemporaryDir home; + if (!home.isValid()) { + std::fprintf(stderr, "Failed to create a temporary home directory\n"); + return EXIT_FAILURE; + } + qputenv("HOME", home.path().toLocal8Bit()); + + QCoreApplication app(argc, argv); + QLoggingCategory::setFilterRules(QStringLiteral("card_database.loading=false\n" + "card_database.loading.success_or_failure=false\n" + "settings_cache.*=false")); + + // Copy the fixtures so the database loader never writes its .cache file into + // the source tree, then point the singleton at the copy. + QTemporaryDir dataDir; + if (!dataDir.isValid() || !QDir().mkpath(dataDir.filePath(QStringLiteral("customsets")))) { + std::fprintf(stderr, "Failed to create a temporary card database directory\n"); + return EXIT_FAILURE; + } + if (!copyFixture(QStringLiteral("cards.xml"), dataDir.path()) || + !copyFixture(QStringLiteral("tokens.xml"), dataDir.path()) || + !copyFixture(QStringLiteral("spoilers.xml"), dataDir.path())) { + return EXIT_FAILURE; + } + + // Function-local static: constructed before the CardDatabase singleton below, + // so it is destroyed after it and the raw pointer stored in the manager stays + // valid for the database's lifetime. + static GameTestPathProvider pathProvider(dataDir.path()); + CardDatabaseManager::setCardDatabasePathProvider(&pathProvider); + + CardDatabase *database = CardDatabaseManager::getInstance(); + database->loadCardDatabases(); + if (database->getLoadStatus() != Ok) { + std::fprintf(stderr, "Failed to load the game test card database (status %d)\n", + static_cast(database->getLoadStatus())); + return EXIT_FAILURE; + } + + return RUN_ALL_TESTS(); +} diff --git a/tests/game/player_actions_test.cpp b/tests/game/player_actions_test.cpp new file mode 100644 index 000000000..11ae7338d --- /dev/null +++ b/tests/game/player_actions_test.cpp @@ -0,0 +1,119 @@ +#include "game_test_fixture.h" + +#include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include + +TEST(PlayerActionsTest, PlayCreatureSendsMoveToTable) +{ + GameHarness harness; + CardState *cat = harness.addCardToZone(ZoneNames::HAND, QStringLiteral("Cat"), 7); + ASSERT_NE(cat, nullptr); + + harness.player()->getPlayerActions()->playCard(cat, false); + + ASSERT_FALSE(harness.client()->sent.isEmpty()); + const CommandContainer &container = harness.lastCommand(); + ASSERT_EQ(container.game_command_size(), 1); + const Command_MoveCard command = container.game_command(0).GetExtension(Command_MoveCard::ext); + + EXPECT_EQ(command.start_player_id(), 1); + EXPECT_EQ(command.start_zone(), std::string(ZoneNames::HAND)); + EXPECT_EQ(command.target_player_id(), 1); + EXPECT_EQ(command.target_zone(), std::string(ZoneNames::TABLE)); + EXPECT_EQ(command.x(), -1); + EXPECT_EQ(command.y(), TableZoneLogic::tableRowToGridY(0)); + + ASSERT_EQ(command.cards_to_move().card_size(), 1); + const CardToMove &card = command.cards_to_move().card(0); + EXPECT_EQ(card.card_id(), 7); + EXPECT_TRUE(card.has_face_down()); + EXPECT_FALSE(card.face_down()); + EXPECT_TRUE(card.has_pt()); + EXPECT_EQ(card.pt(), "3/3"); + EXPECT_TRUE(card.has_tapped()); + EXPECT_FALSE(card.tapped()); +} + +TEST(PlayerActionsTest, PlayInstantSendsMoveToStack) +{ + GameHarness harness; + CardState *bolt = harness.addCardToZone(ZoneNames::HAND, QStringLiteral("Lightning Bolt"), 8); + ASSERT_NE(bolt, nullptr); + + harness.player()->getPlayerActions()->playCard(bolt, false); + + ASSERT_FALSE(harness.client()->sent.isEmpty()); + const CommandContainer &container = harness.lastCommand(); + ASSERT_EQ(container.game_command_size(), 1); + const Command_MoveCard command = container.game_command(0).GetExtension(Command_MoveCard::ext); + + EXPECT_EQ(command.target_zone(), std::string(ZoneNames::STACK)); + EXPECT_EQ(command.x(), -1); + EXPECT_EQ(command.y(), 0); + + ASSERT_EQ(command.cards_to_move().card_size(), 1); + const CardToMove &card = command.cards_to_move().card(0); + EXPECT_EQ(card.card_id(), 8); + // The stack branch deliberately leaves the other attributes unset. + EXPECT_FALSE(card.has_face_down()); + EXPECT_FALSE(card.has_pt()); + EXPECT_FALSE(card.has_tapped()); +} + +TEST(PlayerActionsTest, PlayFaceDownCreatureUsesFaceDownRow) +{ + GameHarness harness; + CardState *cat = harness.addCardToZone(ZoneNames::HAND, QStringLiteral("Cat"), 7); + ASSERT_NE(cat, nullptr); + + harness.player()->getPlayerActions()->playCard(cat, true); + + ASSERT_FALSE(harness.client()->sent.isEmpty()); + const CommandContainer &container = harness.lastCommand(); + ASSERT_EQ(container.game_command_size(), 1); + const Command_MoveCard command = container.game_command(0).GetExtension(Command_MoveCard::ext); + + EXPECT_EQ(command.target_zone(), std::string(ZoneNames::TABLE)); + EXPECT_EQ(command.x(), -1); + EXPECT_EQ(command.y(), TableZoneLogic::tableRowToGridY(2)); + + ASSERT_EQ(command.cards_to_move().card_size(), 1); + const CardToMove &card = command.cards_to_move().card(0); + EXPECT_EQ(card.card_id(), 7); + EXPECT_TRUE(card.has_face_down()); + EXPECT_TRUE(card.face_down()); + EXPECT_FALSE(card.has_pt()); + EXPECT_TRUE(card.has_tapped()); + EXPECT_FALSE(card.tapped()); +} + +TEST(PlayerActionsTest, PlayCiptCreatureSendsTapped) +{ + GameHarness harness; + CardState *bears = harness.addCardToZone(ZoneNames::HAND, QStringLiteral("Grizzly Bears"), 9); + ASSERT_NE(bears, nullptr); + + harness.player()->getPlayerActions()->playCard(bears, false); + + ASSERT_FALSE(harness.client()->sent.isEmpty()); + const CommandContainer &container = harness.lastCommand(); + ASSERT_EQ(container.game_command_size(), 1); + const Command_MoveCard command = container.game_command(0).GetExtension(Command_MoveCard::ext); + + EXPECT_EQ(command.target_zone(), std::string(ZoneNames::TABLE)); + + ASSERT_EQ(command.cards_to_move().card_size(), 1); + const CardToMove &card = command.cards_to_move().card(0); + EXPECT_EQ(card.card_id(), 9); + EXPECT_TRUE(card.has_face_down()); + EXPECT_FALSE(card.face_down()); + EXPECT_TRUE(card.has_pt()); + EXPECT_EQ(card.pt(), "2/2"); + EXPECT_TRUE(card.has_tapped()); + EXPECT_TRUE(card.tapped()); +} diff --git a/tests/game/replay_test.cpp b/tests/game/replay_test.cpp new file mode 100644 index 000000000..218ffdf51 --- /dev/null +++ b/tests/game/replay_test.cpp @@ -0,0 +1,349 @@ +/** + * @file replay_test.cpp + * + * Replays a synthetic GameReplay through the real dispatcher. This is the only + * place that exercises PlayerLogic::processPlayerInfo / processCardAttachment + * end to end: the game state arrives as a GAME_STATE_CHANGED event and the + * graphics side is replaced by a small materializer that mirrors what + * CardZone::onCreateCardRequested does with the logic objects. + * + * The replay is built in code rather than from a .cor file: event application + * is deterministic (the client never shuffles on its own for a replay), and a + * checked-in binary fixture would be opaque about what it asserts. + */ + +#include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +ServerInfo_Card makeCard(int id, const QString &name, int x = -1, int y = -1) +{ + ServerInfo_Card card; + card.set_id(id); + card.set_name(name.toStdString()); + card.set_x(x); + card.set_y(y); + return card; +} + +ServerInfo_Zone makeZone(const char *name, + ServerInfo_Zone::ZoneType type, + bool withCoords, + const QList &cards, + int cardCount = 0) +{ + ServerInfo_Zone zone; + zone.set_name(name); + zone.set_type(type); + zone.set_with_coords(withCoords); + for (const auto &card : cards) { + zone.add_card_list()->CopyFrom(card); + } + if (cardCount > 0) { + zone.set_card_count(cardCount); + } + return zone; +} + +ServerInfo_Player makePlayer(int playerId, const QString &name, const QList &zones) +{ + ServerInfo_Player player; + ServerInfo_PlayerProperties *properties = player.mutable_properties(); + properties->set_player_id(playerId); + properties->mutable_user_info()->set_name(name.toStdString()); + properties->mutable_user_info()->set_user_level(ServerInfo_User::IsUser); + for (const auto &zone : zones) { + player.add_zone_list()->CopyFrom(zone); + } + return player; +} + +template GameEventContainer makeContainer(int playerId, const EventType &message) +{ + GameEventContainer container; + container.set_game_id(42); + + GameEvent *event = container.add_event_list(); + event->set_player_id(playerId); + event->MutableExtension(EventType::ext)->CopyFrom(message); + + return container; +} + +/** + * Stands in for the graphics side of a replay: when the logic asks for a card + * item, create the CardState and add it to the zone, copying the state fields + * the real CardItem::processCardInfo applies. + * + * The production view owns the visual item; the state itself is all a headless + * test needs, and this is the smallest thing that lets processPlayerInfo build + * a real zone. + */ +class HeadlessZoneView : public QObject +{ +public: + using QObject::QObject; + + ~HeadlessZoneView() override + { + qDeleteAll(cards_); + cards_.clear(); + } + + void attach(PlayerLogic *player) + { + for (CardZoneLogic *zone : player->getZones()) { + connect(zone, &CardZoneLogic::createCardRequested, this, + [this, zone](const ServerInfo_Card &cardInfo, bool reorganize) { + materialize(zone, cardInfo, reorganize); + }); + } + } + +private: + void materialize(CardZoneLogic *zone, const ServerInfo_Card &info, bool reorganize) + { + const CardRef cardRef{QString::fromStdString(info.name()), QString::fromStdString(info.provider_id())}; + auto *card = new CardState(nullptr, zone, cardRef, info.id()); + // The real CardItem constructor refreshes the card info before + // processCardInfo overwrites the state fields. + card->refreshCardInfo(); + + card->clearCounters(); + for (int i = 0; i < info.counter_list_size(); ++i) { + card->insertCounter(info.counter_list(i).id(), info.counter_list(i).value()); + } + card->setAttacking(info.attacking()); + card->setFaceDown(info.face_down()); + card->setPT(QString::fromStdString(info.pt())); + card->setAnnotation(QString::fromStdString(info.annotation())); + card->setColor(QString::fromStdString(info.color())); + card->setTapped(info.tapped()); + card->setDestroyOnZoneChange(info.destroy_on_zone_change()); + card->setDoesntUntap(info.doesnt_untap()); + + zone->addCard(card, reorganize, info.x(), info.y()); + cards_.append(card); + } + + QList cards_; +}; + +/** Owns a Replay built from a GameReplay and feeds its containers through it. */ +class ReplayHarness +{ +public: + explicit ReplayHarness(GameReplay replay) + : replayProto_(std::move(replay)), view_(std::make_unique()), + replay_(std::make_unique(nullptr, &replayProto_, false)) + { + QObject::connect(replay_->getPlayerManager(), &PlayerManager::playerAdded, view_.get(), + &HeadlessZoneView::attach); + } + + ReplayHarness(const ReplayHarness &) = delete; + ReplayHarness &operator=(const ReplayHarness &) = delete; + + void processAll() + { + for (const GameEventContainer &container : replayProto_.event_list()) { + replay_->getGameEventHandler()->processGameEventContainer(container, nullptr, EventProcessingOptions()); + } + } + + [[nodiscard]] Replay *replay() const + { + return replay_.get(); + } + + [[nodiscard]] PlayerLogic *player(int playerId) const + { + return replay_->getPlayerManager()->getPlayer(playerId); + } + +private: + GameReplay replayProto_; + std::unique_ptr view_; + std::unique_ptr replay_; +}; + +GameReplay makeReplay(const QList &containers) +{ + GameReplay replay; + replay.mutable_game_info()->set_game_id(42); + replay.mutable_game_info()->set_started(true); + for (const auto &container : containers) { + replay.add_event_list()->CopyFrom(container); + } + return replay; +} + +Event_GameStateChanged initialGameState() +{ + Event_GameStateChanged state; + state.set_game_started(true); + state.set_active_player_id(1); + state.set_active_phase(0); + return state; +} + +} // namespace + +TEST(ReplayTest, MaterializesGameStateZonesAndAttachments) +{ + Event_GameStateChanged state = initialGameState(); + + ServerInfo_Card cat = makeCard(7, QStringLiteral("Cat")); + // Cross-player attachment: Alice's card is attached to Bob's creature. + cat.set_attach_player_id(2); + cat.set_attach_zone(ZoneNames::TABLE); + cat.set_attach_card_id(9); + + const ServerInfo_Player alice = makePlayer(1, QStringLiteral("Alice"), + {makeZone(ZoneNames::HAND, ServerInfo_Zone::PrivateZone, false, {cat}), + makeZone(ZoneNames::TABLE, ServerInfo_Zone::PublicZone, true, + {makeCard(9, QStringLiteral("Grizzly Bears"), 2, 1)}), + makeZone(ZoneNames::DECK, ServerInfo_Zone::HiddenZone, false, {}, 2)}); + const ServerInfo_Player bob = makePlayer(2, QStringLiteral("Bob"), + {makeZone(ZoneNames::TABLE, ServerInfo_Zone::PublicZone, true, + {makeCard(9, QStringLiteral("Grizzly Bears"), 1, 0)})}); + + state.add_player_list()->CopyFrom(alice); + state.add_player_list()->CopyFrom(bob); + + ReplayHarness harness(makeReplay({makeContainer(-1, state)})); + harness.processAll(); + + PlayerLogic *aliceLogic = harness.player(1); + PlayerLogic *bobLogic = harness.player(2); + ASSERT_NE(aliceLogic, nullptr); + ASSERT_NE(bobLogic, nullptr); + + CardZoneLogic *hand = aliceLogic->getZones().value(ZoneNames::HAND); + CardZoneLogic *table = aliceLogic->getZones().value(ZoneNames::TABLE); + CardZoneLogic *deck = aliceLogic->getZones().value(ZoneNames::DECK); + ASSERT_NE(hand, nullptr); + ASSERT_NE(table, nullptr); + ASSERT_NE(deck, nullptr); + + ASSERT_EQ(hand->getCards().size(), 1); + CardState *catState = hand->getCards().first(); + EXPECT_EQ(catState->getId(), 7); + EXPECT_EQ(catState->getName(), QStringLiteral("Cat")); + + ASSERT_EQ(table->getCards().size(), 1); + CardState *host = table->getCards().first(); + EXPECT_EQ(host->getName(), QStringLiteral("Grizzly Bears")); + EXPECT_EQ(host->getGridPoint(), QPoint(2, 1)); + + // The hidden deck only carries a count, and the view materializes that many + // unknown cards. + EXPECT_EQ(deck->getCards().size(), 2); + for (const CardState *hidden : deck->getCards()) { + EXPECT_EQ(hidden->getId(), -1); + } + + // processCardAttachment links both sides after every player is materialized. + CardZoneLogic *bobTable = bobLogic->getZones().value(ZoneNames::TABLE); + ASSERT_NE(bobTable, nullptr); + ASSERT_EQ(bobTable->getCards().size(), 1); + CardState *bobHost = bobTable->getCards().first(); + EXPECT_EQ(catState->getAttachedTo(), bobHost); + ASSERT_EQ(bobHost->getAttachedCards().size(), 1); + EXPECT_EQ(bobHost->getAttachedCards().first(), catState); + // The identically numbered card on Alice's own table is a different card. + EXPECT_TRUE(host->getAttachedCards().isEmpty()); + + EXPECT_TRUE(harness.replay()->getGameState()->isGameStateKnown()); +} + +TEST(ReplayTest, AppliesMovesAttributesAndCountersInOrder) +{ + Event_GameStateChanged state = initialGameState(); + state.add_player_list()->CopyFrom(makePlayer( + 1, QStringLiteral("Alice"), + {makeZone(ZoneNames::HAND, ServerInfo_Zone::PrivateZone, false, {makeCard(7, QStringLiteral("Cat"))}), + makeZone(ZoneNames::TABLE, ServerInfo_Zone::PublicZone, true, + {makeCard(9, QStringLiteral("Grizzly Bears"), 0, TableZoneLogic::tableRowToGridY(0))}), + makeZone(ZoneNames::DECK, ServerInfo_Zone::HiddenZone, false, {}, 2)})); + + Event_MoveCard move; + move.set_card_id(7); + move.set_start_player_id(1); + move.set_start_zone(ZoneNames::HAND); + move.set_position(0); + move.set_target_player_id(1); + move.set_target_zone(ZoneNames::TABLE); + move.set_x(3); + move.set_y(TableZoneLogic::tableRowToGridY(1)); + move.set_new_card_id(7); + move.set_face_down(false); + + Event_SetCardAttr tap; + tap.set_zone_name(ZoneNames::TABLE); + tap.set_card_id(7); + tap.set_attribute(AttrTapped); + tap.set_attr_value("1"); + + Event_SetCardCounter counter; + counter.set_zone_name(ZoneNames::TABLE); + counter.set_card_id(7); + counter.set_counter_id(1); + counter.set_counter_value(2); + + ReplayHarness harness(makeReplay( + {makeContainer(-1, state), makeContainer(1, move), makeContainer(1, tap), makeContainer(1, counter)})); + harness.processAll(); + + PlayerLogic *alice = harness.player(1); + ASSERT_NE(alice, nullptr); + CardZoneLogic *hand = alice->getZones().value(ZoneNames::HAND); + CardZoneLogic *table = alice->getZones().value(ZoneNames::TABLE); + ASSERT_NE(hand, nullptr); + ASSERT_NE(table, nullptr); + + EXPECT_TRUE(hand->getCards().isEmpty()); + ASSERT_EQ(table->getCards().size(), 2); + + CardState *moved = table->getCard(7); + ASSERT_NE(moved, nullptr); + EXPECT_EQ(moved->getGridPoint(), QPoint(3, TableZoneLogic::tableRowToGridY(1))); + EXPECT_EQ(moved->getPT(), QStringLiteral("3/3")); + EXPECT_TRUE(moved->getTapped()); + EXPECT_EQ(moved->getCounters().value(1), 2); + + // The untouched creature keeps its own position and state. + CardState *bears = table->getCard(9); + ASSERT_NE(bears, nullptr); + EXPECT_EQ(bears->getGridPoint(), QPoint(0, TableZoneLogic::tableRowToGridY(0))); + EXPECT_FALSE(bears->getTapped()); +} From 94e6281d87a8e55e1e150463ae0df78eece1676f Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 2 Nov 2026 07:36:49 +0100 Subject: [PATCH 094/108] [Logs] Browse room history without filters with paginated infinite scroll (#7531) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Logs] Browse room history without filters with paginated infinite scroll * [Logs] Continue pagination when switching log tabs --------- Co-authored-by: Lukas Brübach --- .../src/interface/widgets/tabs/tab_logs.cpp | 283 +++++++++++++----- .../src/interface/widgets/tabs/tab_logs.h | 31 ++ .../protocol/pb/moderator_commands.proto | 18 +- .../pb/response_viewlog_history.proto | 1 + .../src/servatrice_database_interface.cpp | 64 +++- .../src/servatrice_database_interface.h | 5 +- servatrice/src/serversocketinterface.cpp | 10 +- 7 files changed, 321 insertions(+), 91 deletions(-) diff --git a/cockatrice/src/interface/widgets/tabs/tab_logs.cpp b/cockatrice/src/interface/widgets/tabs/tab_logs.cpp index 67e076983..9509f48ec 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_logs.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_logs.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -21,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -31,6 +33,17 @@ class TabSupervisor; +namespace +{ +/** @brief Identifies the newest row of a page well enough to recognise a repeated page. */ +QString pageStartKey(const Response_ViewLogHistory &response) +{ + const ServerInfo_ChatMessage &first = response.log_message(0); + return QString::fromStdString(first.time()) + QChar(0x1f) + QString::fromStdString(first.sender_name()) + + QChar(0x1f) + QString::fromStdString(first.message()); +} +} // namespace + TabLog::TabLog(TabSupervisor *_tabSupervisor, AbstractClient *_client, bool _canUseDeveloperCommands) : Tab(_tabSupervisor), client(_client), canUseDeveloperCommands(_canUseDeveloperCommands) { @@ -52,12 +65,20 @@ TabLog::TabLog(TabSupervisor *_tabSupervisor, AbstractClient *_client, bool _can chatTable->setHorizontalHeaderLabels( QString(tr("Time;SenderName;SenderIP;Message;TargetID;TargetName")).split(";")); - QTabWidget *tabManager = new QTabWidget(); + tabManager = new QTabWidget(); tabManager->addTab(roomTable, tr("Room Logs")); tabManager->addTab(gameTable, tr("Game Logs")); tabManager->addTab(chatTable, tr("Chat Logs")); setCentralWidget(tabManager); + connect(roomTable->verticalScrollBar(), &QScrollBar::valueChanged, this, &TabLog::checkFetchMore); + connect(gameTable->verticalScrollBar(), &QScrollBar::valueChanged, this, &TabLog::checkFetchMore); + connect(chatTable->verticalScrollBar(), &QScrollBar::valueChanged, this, &TabLog::checkFetchMore); + // A tab that gains only a few rows has nothing to scroll, so its scrollbar never fires: + // switching to it would leave its next page unfetched until the moderator scrolled + // another tab. Re-check on every tab switch so the visible table can continue the browse. + connect(tabManager, &QTabWidget::currentChanged, this, &TabLog::checkFetchMore); + createDock(); restartLayout(); clearClicked(); @@ -74,12 +95,9 @@ void TabLog::retranslateUi() void TabLog::getClicked() { - if (findUsername->text().isEmpty() && findIPAddress->text().isEmpty() && findGameName->text().isEmpty() && - findGameID->text().isEmpty() && findMessage->text().isEmpty()) { - QMessageBox::critical(this, tr("Error"), tr("You must select at least one filter.")); - return; - } - + // Empty filters are a valid browse: room/game history going back from + // today, paginated. Only require a date range + location, both of which + // get sensible defaults below so a bare "Get" just works. if (!lastHour->isChecked() && !today->isChecked() && !pastDays->isChecked()) { pastDays->setChecked(true); pastXDays->setValue(20); @@ -99,39 +117,71 @@ void TabLog::getClicked() } if (maximumResults->value() == 0) { - maximumResults->setValue(1000); + maximumResults->setValue(200); } + pageSize = qBound(1, maximumResults->value(), 1000); - int dateRange = 0; - if (lastHour->isChecked()) { - dateRange = 1; - } - - if (today->isChecked()) { - dateRange = 24; - } - - if (pastDays->isChecked()) { - dateRange = pastXDays->value() * 24; - } - - Command_ViewLogHistory cmd; - cmd.set_user_name(findUsername->text().toStdString()); - cmd.set_ip_address(findIPAddress->text().toStdString()); - cmd.set_game_name(findGameName->text().toStdString()); - cmd.set_game_id(findGameID->text().toStdString()); - cmd.set_message(findMessage->text().toStdString()); + // Build the command once, here, and let every page copy it: rebuilding it from the + // widgets per page would run page 2+ against filters edited after Get was clicked, + // appending rows selected by a different query under the first page's results. + currentQuery.Clear(); + currentQuery.set_user_name(findUsername->text().toStdString()); + currentQuery.set_ip_address(findIPAddress->text().toStdString()); + currentQuery.set_game_name(findGameName->text().toStdString()); + currentQuery.set_game_id(findGameID->text().toStdString()); + currentQuery.set_message(findMessage->text().toStdString()); if (mainRoom->isChecked()) { - cmd.add_log_location("room"); - }; + currentQuery.add_log_location("room"); + } if (gameRoom->isChecked()) { - cmd.add_log_location("game"); - }; + currentQuery.add_log_location("game"); + } if (privateChat->isChecked()) { - cmd.add_log_location("chat"); - }; - cmd.set_date_range(dateRange); - cmd.set_maximum_results(maximumResults->value()); + currentQuery.add_log_location("chat"); + } + currentQuery.set_date_range(dateRangeForSelection()); + currentQuery.set_maximum_results(pageSize); + hasCurrentQuery = true; + snapshotBefore.clear(); + lastPageStartKey.clear(); + + // Fresh search: reset pagination state and clear previous pages. + currentOffset = 0; + roomCounter = 0; + gameCounter = 0; + chatCounter = 0; + roomTable->setRowCount(0); + gameTable->setRowCount(0); + chatTable->setRowCount(0); + + fetchPage(0); +} + +int TabLog::dateRangeForSelection() const +{ + if (lastHour->isChecked()) { + return 1; + } + if (today->isChecked()) { + return 24; + } + if (pastDays->isChecked()) { + return pastXDays->value() * 24; + } + return 0; +} + +void TabLog::fetchPage(int offset) +{ + if (isFetching || !hasCurrentQuery) { + return; + } + + Command_ViewLogHistory cmd = currentQuery; + cmd.set_offset(offset); + if (!snapshotBefore.isEmpty()) { + cmd.set_before_time(snapshotBefore.toStdString()); + } PendingCommand *pend; if (canUseDeveloperCommands) { @@ -141,10 +191,42 @@ void TabLog::getClicked() pend = client->prepareModeratorCommand(cmd); } + isFetching = true; + getButton->setEnabled(false); + connect(pend, &PendingCommand::finished, this, &TabLog::viewLogHistory_processResponse); client->sendCommand(pend); } +void TabLog::checkFetchMore() +{ + if (isFetching || !hasMoreResults) { + return; + } + + // Only the table the moderator is looking at may pull the next page. Checking all + // three would make an empty or hidden table nearBottom by definition (maximum() and + // value() are both zero), so a room-only browse chained page after page without any + // scrolling - pulling the whole range with a growing OFFSET scan per page, and + // looping forever against a server that ignores `offset`. + auto *table = qobject_cast(tabManager->currentWidget()); + if (table == nullptr || table->rowCount() == 0) { + return; + } + + // ScrollPerItem (the default) counts rows, not pixels: `value` is the top visible row + // and `maximum` the last one that can be scrolled to. Prefetch half a page ahead so + // scrolling never visibly stalls, without the fixed 300-row threshold firing the next + // page the moment a page loads. + const QScrollBar *bar = table->verticalScrollBar(); + const int prefetchRows = qMax(1, pageSize / 2); + if (bar->maximum() - bar->value() > prefetchRows) { + return; + } + + fetchPage(currentOffset); +} + void TabLog::clearClicked() { findUsername->clear(); @@ -166,6 +248,10 @@ void TabLog::clearClicked() pastDays->setAutoExclusive(true); today->setAutoExclusive(true); lastHour->setAutoExclusive(true); + currentOffset = 0; + hasMoreResults = false; + hasCurrentQuery = false; + snapshotBefore.clear(); } void TabLog::createDock() @@ -209,14 +295,15 @@ void TabLog::createDock() pastXDays = new QSpinBox; pastXDays->setMaximum(20); - labelMaximum = new QLabel(tr("Maximum Results: ")); + labelMaximum = new QLabel(tr("Results per page: ")); maximumResults = new QSpinBox; maximumResults->setMaximum(1000); + maximumResults->setToolTip(tr("Page size for smooth scrolling: more rows load automatically as you scroll.")); - labelDescription = new QLabel(tr( - "At least one filter is required.\nThe more information you put in, the more specific your results will be.")); + labelDescription = new QLabel(tr("Leave filters empty to browse recent room and game messages.\n" + "More rows load automatically as you scroll.")); - getButton = new QPushButton(tr("Get User Logs")); + getButton = new QPushButton(tr("Get Logs")); getButton->setAutoDefault(true); connect(getButton, &QPushButton::clicked, this, &TabLog::getClicked); @@ -262,7 +349,7 @@ void TabLog::createDock() maxResultsGrid->addWidget(labelMaximum, 0, 0); maxResultsGrid->addWidget(maximumResults, 0, 1); - maxResultsGroupBox = new QGroupBox(tr("Maximum Results")); + maxResultsGroupBox = new QGroupBox(tr("Results per page")); maxResultsGroupBox->setLayout(maxResultsGrid); descriptionGrid = new QGridLayout; @@ -295,84 +382,134 @@ void TabLog::createDock() searchDock->setWidget(searchDockContents); } +void TabLog::appendChatMessage(QTableWidget *table, + int &counter, + const QString &time, + const QString &senderName, + const QString &senderIp, + const QString &messageText, + const QString &targetId, + const QString &targetName, + const QString &channel) +{ + table->insertRow(counter); + table->setItem(counter, 0, new QTableWidgetItem(time)); + table->setItem(counter, 1, new QTableWidgetItem(senderName)); + table->setItem(counter, 2, new QTableWidgetItem(senderIp)); + table->setItem(counter, 3, new QTableWidgetItem(messageText)); + table->setItem(counter, 4, new QTableWidgetItem(targetId)); + table->setItem(counter, 5, new QTableWidgetItem(targetName)); + if (table->columnCount() > 6) { + table->setItem(counter, 6, new QTableWidgetItem(channel)); + } + ++counter; +} + void TabLog::viewLogHistory_processResponse(const Response &resp) { const Response_ViewLogHistory &response = resp.GetExtension(Response_ViewLogHistory::ext); + const bool isFirstPage = (currentOffset == 0); + isFetching = false; + getButton->setEnabled(true); + if (resp.response_code() != Response::RespOk) { QMessageBox::critical(static_cast(parent()), tr("Message History"), tr("Failed to collect message history information.")); return; } - if (response.log_message_size() == 0) { + if (isFirstPage && response.log_message_size() == 0) { QMessageBox::information(static_cast(parent()), tr("Message History"), tr("There are no messages for the selected filters.")); + hasMoreResults = false; return; } - int roomCounter = 0, gameCounter = 0, chatCounter = 0; - roomTable->setRowCount(roomCounter); - gameTable->setRowCount(gameCounter); - chatTable->setRowCount(chatCounter); + const QString thisPageStart = response.log_message_size() > 0 ? pageStartKey(response) : QString(); + + if (isFirstPage) { + // Rows come newest first, so the first one fixes the snapshot every later page is + // bounded by. Without it, each message logged between two page fetches pushes the + // window down by one and repeats a row the moderator already has. + snapshotBefore = QString::fromStdString(response.log_message(0).time()); + } else if (!response.has_has_more() && !thisPageStart.isEmpty() && thisPageStart == lastPageStartKey) { + // A server that predates `offset` ignores it and returns the same page again. It + // also predates `has_more`, so the full-page heuristic below can never prove + // exhaustion and every scroll would append the same rows forever. The identical + // first row is the proof; stop instead of looping. + hasMoreResults = false; + return; + } + lastPageStartKey = thisPageStart; for (int i = 0; i < response.log_message_size(); ++i) { ServerInfo_ChatMessage message = response.log_message(i); const QString targetType = QString::fromStdString(message.target_type()); if (targetType == "room" || targetType == "room_channel") { - roomTable->insertRow(roomCounter); - roomTable->setItem(roomCounter, 0, new QTableWidgetItem(QString::fromStdString(message.time()))); - roomTable->setItem(roomCounter, 1, new QTableWidgetItem(QString::fromStdString(message.sender_name()))); - roomTable->setItem(roomCounter, 2, new QTableWidgetItem(QString::fromStdString(message.sender_ip()))); - roomTable->setItem(roomCounter, 3, new QTableWidgetItem(QString::fromStdString(message.message()))); - roomTable->setItem(roomCounter, 4, new QTableWidgetItem(QString::fromStdString(message.target_id()))); - roomTable->setItem(roomCounter, 5, new QTableWidgetItem(QString::fromStdString(message.target_name()))); - roomTable->setItem(roomCounter, 6, new QTableWidgetItem(QString::fromStdString(message.channel()))); - ++roomCounter; + appendChatMessage(roomTable, roomCounter, QString::fromStdString(message.time()), + QString::fromStdString(message.sender_name()), + QString::fromStdString(message.sender_ip()), QString::fromStdString(message.message()), + QString::fromStdString(message.target_id()), + QString::fromStdString(message.target_name()), QString::fromStdString(message.channel())); } if (targetType == "game") { - gameTable->insertRow(gameCounter); - gameTable->setItem(gameCounter, 0, new QTableWidgetItem(QString::fromStdString(message.time()))); - gameTable->setItem(gameCounter, 1, new QTableWidgetItem(QString::fromStdString(message.sender_name()))); - gameTable->setItem(gameCounter, 2, new QTableWidgetItem(QString::fromStdString(message.sender_ip()))); - gameTable->setItem(gameCounter, 3, new QTableWidgetItem(QString::fromStdString(message.message()))); - gameTable->setItem(gameCounter, 4, new QTableWidgetItem(QString::fromStdString(message.target_id()))); - gameTable->setItem(gameCounter, 5, new QTableWidgetItem(QString::fromStdString(message.target_name()))); - ++gameCounter; + appendChatMessage(gameTable, gameCounter, QString::fromStdString(message.time()), + QString::fromStdString(message.sender_name()), + QString::fromStdString(message.sender_ip()), QString::fromStdString(message.message()), + QString::fromStdString(message.target_id()), + QString::fromStdString(message.target_name())); } if (targetType == "chat") { - chatTable->insertRow(chatCounter); - chatTable->setItem(chatCounter, 0, new QTableWidgetItem(QString::fromStdString(message.time()))); - chatTable->setItem(chatCounter, 1, new QTableWidgetItem(QString::fromStdString(message.sender_name()))); - chatTable->setItem(chatCounter, 2, new QTableWidgetItem(QString::fromStdString(message.sender_ip()))); - chatTable->setItem(chatCounter, 3, new QTableWidgetItem(QString::fromStdString(message.message()))); - chatTable->setItem(chatCounter, 4, new QTableWidgetItem(QString::fromStdString(message.target_id()))); - chatTable->setItem(chatCounter, 5, new QTableWidgetItem(QString::fromStdString(message.target_name()))); - ++chatCounter; + appendChatMessage(chatTable, chatCounter, QString::fromStdString(message.time()), + QString::fromStdString(message.sender_name()), + QString::fromStdString(message.sender_ip()), QString::fromStdString(message.message()), + QString::fromStdString(message.target_id()), + QString::fromStdString(message.target_name())); } } + currentOffset += response.log_message_size(); + if (response.has_has_more()) { + hasMoreResults = response.has_more(); + } else { + // Legacy server without has_more: a full page implies more may exist. + hasMoreResults = (response.log_message_size() >= pageSize); + } + if (roomCounter) { roomTable->show(); - roomTable->resizeColumnsToContents(); + if (isFirstPage) { + roomTable->resizeColumnsToContents(); + } } else { roomTable->hide(); } if (gameCounter) { - gameTable->resizeColumnsToContents(); + if (isFirstPage) { + gameTable->resizeColumnsToContents(); + } gameTable->show(); } else { gameTable->hide(); } if (chatCounter) { - chatTable->resizeColumnsToContents(); + if (isFirstPage) { + chatTable->resizeColumnsToContents(); + } chatTable->show(); } else { chatTable->hide(); } + + // The new rows may already fill less than a viewport (or the user is + // still parked at the bottom), so chain the next page without waiting + // for another scroll event. The prefetch stays invisible: rows simply + // keep appearing as you scroll. + checkFetchMore(); } void TabLog::restartLayout() diff --git a/cockatrice/src/interface/widgets/tabs/tab_logs.h b/cockatrice/src/interface/widgets/tabs/tab_logs.h index fc8ef1ea0..f2f5842c5 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_logs.h +++ b/cockatrice/src/interface/widgets/tabs/tab_logs.h @@ -10,6 +10,7 @@ #include "tab.h" #include +#include #include class AbstractClient; @@ -25,6 +26,7 @@ class QWidget; class QGridLayout; class QVBoxLayout; class QTableWidget; +class QTabWidget; class Response; class TabSupervisor; @@ -48,8 +50,37 @@ private: *buttonGroupBox; QVBoxLayout *mainLayout; QTableWidget *roomTable, *gameTable, *chatTable; + QTabWidget *tabManager = nullptr; + + //! The filters of the search in flight, captured when Get was clicked so that + //! later pages cannot pick up edits the moderator made after starting it. + Command_ViewLogHistory currentQuery; + bool hasCurrentQuery = false; + //! Newest `log_time` of the first page, sent back as the upper bound of every + //! later page so messages logged in the meantime do not shift the window. + QString snapshotBefore; + //! Identity of the previous page's first row, used to spot a server that ignores + //! `offset` and hands back the same page forever. + QString lastPageStartKey; + int currentOffset = 0; + int pageSize = 200; + int roomCounter = 0, gameCounter = 0, chatCounter = 0; + bool isFetching = false; + bool hasMoreResults = false; void createDock(); + [[nodiscard]] int dateRangeForSelection() const; + void fetchPage(int offset); + void appendChatMessage(QTableWidget *table, + int &counter, + const QString &time, + const QString &senderName, + const QString &senderIp, + const QString &messageText, + const QString &targetId, + const QString &targetName, + const QString &channel = QString()); + void checkFetchMore(); signals: private slots: diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto index 292917fa9..04cb31d98 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto @@ -87,14 +87,16 @@ message Command_ViewLogHistory { extend DeveloperCommand { optional Command_ViewLogHistory dev_ext = 1001; } - optional string user_name = 1; // user that created message - optional string ip_address = 2; // ip address of user that created message - optional string game_name = 3; // client id of user that created the message - optional string game_id = 4; // game number the message was sent to - optional string message = 5; // raw message that was sent - repeated string log_location = 6; // destination of message (ex: main room, game room, private chat) - required uint32 date_range = 7; // the length of time (in minutes) to look back for - optional uint32 maximum_results = 8; // the maximum number of query results + optional string user_name = 1; // user that created message + optional string ip_address = 2; // ip address of user that created message + optional string game_name = 3; // client id of user that created the message + optional string game_id = 4; // game number the message was sent to + optional string message = 5; // raw message that was sent + repeated string log_location = 6; // destination of message (ex: main room, game room, private chat) + required uint32 date_range = 7; // the length of time (in minutes) to look back for + optional uint32 maximum_results = 8; // the maximum number of query results (page size when paginating) + optional uint32 offset = 9 [default = 0]; // rows to skip for pagination, ORDER BY log_time DESC + optional string before_time = 10; // page those rows against this `log_time` snapshot (newest first page row) } message Command_GrantReplayAccess { diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_viewlog_history.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_viewlog_history.proto index 2ec0aff28..1041bca74 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/response_viewlog_history.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_viewlog_history.proto @@ -7,4 +7,5 @@ message Response_ViewLogHistory { optional Response_ViewLogHistory ext = 1015; } repeated ServerInfo_ChatMessage log_message = 1; + optional bool has_more = 2 [default = false]; // true when more rows exist past this page } diff --git a/servatrice/src/servatrice_database_interface.cpp b/servatrice/src/servatrice_database_interface.cpp index 45ae9bde8..a4f184e38 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -33,6 +33,7 @@ #include #include #include +#include #include #include #include @@ -2249,8 +2250,14 @@ QList Servatrice_DatabaseInterface::getMessageLogHistory bool &game, bool &room, int &range, - int &maxresults) + int &maxresults, + int offset, + const QString &beforeTime, + bool *hasMore) { + if (hasMore != nullptr) { + *hasMore = false; + } QList results; ServerInfo_ChatMessage chatMessage; @@ -2259,8 +2266,31 @@ QList Servatrice_DatabaseInterface::getMessageLogHistory return results; } - if (user.isEmpty() && ipaddress.isEmpty() && gameid.isEmpty() && gamename.isEmpty()) { - // To ensure quick results and minimal lag, require an indexed field + // Page size guard: always bound the scan so a filter-less browse of room + // history cannot become a 50M-row fetch. Zero means "client did not set a + // size", so fall back to one smooth-scroll page. + const int pageSize = maxresults <= 0 ? 200 : qBound(1, maxresults, 1000); + maxresults = pageSize; + const int safeOffset = qMax(0, offset); + + const bool hasIndexedFilter = !user.isEmpty() || !ipaddress.isEmpty() || !gameid.isEmpty() || !gamename.isEmpty(); + const bool hasLocationFilter = chat || game || room; + if (!hasLocationFilter) { + // No target_type restriction would mix private chat into a room/game + // browse, so refuse rather than leak. + return results; + } + // A browse narrowed only by a message substring has no index to serve it, and `range` + // has no upper bound on the wire: the client offers 20 days, but a hand-built command + // can ask for any value and every LIKE candidate in it is a row the server examines. + // Clamping here keeps the guard independent of what the client happens to send. + constexpr int maxFilterlessRangeHours = 20 * 24; + if (!hasIndexedFilter && range > maxFilterlessRangeHours) { + range = maxFilterlessRangeHours; + } + if (!hasIndexedFilter && range <= 0) { + // Filter-less browse must stay time-bounded (idx_log_time) so the + // query stays an index range scan instead of a full table scan. return results; } @@ -2315,10 +2345,23 @@ QList Servatrice_DatabaseInterface::getMessageLogHistory queryString.append(" AND log_time >= DATE_SUB(now(), INTERVAL :range_time HOUR)"); } - if (maxresults) { - queryString.append(" LIMIT :limit_size"); + if (!beforeTime.isEmpty()) { + // Pages after the first run against the snapshot fixed by the first one, so rows + // logged while the moderator scrolls can no longer push the OFFSET window down and + // repeat a row already on screen. + queryString.append(" AND log_time <= :before_time"); } + // Newest-first keeps "going back from today" stable across pages and lets + // the client append older rows at the bottom for smooth infinite scroll. + queryString.append(" ORDER BY log_time DESC"); + + // Fetch one extra row when the caller wants a has_more cursor so a full + // page does not need a second empty round-trip to prove exhaustion. + const bool detectMore = (hasMore != nullptr); + const int fetchSize = detectMore ? pageSize + 1 : pageSize; + queryString.append(" LIMIT :limit_size OFFSET :offset_size"); + QSqlQuery *query = prepareQuery(queryString); if (!user.isEmpty()) { query->bindValue(":user_name", user); @@ -2338,9 +2381,11 @@ QList Servatrice_DatabaseInterface::getMessageLogHistory if (range) { query->bindValue(":range_time", range); } - if (maxresults) { - query->bindValue(":limit_size", maxresults); + if (!beforeTime.isEmpty()) { + query->bindValue(":before_time", beforeTime); } + query->bindValue(":limit_size", fetchSize); + query->bindValue(":offset_size", safeOffset); if (!execSqlQuery(query)) { qCWarning(DatabaseInterfaceLog) << "Failed to collect log history information: SQL Error"; @@ -2360,6 +2405,11 @@ QList Servatrice_DatabaseInterface::getMessageLogHistory results << chatMessage; } + if (detectMore && results.size() > pageSize) { + results.removeLast(); + *hasMore = true; + } + return results; } diff --git a/servatrice/src/servatrice_database_interface.h b/servatrice/src/servatrice_database_interface.h index a27bd55b8..eb74063ac 100644 --- a/servatrice/src/servatrice_database_interface.h +++ b/servatrice/src/servatrice_database_interface.h @@ -219,7 +219,10 @@ public: bool &game, bool &room, int &range, - int &maxresults); + int &maxresults, + int offset = 0, + const QString &beforeTime = QString(), + bool *hasMore = nullptr); QList getUserSessions(const QString &userName, int limit); QList getUserAlts(const QString &userName); QList getModeratorLastLogins(); diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index 67e807380..c38d790b1 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -1743,12 +1743,17 @@ Response::ResponseCode AbstractServerSocketInterface::cmdGetLogHistory(const Com int dateRange = cmd.date_range(); int maximumResults = cmd.maximum_results(); + const int offset = cmd.has_offset() ? static_cast(cmd.offset()) : 0; + const QString beforeTime = cmd.has_before_time() ? QString::fromStdString(cmd.before_time()) : QString(); Response_ViewLogHistory *re = new Response_ViewLogHistory; if (servatrice->getEnableLogQuery()) { - QListIterator messageIterator(sqlInterface->getMessageLogHistory( - userName, ipAddress, gameName, gameID, message, chatType, gameType, roomType, dateRange, maximumResults)); + bool hasMore = false; + QList history = + sqlInterface->getMessageLogHistory(userName, ipAddress, gameName, gameID, message, chatType, gameType, + roomType, dateRange, maximumResults, offset, beforeTime, &hasMore); + QListIterator messageIterator(history); while (messageIterator.hasNext()) { ServerInfo_ChatMessage chatMessage = messageIterator.next(); if (!allowPrivateChat) { @@ -1756,6 +1761,7 @@ Response::ResponseCode AbstractServerSocketInterface::cmdGetLogHistory(const Com } re->add_log_message()->CopyFrom(chatMessage); } + re->set_has_more(hasMore); } else { ServerInfo_ChatMessage chatMessage; From a1a8a59f4a7a5d27cf3f7252beadbbac334d3378 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 2 Nov 2026 08:35:58 +0100 Subject: [PATCH 095/108] [VisualDatabase] Draw the card grid with a model/view (#7701) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [VisualDatabase] Draw the card grid with a model/view instead of a widget per card The visual card database built one CardInfoPictureWithTextOverlayWidget per card, 100 per page, and tore them down on every search, resize and scroll page. It also looked each card up again by name from the model row, kept a shadow QList, and carried paging state plus a 5 s timer that only existed to defer the first page. Replace that with a real view on a flattened model: - CardDatabaseDisplayModel gains CardInfoRole and cardInfoAt(), so a view can get the CardInfoPtr behind a proxy row without a name round trip. - New CardDatabaseVisualDisplayModel (libcockatrice_models) flattens the filtered and sorted proxy into one entry per displayed card picture: the preferred printing normally, every printing of the selected sets while set filters are active. Proxy changes rebuild it on a 30 ms coalescing timer. - New CardDatabaseVisualView (QListView IconMode) paints only visible rows through the new CardPictureDelegate, keeps the old interaction contract (left/right/middle press -> cardClicked, hover -> cardHovered, Ctrl+wheel resize, no selection, no focus stealing, scroll to top on a new result) and repaints when CardPictureLoader reports an async image. - CardSizeWidget::enableCtrlScrollResize() also watches scroll-area viewports, which is what makes Ctrl+wheel work over a QListView. - VisualDatabaseDisplayWidget drops the paging, the shadow list, the dead members and the 5 s timer. Behaviour-preserving: no gameplay change, no new user-facing strings. * [VisualDatabase] Fix the card grid wheel step QListView hands the delegate's item height to the scroll bar as its single step under ScrollPerPixel, so QAbstractSlider moved wheelScrollLines() whole card rows per notch. Handle the wheel in the view instead: one line is an eighth of the grid cell, so a notch moves about a third of a card row at any card size and still follows the OS line count. --------- Co-authored-by: Lukas Brübach --- cockatrice/CMakeLists.txt | 2 + cockatrice/src/filters/filter_tree_model.cpp | 72 +++--- cockatrice/src/filters/filter_tree_model.h | 14 ++ .../card_picture_loader.cpp | 1 + .../card_picture_loader/card_picture_loader.h | 8 + .../cards/card_info_picture_widget.cpp | 19 +- .../widgets/cards/card_info_picture_widget.h | 11 + .../widgets/cards/card_picture_delegate.cpp | 83 +++++++ .../widgets/cards/card_picture_delegate.h | 45 ++++ .../widgets/cards/card_size_widget.cpp | 30 ++- .../widgets/cards/card_size_widget.h | 9 +- .../card_database_visual_view.cpp | 203 +++++++++++++++++ .../card_database_visual_view.h | 67 ++++++ .../visual_database_display_widget.cpp | 215 +++--------------- .../visual_database_display_widget.h | 49 ++-- .../libcockatrice/filters/filter_card.h | 2 +- .../models/database/CMakeLists.txt | 2 + .../database/card_database_display_model.cpp | 22 +- .../database/card_database_display_model.h | 24 ++ .../card_database_visual_display_model.cpp | 123 ++++++++++ .../card_database_visual_display_model.h | 90 ++++++++ tests/carddatabase/CMakeLists.txt | 15 ++ .../visual_display_model_test.cpp | 188 +++++++++++++++ vcpkg | 2 +- 24 files changed, 1019 insertions(+), 277 deletions(-) create mode 100644 cockatrice/src/interface/widgets/cards/card_picture_delegate.cpp create mode 100644 cockatrice/src/interface/widgets/cards/card_picture_delegate.h create mode 100644 cockatrice/src/interface/widgets/visual_database_display/card_database_visual_view.cpp create mode 100644 cockatrice/src/interface/widgets/visual_database_display/card_database_visual_view.h create mode 100644 libcockatrice_models/libcockatrice/models/database/card_database_visual_display_model.cpp create mode 100644 libcockatrice_models/libcockatrice/models/database/card_database_visual_display_model.h create mode 100644 tests/carddatabase/visual_display_model_test.cpp diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index ce7ad5712..642d998a0 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -171,6 +171,7 @@ set(cockatrice_SOURCES src/interface/widgets/cards/card_info_picture_widget.cpp src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.cpp src/interface/widgets/cards/card_info_text_widget.cpp + src/interface/widgets/cards/card_picture_delegate.cpp src/interface/widgets/cards/card_size_widget.cpp src/interface/widgets/cards/deck_card_zone_display_widget.cpp src/interface/widgets/cards/deck_preview_card_picture_widget.cpp @@ -290,6 +291,7 @@ set(cockatrice_SOURCES src/interface/widgets/utility/sequence_edit.cpp src/interface/widgets/utility/visibility_change_listener.cpp src/interface/widgets/utility/visibility_change_listener.h + src/interface/widgets/visual_database_display/card_database_visual_view.cpp src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.cpp src/interface/widgets/visual_database_display/visual_database_display_filter_save_load_widget.cpp src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp diff --git a/cockatrice/src/filters/filter_tree_model.cpp b/cockatrice/src/filters/filter_tree_model.cpp index b3bf307f1..9b55ce2fd 100644 --- a/cockatrice/src/filters/filter_tree_model.cpp +++ b/cockatrice/src/filters/filter_tree_model.cpp @@ -11,6 +11,27 @@ class QObject; +namespace +{ +/** + * @brief Visits every filter item below @p node, depth first. + */ +template void forEachFilter(const FilterTreeNode *node, const Callback &callback) +{ + if (node == nullptr) { + return; + } + + if (const auto *filterItem = dynamic_cast(node)) { + callback(filterItem); + } + + for (int i = 0; i < node->childCount(); ++i) { + forEachFilter(node->nodeAt(i), callback); + } +} +} // namespace + FilterTreeModel::FilterTreeModel(QObject *parent) : QAbstractItemModel(parent) { fTree = new FilterTree; @@ -110,46 +131,31 @@ void FilterTreeModel::clearFiltersOfType(CardFilter::Attr filterType) QList FilterTreeModel::getFiltersOfType(CardFilter::Attr filterType) const { QList filters; - if (!fTree) { - return filters; - } - - std::function traverse; - traverse = [&](const FilterTreeNode *node) { - if (const auto *filterItem = dynamic_cast(node)) { - if (filterItem->attr() == filterType) { - QString text = filterItem->text(); - filters.append(new CardFilter(text, filterItem->type(), filterItem->attr())); - } + forEachFilter(fTree, [&](const FilterItem *filterItem) { + if (filterItem->attr() == filterType) { + filters.append(new CardFilter(filterItem->text(), filterItem->type(), filterItem->attr())); } - for (int i = 0; i < node->childCount(); ++i) { - traverse(node->nodeAt(i)); - } - }; - - traverse(fTree); + }); return filters; } +QStringList FilterTreeModel::getFilterTermsOfType(CardFilter::Attr filterType) const +{ + QStringList terms; + forEachFilter(fTree, [&](const FilterItem *filterItem) { + if (filterItem->attr() == filterType) { + terms.append(filterItem->text()); + } + }); + return terms; +} + QList FilterTreeModel::allFilters() const { QList filters; - if (!fTree) { - return filters; - } - - std::function traverse; - traverse = [&](const FilterTreeNode *node) { - if (const auto *filterItem = dynamic_cast(node)) { - QString text = filterItem->text(); - filters.append(new CardFilter(text, filterItem->type(), filterItem->attr())); - } - for (int i = 0; i < node->childCount(); ++i) { - traverse(node->nodeAt(i)); - } - }; - - traverse(fTree); + forEachFilter(fTree, [&](const FilterItem *filterItem) { + filters.append(new CardFilter(filterItem->text(), filterItem->type(), filterItem->attr())); + }); return filters; } diff --git a/cockatrice/src/filters/filter_tree_model.h b/cockatrice/src/filters/filter_tree_model.h index 826fcc941..05f34f508 100644 --- a/cockatrice/src/filters/filter_tree_model.h +++ b/cockatrice/src/filters/filter_tree_model.h @@ -28,7 +28,14 @@ public slots: void addFilter(const CardFilter *f); void removeFilter(const CardFilter *f); void clearFiltersOfType(CardFilter::Attr filterType); + /** + * @brief Copies every active filter of one attribute into new CardFilter objects. + * + * The caller owns the returned filters. Prefer getFilterTermsOfType() when only + * the terms are needed. + */ [[nodiscard]] QList getFiltersOfType(CardFilter::Attr filterType) const; + /// @brief Copies every active filter into new CardFilter objects. The caller owns them. [[nodiscard]] QList allFilters() const; private slots: @@ -48,6 +55,13 @@ public: { return fTree; } + + /** + * @brief The texts of every active filter of one attribute, without allocating filters. + * + * Prefer this over getFiltersOfType() when only the filter terms are needed. + */ + [[nodiscard]] QStringList getFilterTermsOfType(CardFilter::Attr filterType) const; [[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override; [[nodiscard]] int columnCount(const QModelIndex & /*parent*/ = QModelIndex()) const override; [[nodiscard]] QVariant data(const QModelIndex &index, int role) const override; diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp index 4f77c7096..2d35538d2 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp @@ -238,6 +238,7 @@ void CardPictureLoader::imageLoaded(const ExactCard &card, const QImage &image) }); card.emitPixmapUpdated(); + emit getInstance().pixmapLoaded(card); } void CardPictureLoader::deleteAllLocalOverrides(const ExactCard &card) diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader.h b/cockatrice/src/interface/card_picture_loader/card_picture_loader.h index c6d91570b..0c73e5d59 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader.h +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader.h @@ -146,6 +146,14 @@ signals: /** @brief Emitted after the worker has finished clearing the network and redirect caches. */ void networkCacheCleared(); + /** + * @brief Emitted once an image has been loaded and put into the pixmap cache. + * + * Views that paint cards from the loader without owning a widget per card can + * listen to this to repaint the rows whose picture just became available. + */ + void pixmapLoaded(const ExactCard &card); + private slots: /** * @brief Triggered when the user changes the picture download settings. diff --git a/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp b/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp index 0d94e7209..8f02ebcde 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp @@ -43,11 +43,6 @@ #include #include -static constexpr qreal MTG_CARD_ASPECT_RATIO = 1.396; -// static constexpr qreal YUGIOH_CARD_ASPECT_RATIO = 1.457; -static constexpr qreal ASPECT_RATIO = MTG_CARD_ASPECT_RATIO; - -static constexpr int BASE_WIDTH = 200; static constexpr int BASE_HEIGHT = 200; static constexpr int ENLARGED_PIXMAP_OFFSET = 10; @@ -162,12 +157,11 @@ void CardInfoPictureWidget::resizeEvent(QResizeEvent *event) */ void CardInfoPictureWidget::setScaleFactor(const int scale) { - const int newWidth = BASE_WIDTH * scale / 100; - const int newHeight = static_cast(newWidth * ASPECT_RATIO); + const QSize newSize = sizeForScaleFactor(scale); scaleFactor = scale; - setFixedSize(newWidth, newHeight); + setFixedSize(newSize); updatePixmap(); emit cardScaleFactorChanged(scale); @@ -266,8 +260,13 @@ void CardInfoPictureWidget::paintEvent(QPaintEvent *event) */ QSize CardInfoPictureWidget::sizeHint() const { - return {static_cast(BASE_WIDTH * scaleFactor / 100.0), - static_cast(BASE_WIDTH * scaleFactor / 100.0 * ASPECT_RATIO)}; + return sizeForScaleFactor(scaleFactor); +} + +QSize CardInfoPictureWidget::sizeForScaleFactor(int scaleFactor) +{ + const int width = BASE_WIDTH * scaleFactor / 100; + return {width, static_cast(width * ASPECT_RATIO)}; } /** diff --git a/cockatrice/src/interface/widgets/cards/card_info_picture_widget.h b/cockatrice/src/interface/widgets/cards/card_info_picture_widget.h index 8d2d63736..0eb731d92 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_picture_widget.h +++ b/cockatrice/src/interface/widgets/cards/card_info_picture_widget.h @@ -28,6 +28,11 @@ class CardInfoPictureWidget : public QWidget Q_OBJECT public: + /// Width of a card picture at 100 % scale, in pixels. + static constexpr int BASE_WIDTH = 200; + /// Height / width of a card picture. + static constexpr qreal ASPECT_RATIO = 1.396; + explicit CardInfoPictureWidget(QWidget *parent = nullptr, bool hoverToZoomEnabled = false, bool raiseOnEnter = false); @@ -37,6 +42,12 @@ public: } [[nodiscard]] QSize sizeHint() const override; + /** + * @brief The size a card picture has at @p scaleFactor percent. + * @param scaleFactor Percentage of BASE_WIDTH, as set by the card size slider. + */ + [[nodiscard]] static QSize sizeForScaleFactor(int scaleFactor); + public slots: void setCard(const ExactCard &card); void setScaleFactor(int scale); // New slot for scaling diff --git a/cockatrice/src/interface/widgets/cards/card_picture_delegate.cpp b/cockatrice/src/interface/widgets/cards/card_picture_delegate.cpp new file mode 100644 index 000000000..781c767ef --- /dev/null +++ b/cockatrice/src/interface/widgets/cards/card_picture_delegate.cpp @@ -0,0 +1,83 @@ +#include "card_picture_delegate.h" + +#include "../../../client/settings/cache_settings.h" +#include "../../card_picture_loader/card_picture_loader.h" +#include "card_art_utils.h" +#include "card_database_visual_display_model.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "libcockatrice/settings/cards_display_settings.h" + +#include +#include +#include +#include +#include +#include + +CardPictureDelegate::CardPictureDelegate(CardDatabaseVisualDisplayModel *_model, QObject *parent) + : QStyledItemDelegate(parent), model(_model) +{ +} + +void CardPictureDelegate::setCardSize(const QSize &size) +{ + if (cardSize == size) { + return; + } + + cardSize = size; +} + +void CardPictureDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const +{ + if (model == nullptr) { + return; + } + + const ExactCard card = model->cardAt(index.row()); + if (!card) { + return; + } + + // The grid cell is the card plus SPACING; give the picture the rest. + const int inset = SPACING / 2; + const QRect cardArea = option.rect.adjusted(inset, inset, -inset, -inset); + + QPixmap pixmap; + CardPictureLoader::getCardBackLoadingInProgressPixmap(pixmap, cardArea.size()); + CardPictureLoader::getPixmap(pixmap, card, cardArea.size()); + + if (pixmap.isNull()) { + return; + } + + if (SettingsCache::instance().cardsDisplay().getAutoRotateSidewaysLayoutCards()) { + pixmap = CardArtUtils::rotateSidewaysLayoutArt(pixmap, card); + } + + // Fit the picture into the cell and center it, like CardInfoPictureWidget does. + const QSize pictureSize = pixmap.deviceIndependentSize().toSize().scaled(cardArea.size(), Qt::KeepAspectRatio); + const QRect pictureRect(cardArea.center().x() - pictureSize.width() / 2, + cardArea.center().y() - pictureSize.height() / 2, pictureSize.width(), + pictureSize.height()); + + const qreal radius = SettingsCache::instance().cardsDisplay().getRoundCardCorners() + ? 0.05 * static_cast(pictureRect.width()) + : 0.0; + + painter->save(); + painter->setRenderHint(QPainter::Antialiasing); + painter->setRenderHint(QPainter::SmoothPixmapTransform); + QPainterPath shape; + shape.addRoundedRect(pictureRect, radius, radius); + painter->setClipPath(shape); + // Source rect is in device pixels, target in logical ones: drawPixmap scales + // between them without the device pixel ratio being applied twice. + painter->drawPixmap(pictureRect, pixmap, QRectF(pixmap.rect())); + painter->restore(); +} + +QSize CardPictureDelegate::sizeHint(const QStyleOptionViewItem & /*option*/, const QModelIndex & /*index*/) const +{ + return cardSize + QSize(SPACING, SPACING); +} diff --git a/cockatrice/src/interface/widgets/cards/card_picture_delegate.h b/cockatrice/src/interface/widgets/cards/card_picture_delegate.h new file mode 100644 index 000000000..31e673624 --- /dev/null +++ b/cockatrice/src/interface/widgets/cards/card_picture_delegate.h @@ -0,0 +1,45 @@ +/** + * @file card_picture_delegate.h + * @ingroup CardWidgets + * @brief Paints a card picture for a visual card grid item. + */ + +#ifndef CARD_PICTURE_DELEGATE_H +#define CARD_PICTURE_DELEGATE_H + +#include +#include +#include + +class CardDatabaseVisualDisplayModel; + +/** + * @brief Draws the card of a CardDatabaseVisualDisplayModel row as a picture. + * + * The delegate only paints rows the view asks for, so a grid of the whole card + * database stays cheap: no widget per card and no pixmap loaded for items that + * are off screen. Card images come from CardPictureLoader, which caches them and + * loads missing ones asynchronously. + */ +class CardPictureDelegate : public QStyledItemDelegate +{ + Q_OBJECT + +public: + /// Gap left around a card picture inside its grid cell, in pixels. + static constexpr int SPACING = 4; + + explicit CardPictureDelegate(CardDatabaseVisualDisplayModel *_model, QObject *parent = nullptr); + + /// The picture size inside a grid cell. The cell is this size plus SPACING. + void setCardSize(const QSize &size); + + void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; + [[nodiscard]] QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override; + +private: + CardDatabaseVisualDisplayModel *model; + QSize cardSize; +}; + +#endif // CARD_PICTURE_DELEGATE_H diff --git a/cockatrice/src/interface/widgets/cards/card_size_widget.cpp b/cockatrice/src/interface/widgets/cards/card_size_widget.cpp index ab1840591..ac1ab9cb1 100644 --- a/cockatrice/src/interface/widgets/cards/card_size_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_size_widget.cpp @@ -2,6 +2,7 @@ #include "../general/layout_containers/flow_widget.h" +#include #include #include #include @@ -33,8 +34,8 @@ CardSizeWidget::CardSizeWidget(QWidget *parent, FlowWidget *_flowWidget, int def cardSizeLabel->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum); cardSizeSlider = new QSlider(Qt::Horizontal, this); - cardSizeSlider->setRange(50, 250); ///< Slider range for card size adjustment. - cardSizeSlider->setValue(defaultValue); ///< Initial slider value. + cardSizeSlider->setRange(MIN_CARD_SIZE, MAX_CARD_SIZE); ///< Slider range for card size adjustment. + cardSizeSlider->setValue(defaultValue); ///< Initial slider value. cardSizeLayout->addWidget(cardSizeLabel); cardSizeLayout->addWidget(cardSizeSlider); @@ -73,13 +74,26 @@ QSlider *CardSizeWidget::getSlider() const void CardSizeWidget::enableCtrlScrollResize(QWidget *host) { - host->installEventFilter(this); - - const auto scrollAreas = host->findChildren(); - for (QScrollArea *scrollArea : scrollAreas) { - if (QWidget *content = scrollArea->widget()) { - content->installEventFilter(this); + const auto watchScrollArea = [this](QAbstractScrollArea *scrollArea) { + if (scrollArea == nullptr) { + return; } + if (QWidget *viewport = scrollArea->viewport()) { + viewport->installEventFilter(this); + } + if (auto *contentScrollArea = qobject_cast(scrollArea)) { + if (QWidget *content = contentScrollArea->widget()) { + content->installEventFilter(this); + } + } + }; + + host->installEventFilter(this); + watchScrollArea(qobject_cast(host)); + + const auto scrollAreas = host->findChildren(); + for (QAbstractScrollArea *scrollArea : scrollAreas) { + watchScrollArea(scrollArea); } } diff --git a/cockatrice/src/interface/widgets/cards/card_size_widget.h b/cockatrice/src/interface/widgets/cards/card_size_widget.h index 7de97a5e9..fced90d33 100644 --- a/cockatrice/src/interface/widgets/cards/card_size_widget.h +++ b/cockatrice/src/interface/widgets/cards/card_size_widget.h @@ -24,6 +24,10 @@ class CardSizeWidget : public QWidget Q_OBJECT public: + /// Range of the card size slider, as a percentage of the base card width. + static constexpr int MIN_CARD_SIZE = 50; + static constexpr int MAX_CARD_SIZE = 250; + explicit CardSizeWidget(QWidget *parent, FlowWidget *flowWidget = nullptr, int defaultValue = 100); [[nodiscard]] QSlider *getSlider() const; @@ -31,8 +35,9 @@ public: * @brief Resizes the cards when the user Ctrl + scrolls over @p host or any of its descendants. * * Installs this widget as an event filter on the given host widget. If the host contains a - * scroll area the filter is also installed on the scroll area's content widget, so the resize - * intercepts the wheel event before the scroll area would scroll the view. + * scroll area the filter is also installed on the scroll area's content widget, and on the + * viewport of every scroll area base (QListView, QTableView, ...), so the resize intercepts + * the wheel event before the scroll area would scroll the view. */ void enableCtrlScrollResize(QWidget *host); diff --git a/cockatrice/src/interface/widgets/visual_database_display/card_database_visual_view.cpp b/cockatrice/src/interface/widgets/visual_database_display/card_database_visual_view.cpp new file mode 100644 index 000000000..7b0d526f9 --- /dev/null +++ b/cockatrice/src/interface/widgets/visual_database_display/card_database_visual_view.cpp @@ -0,0 +1,203 @@ +#include "card_database_visual_view.h" + +#include "../../card_picture_loader/card_picture_loader.h" +#include "../cards/card_info_picture_widget.h" +#include "../cards/card_picture_delegate.h" +#include "../cards/card_size_widget.h" +#include "card_database_visual_display_model.h" +#include "libcockatrice/card/printing/exact_card.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +CardDatabaseVisualView::CardDatabaseVisualView(CardDatabaseVisualDisplayModel *_model, QWidget *parent) + : QListView(parent), model(_model) +{ + auto *cardDelegate = new CardPictureDelegate(model, this); + setItemDelegate(cardDelegate); + setModel(model); + + setViewMode(QListView::IconMode); + setFlow(QListView::LeftToRight); + setWrapping(true); + setResizeMode(QListView::Adjust); + setMovement(QListView::Static); + setUniformItemSizes(true); + setLayoutMode(QListView::SinglePass); + setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); + setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + setFrameShape(QFrame::NoFrame); + setWordWrap(false); + + // QListView hands the delegate's item height to the scroll bar as its single + // step under ScrollPerPixel, so one wheel notch would jump wheelScrollLines() + // whole card rows. Give the bar a text line instead; the wheel handler below + // scales that line to the grid. A positive step also stops Qt from taking the + // item height back on the next layout. + verticalScrollBar()->setSingleStep(qMax(1, fontMetrics().height())); + + // The grid is a read-only surface: clicks are actions on a card, not a + // selection, and they must not steal focus from the search field. + setSelectionMode(QAbstractItemView::NoSelection); + setEditTriggers(QAbstractItemView::NoEditTriggers); + setFocusPolicy(Qt::NoFocus); + + // Hover drives the card preview. + setMouseTracking(true); + viewport()->setMouseTracking(true); + + connect(&CardPictureLoader::getInstance(), &CardPictureLoader::pixmapLoaded, this, + [this] { viewport()->update(); }); + + // A new search result starts at the top, like the widget-per-card grid did. + connect(model, &QAbstractItemModel::modelReset, this, [this] { + verticalScrollBar()->setValue(0); + // The rows under a stationary cursor changed with the reset: re-resolve the + // hover so the preview follows the grid, as enterEvent did per card widget. + hoveredRow = -1; + updateHoveredRow(viewport()->mapFromGlobal(QCursor::pos())); + }); + + setCardSizeFactor(cardSizeFactor); +} + +void CardDatabaseVisualView::setCardSizeFactor(int factor) +{ + cardSizeFactor = qBound(CardSizeWidget::MIN_CARD_SIZE, factor, CardSizeWidget::MAX_CARD_SIZE); + + const QSize cardSize = CardInfoPictureWidget::sizeForScaleFactor(cardSizeFactor); + if (auto *cardDelegate = qobject_cast(itemDelegate())) { + cardDelegate->setCardSize(cardSize); + } + // The delegate's sizeHint does not emit sizeHintChanged, so the grid size is what + // makes a resize take effect. Keep the two in step. + setGridSize(cardSize + QSize(CardPictureDelegate::SPACING, CardPictureDelegate::SPACING)); +} + +void CardDatabaseVisualView::mousePressEvent(QMouseEvent *event) +{ + const QModelIndex index = indexAt(event->pos()); + if (index.isValid() && model != nullptr) { + const ExactCard card = model->cardAt(index.row()); + if (card) { + emit cardClicked(event, card); + event->accept(); + return; + } + } + + QListView::mousePressEvent(event); +} + +void CardDatabaseVisualView::mouseMoveEvent(QMouseEvent *event) +{ + QListView::mouseMoveEvent(event); + updateHoveredRow(event->pos()); +} + +bool CardDatabaseVisualView::viewportEvent(QEvent *event) +{ + // A per-card widget reported its hover from enterEvent, which also fires when the + // grid changes under a stationary cursor. A QListView only reports moves, so + // re-resolve the hover on enter and clear it on leave. + if (event->type() == QEvent::Enter) { + updateHoveredRow(viewport()->mapFromGlobal(QCursor::pos())); + } else if (event->type() == QEvent::Leave) { + hoveredRow = -1; + } + + return QListView::viewportEvent(event); +} + +void CardDatabaseVisualView::wheelEvent(QWheelEvent *event) +{ + // Ctrl+wheel is the card-size gesture and is filtered out before it reaches + // the view; any other modifier keeps QAbstractSlider's own page scrolling. + if (event->modifiers() != Qt::NoModifier) { + QListView::wheelEvent(event); + return; + } + + QScrollBar *bar = verticalScrollBar(); + if (!bar->isVisible()) { + event->ignore(); + return; + } + + // Touchpads and high-resolution wheels already report pixels: follow them. + if (!event->pixelDelta().isNull()) { + bar->setValue(bar->value() - event->pixelDelta().y()); + event->accept(); + return; + } + + if (event->angleDelta().y() == 0) { + QListView::wheelEvent(event); + return; + } + + // Accumulate wheel deltas smaller than a notch, like QAbstractSlider does, so + // a fine-grained wheel still moves. + wheelRemainder += static_cast(event->angleDelta().y()) / 120.0; + const int notches = static_cast(wheelRemainder); + if (notches == 0) { + event->accept(); + return; + } + wheelRemainder -= notches; + + const int pixels = -notches * wheelStep(); + bar->setValue(qBound(bar->minimum(), bar->value() + pixels, bar->maximum())); + event->accept(); +} + +int CardDatabaseVisualView::wheelStep() const +{ + // A card row is far too coarse for one wheel line, and a text line is too + // fine, so a line is an eighth of the grid cell. A notch then moves about a + // third of a card row at any card size, and the OS line count still applies. + const int lineHeight = qMax(1, gridSize().height() / 8); + return qMax(1, QApplication::wheelScrollLines()) * lineHeight; +} + +void CardDatabaseVisualView::updateHoveredRow(const QPoint &position) +{ + // indexAt() also answers for points outside the viewport, so a cursor parked + // below the grid (on the size slider, say) would resolve to the row just under + // the visible area. Only a point inside the viewport can hover a card. + if (model == nullptr || !viewport()->rect().contains(position)) { + return; + } + + const QModelIndex index = indexAt(position); + const int row = index.isValid() ? index.row() : -1; + if (row == hoveredRow) { + return; + } + + hoveredRow = row; + if (row >= 0) { + const ExactCard card = model->cardAt(row); + if (card) { + emit cardHovered(card); + } + } +} + +void CardDatabaseVisualView::leaveEvent(QEvent *event) +{ + hoveredRow = -1; + QListView::leaveEvent(event); +} diff --git a/cockatrice/src/interface/widgets/visual_database_display/card_database_visual_view.h b/cockatrice/src/interface/widgets/visual_database_display/card_database_visual_view.h new file mode 100644 index 000000000..5f004484d --- /dev/null +++ b/cockatrice/src/interface/widgets/visual_database_display/card_database_visual_view.h @@ -0,0 +1,67 @@ +/** + * @file card_database_visual_view.h + * @ingroup VisualCardDatabaseWidgets + * @brief Icon-mode view of the card database, one card picture per item. + */ + +#ifndef CARD_DATABASE_VISUAL_VIEW_H +#define CARD_DATABASE_VISUAL_VIEW_H + +#include +#include +#include + +class CardDatabaseVisualDisplayModel; +class ExactCard; +class QEvent; +class QMouseEvent; +class QPoint; +class QWheelEvent; + +/** + * @brief A wrapping grid of card pictures. + * + * The view virtualizes painting: it only asks the delegate for the items that are + * on screen, so the whole card database can sit in the model without a widget (or + * pixmap) per card. Clicks and hover are reported with the exact card under the + * cursor, which is what the deck editor needs to add, decrement or preview a card. + */ +class CardDatabaseVisualView : public QListView +{ + Q_OBJECT + +public: + explicit CardDatabaseVisualView(CardDatabaseVisualDisplayModel *_model, QWidget *parent = nullptr); + + /** + * @brief Sets the card size as a percentage of the base card width. + * @param factor Percentage in the range of the card size slider. + */ + void setCardSizeFactor(int factor); + +signals: + /** @brief A card was pressed. The event is valid for the duration of the emission. */ + void cardClicked(QMouseEvent *event, const ExactCard &card); + /** @brief The cursor moved onto a different card. */ + void cardHovered(const ExactCard &card); + +protected: + void mousePressEvent(QMouseEvent *event) override; + void mouseMoveEvent(QMouseEvent *event) override; + void leaveEvent(QEvent *event) override; + bool viewportEvent(QEvent *event) override; + void wheelEvent(QWheelEvent *event) override; + +private: + void updateHoveredRow(const QPoint &position); + /// Pixels one wheel notch moves, scaled to the grid and the OS line count. + [[nodiscard]] int wheelStep() const; + + CardDatabaseVisualDisplayModel *model; + /// Fraction of a line left over from a wheel event smaller than one notch. + qreal wheelRemainder = 0.0; + int cardSizeFactor = 100; + int hoveredRow = -1; +}; + +#endif // CARD_DATABASE_VISUAL_VIEW_H diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp index 30e0af66b..6037418ad 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp @@ -5,40 +5,29 @@ #include "../../../filters/syntax_help.h" #include "../../key_signals.h" #include "../../pixel_map_generator.h" -#include "../cards/card_info_picture_widget.h" -#include "../cards/card_info_picture_with_text_overlay_widget.h" #include "../cards/card_size_widget.h" #include "../deck_editor/card_database_view.h" #include "../general/layout_containers/flow_widget.h" #include "../utility/custom_line_edit.h" #include "card_database_display_model.h" #include "card_database_model.h" +#include "card_database_visual_display_model.h" +#include "card_database_visual_view.h" #include "deck_list_model.h" #include "libcockatrice/card/card_localization.h" #include "libcockatrice/card/database/card_database_loader.h" #include "libcockatrice/card/database/card_database_querier.h" #include "libcockatrice/card/printing/exact_card.h" -#include "libcockatrice/card/printing/printing_info.h" #include "libcockatrice/filters/filter_card.h" #include "libcockatrice/filters/filter_tree.h" #include "visual_database_display_color_filter_widget.h" #include "visual_database_display_filter_toolbar_widget.h" -#include #include -#include #include #include #include -#include -#include -#include -#include -#include #include -#include -#include -#include #include #include #include @@ -48,14 +37,11 @@ #include #include #include -#include -#include #include #include #include #include #include -#include class QMouseEvent; @@ -64,11 +50,6 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, DeckListModel *deckListModel) : QWidget(parent) { - debounceTimer = new QTimer(this); - debounceTimer->setSingleShot(true); // Ensure it only fires once after the timeout - - connect(debounceTimer, &QTimer::timeout, this, &VisualDatabaseDisplayWidget::onSearchModelChanged); - // Create display model databaseDisplayModel = new CardDatabaseDisplayModel(this); databaseDisplayModel->setObjectName("databaseDisplayModel"); @@ -86,23 +67,12 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardSearchLanguageChanged, this, applyCardSearchLanguage); - cards = new QList; - connect(databaseDisplayModel, &CardDatabaseDisplayModel::modelDirty, this, - &VisualDatabaseDisplayWidget::modelDirty); - // Set up main layout and widgets setMinimumSize(0, 0); setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); mainLayout = QtUtils::makeLayout(this, 0, 1); setLayout(mainLayout); - flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarPolicy::ScrollBarAsNeeded); - cardSizeWidget = new CardSizeWidget(this, flowWidget, - SettingsCache::instance().cardsDisplay().getVisualDatabaseDisplayCardSize()); - connect(cardSizeWidget, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance().cardsDisplay(), - &CardsDisplaySettings::setVisualDatabaseDisplayCardSize); - cardSizeWidget->enableCtrlScrollResize(flowWidget); - searchContainer = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAlwaysOff); searchEdit = new SearchLineEdit(); @@ -159,6 +129,25 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, connect(databaseView, &CardDatabaseView::relatedCardClicked, this, &VisualDatabaseDisplayWidget::onRelatedCardClicked); + // The visual grid is a real view on a flattened model: one row per displayed + // card picture, painted on demand. + visualDisplayModel = new CardDatabaseVisualDisplayModel(databaseDisplayModel, CardDatabaseManager::query(), this); + + cardView = new CardDatabaseVisualView(visualDisplayModel, this); + cardView->setObjectName("visualCardView"); + connect(cardView, &CardDatabaseVisualView::cardClicked, this, + &VisualDatabaseDisplayWidget::cardClickedDatabaseDisplay); + connect(cardView, &CardDatabaseVisualView::cardHovered, this, + &VisualDatabaseDisplayWidget::cardHoveredDatabaseDisplay); + + const int cardSize = SettingsCache::instance().cardsDisplay().getVisualDatabaseDisplayCardSize(); + cardSizeWidget = new CardSizeWidget(this, nullptr, cardSize); + connect(cardSizeWidget, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance().cardsDisplay(), + &CardsDisplaySettings::setVisualDatabaseDisplayCardSize); + connect(cardSizeWidget->getSlider(), &QSlider::valueChanged, cardView, &CardDatabaseVisualView::setCardSizeFactor); + cardView->setCardSizeFactor(cardSize); + cardSizeWidget->enableCtrlScrollResize(cardView); + colorFilterWidget = new VisualDatabaseDisplayColorFilterWidget(this, filterModel); filterContainer = new VisualDatabaseDisplayFilterToolbarWidget(this, deckListModel); @@ -190,8 +179,6 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, databaseLoadIndicator->setVisible(false); } - QScrollBar *scrollBar = flowWidget->scrollArea->verticalScrollBar(); - connect(scrollBar, &QScrollBar::valueChanged, [this](int /*value*/) { loadCurrentPage(); }); retranslateUi(); } @@ -210,7 +197,7 @@ void VisualDatabaseDisplayWidget::initialize() mainLayout->addWidget(databaseView); - mainLayout->addWidget(flowWidget); + mainLayout->addWidget(cardView); mainLayout->addWidget(cardSizeWidget); @@ -241,8 +228,6 @@ void VisualDatabaseDisplayWidget::initializeFilters() databaseDisplayModel->setFilterTree(filterModel->filterTree()); - QTimer::singleShot(5000, this, [this] { loadCurrentPage(); }); - retranslateUi(); }); } @@ -278,71 +263,36 @@ void VisualDatabaseDisplayWidget::updateDisplayModeButton() displayModeButton->setText(displayModeButton->isChecked() ? tr("Table") : tr("Visual")); } -void VisualDatabaseDisplayWidget::resizeEvent(QResizeEvent *event) -{ - QWidget::resizeEvent(event); - loadCurrentPage(); -} - void VisualDatabaseDisplayWidget::onDisplayModeChanged(bool checked) { updateDisplayModeButton(); if (checked) { // Table mode - flowWidget->setVisible(false); + cardView->setVisible(false); cardSizeWidget->setVisible(false); databaseView->setItemDelegate(new QStyledItemDelegate(databaseView)); databaseView->setVisible(true); } else { // Visual mode - flowWidget->setVisible(true); + cardView->setVisible(true); cardSizeWidget->setVisible(true); databaseView->setVisible(false); - populateCards(); } } -void VisualDatabaseDisplayWidget::onClick(QMouseEvent *event, const ExactCard &card) -{ - emit cardClickedDatabaseDisplay(event, card); -} - -void VisualDatabaseDisplayWidget::onHover(const ExactCard &hoveredCard) -{ - emit cardHoveredDatabaseDisplay(hoveredCard); -} - -void VisualDatabaseDisplayWidget::addCardToDisplay(const ExactCard &cardToAdd) -{ - cards->append(cardToAdd); - auto *display = new CardInfoPictureWithTextOverlayWidget(flowWidget, false); - display->setScaleFactor(cardSizeWidget->getSlider()->value()); - display->setCard(cardToAdd); - flowWidget->addWidget(display); - connect(display, &CardInfoPictureWithTextOverlayWidget::cardClicked, this, &VisualDatabaseDisplayWidget::onClick); - connect(display, &CardInfoPictureWithTextOverlayWidget::hoveredOnCard, this, &VisualDatabaseDisplayWidget::onHover); - connect(cardSizeWidget->getSlider(), &QSlider::valueChanged, display, &CardInfoPictureWidget::setScaleFactor); -} - bool VisualDatabaseDisplayWidget::isVisualDisplayMode() const { return !displayModeButton->isChecked(); } +QStringList VisualDatabaseDisplayWidget::collectSetFilterTerms() const +{ + return filterModel->getFilterTermsOfType(CardFilter::AttrSet); +} + void VisualDatabaseDisplayWidget::onSearchModelChanged() { - if (flowWidget->isVisible()) { - // Clear the current page and prepare for new data - flowWidget->clearLayout(); // Clear existing cards - cards->clear(); // Clear the card list - // Reset scrollbar position to the top after loading new cards - QScrollBar *scrollBar = flowWidget->scrollArea->verticalScrollBar(); - scrollBar->setValue(0); // Reset scrollbar to top - - currentPage = 0; - loadCurrentPage(); - qCDebug(VisualDatabaseDisplayLog) << "Search model changed"; - } + visualDisplayModel->setSetFilterTerms(collectSetFilterTerms()); } void VisualDatabaseDisplayWidget::onSelectedCardChanged(const QString &cardName) @@ -368,108 +318,3 @@ void VisualDatabaseDisplayWidget::onRelatedCardClicked(const QString &relatedCar ExactCard exactCard = CardDatabaseManager::query()->guessCard({relatedCard}); emit cardInfoRequested(exactCard); } - -bool VisualDatabaseDisplayWidget::nearEndOfPage() const -{ - if (!flowWidget->isVisible()) { - return false; - } - - QScrollBar *scrollBar = flowWidget->scrollArea->verticalScrollBar(); - if (scrollBar->value() + scrollBar->pageStep() * 2 < scrollBar->maximum()) { - return false; // there is at least two pages of space to scroll remaining - } - - return true; -} - -void VisualDatabaseDisplayWidget::loadCurrentPage() -{ - // Ensure only the initial page is loaded - if (currentPage == 0) { - if (!initialLoadScheduled) { - initialLoadScheduled = true; - qCDebug(VisualDatabaseDisplayLog) << "Loading the first page"; - // Defer the first page so the tab switch stays responsive. The card - // grid builds one event loop turn later. This also applies to - // search-driven reloads, which reset currentPage back to 0. - QTimer::singleShot(0, this, [this] { - initialLoadScheduled = false; - populateCards(); - }); - } - } else if (nearEndOfPage()) { - // If not the first page, just load the next page and append to the flow widget - loadNextPage(); - } -} - -void VisualDatabaseDisplayWidget::populateCards() -{ - cards->clear(); - loadNextPage(); -} - -void VisualDatabaseDisplayWidget::loadNextPage() -{ - // Calculate the start and end indices for the next page - int rowCount = databaseDisplayModel->rowCount(); - int start = currentPage * cardsPerPage; - int end = qMin(start + cardsPerPage, rowCount); - - // Load the next page of cards and add them to the flow widget - flowWidget->setUpdatesEnabled(false); - loadPage(start, end); - flowWidget->setUpdatesEnabled(true); -} - -void VisualDatabaseDisplayWidget::loadPage(int start, int end) -{ - QList setFilters = filterModel->getFiltersOfType(CardFilter::AttrSet); - for (int row = start; row < end; ++row) { - qCDebug(VisualDatabaseDisplayLog) << "Adding " << row; - QModelIndex index = databaseDisplayModel->index(row, CardDatabaseModel::NameColumn); - QVariant name = databaseDisplayModel->data(index, Qt::DisplayRole); - qCDebug(VisualDatabaseDisplayLog) << name.toString(); - - if (CardInfoPtr info = CardDatabaseManager::query()->getCardInfo(name.toString())) { - if (!setFilters.empty()) { - const auto &setMap = info->getSets(); - for (const CardFilter *setFilter : setFilters) { - const auto it = setMap.constFind(setFilter->term()); - if (it != setMap.constEnd()) { - for (const PrintingInfo &printing : it.value()) { - addCardToDisplay(ExactCard(info, printing)); - } - } - } - } else { - addCardToDisplay(CardDatabaseManager::query()->getPreferredCard(info)); - } - } else { - qCDebug(VisualDatabaseDisplayLog) << "Card not found in database!"; - } - } - currentPage++; -} - -void VisualDatabaseDisplayWidget::modelDirty() const -{ - debounceTimer->start(debounceTime); -} - -void VisualDatabaseDisplayWidget::sortCardList(const QStringList &properties, - Qt::SortOrder order = Qt::AscendingOrder) const -{ - CardInfoComparator comparator(properties, order); - std::sort(cards->begin(), cards->end(), [comparator](const ExactCard &a, const ExactCard &b) { - return comparator(a.getCardPtr(), b.getCardPtr()); - }); -} - -void VisualDatabaseDisplayWidget::databaseDataChanged(const QModelIndex &topLeft, const QModelIndex &bottomRight) -{ - (void)topLeft; - (void)bottomRight; - qCDebug(VisualDatabaseDisplayLog) << "Database Data changed"; -} diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h index 15adca214..6148670a3 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h @@ -9,8 +9,6 @@ #include "libcockatrice/card/card_info.h" -#include -#include #include #include #include @@ -21,26 +19,21 @@ class CardDatabaseDisplayModel; class CardDatabaseModel; class CardDatabaseView; +class CardDatabaseVisualDisplayModel; +class CardDatabaseVisualView; class CardSizeWidget; -class DeckList; class DeckListModel; class ExactCard; class FilterTreeModel; class FlowWidget; -class OverlapControlWidget; class QLabel; -class QModelIndex; +class QMouseEvent; class QPushButton; -class QScrollArea; -class QTimer; class QToolButton; class QVBoxLayout; class SearchLineEdit; class VisualDatabaseDisplayColorFilterWidget; class VisualDatabaseDisplayFilterToolbarWidget; -template class QList; - -inline Q_LOGGING_CATEGORY(VisualDatabaseDisplayLog, "visual_database_display"); class VisualDatabaseDisplayWidget : public QWidget { @@ -52,14 +45,6 @@ public: DeckListModel *deckListModel = nullptr); void retranslateUi(); - void adjustCardsPerPage(); - void populateCards(); - void loadPage(int start, int end); - void loadNextPage(); - void loadCurrentPage(); - void sortCardList(const QStringList &properties, Qt::SortOrder order) const; - void setDeckList(const DeckList &new_deck_list_model); - /** * @brief Sets the callback used to create a custom zone from the add-to-zone menu. * The callback returns the name of the created zone, or an empty string if creation was cancelled. @@ -87,6 +72,13 @@ public: bool isVisualDisplayMode() const; public slots: + /** + * @brief Pushes the active set filters to the visual grid. + * + * The grid's rows follow the display model's filter and sort signals on their + * own; this only forwards the set filters, which decide which printings the + * grid shows. + */ void onSearchModelChanged(); signals: @@ -101,11 +93,6 @@ signals: protected slots: void initialize(); - void onClick(QMouseEvent *event, const ExactCard &card); - void onHover(const ExactCard &hoveredCard); - void addCardToDisplay(const ExactCard &cardToAdd); - void databaseDataChanged(const QModelIndex &topLeft, const QModelIndex &bottomRight); - void modelDirty() const; void onDisplayModeChanged(bool checked); void onSelectedCardChanged(const QString &cardName); @@ -125,31 +112,21 @@ private: QToolButton *clearFilterWidget; VisualDatabaseDisplayFilterToolbarWidget *filterContainer; CardDatabaseDisplayModel *databaseDisplayModel; + CardDatabaseVisualDisplayModel *visualDisplayModel; CardDatabaseView *databaseView; + CardDatabaseVisualView *cardView; std::function newZoneCreator; - QList *cards; QVBoxLayout *mainLayout; - QScrollArea *scrollArea; - FlowWidget *flowWidget; - QWidget *overlapCategories; - QVBoxLayout *overlapCategoriesLayout; - OverlapControlWidget *overlapControlWidget; CardSizeWidget *cardSizeWidget; - QTimer *debounceTimer; - int debounceTime = 300; // in Ms - int currentPage = 0; // Current page index - int cardsPerPage = 100; // Number of cards per page bool filtersInitialized = false; - bool initialLoadScheduled = false; void initializeFilters(); void highlightAllSearchEdit(); void updateDisplayModeButton(); - bool nearEndOfPage() const; + [[nodiscard]] QStringList collectSetFilterTerms() const; protected: - void resizeEvent(QResizeEvent *event) override; void showEvent(QShowEvent *event) override; }; diff --git a/libcockatrice_filters/libcockatrice/filters/filter_card.h b/libcockatrice_filters/libcockatrice/filters/filter_card.h index e6a37ee40..dbeffd18f 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_card.h +++ b/libcockatrice_filters/libcockatrice/filters/filter_card.h @@ -54,7 +54,7 @@ private: enum Attr a; public: - CardFilter(QString &term, Type type, Attr attr) : trm(term), t(type), a(attr) + CardFilter(const QString &term, Type type, Attr attr) : trm(term), t(type), a(attr) { } diff --git a/libcockatrice_models/libcockatrice/models/database/CMakeLists.txt b/libcockatrice_models/libcockatrice/models/database/CMakeLists.txt index 5cf3caa52..22a04cd32 100644 --- a/libcockatrice_models/libcockatrice/models/database/CMakeLists.txt +++ b/libcockatrice_models/libcockatrice/models/database/CMakeLists.txt @@ -1,6 +1,7 @@ set(HEADERS card_database_model.h card_database_display_model.h + card_database_visual_display_model.h card/card_completer_proxy_model.h card/card_search_model.h card_set/card_sets_model.h @@ -13,6 +14,7 @@ add_library( ${HEADERS} card_database_model.cpp card_database_display_model.cpp + card_database_visual_display_model.cpp card/card_completer_proxy_model.cpp card/card_search_model.cpp card_set/card_sets_model.cpp diff --git a/libcockatrice_models/libcockatrice/models/database/card_database_display_model.cpp b/libcockatrice_models/libcockatrice/models/database/card_database_display_model.cpp index 6ed9d8827..9ec0afd9c 100644 --- a/libcockatrice_models/libcockatrice/models/database/card_database_display_model.cpp +++ b/libcockatrice_models/libcockatrice/models/database/card_database_display_model.cpp @@ -21,7 +21,7 @@ class QObject; CardDatabaseDisplayModel::CardDatabaseDisplayModel(QObject *parent) - : QSortFilterProxyModel(parent), isToken(ShowAll), filterString(nullptr), + : QSortFilterProxyModel(parent), isToken(ShowAll), filterString(nullptr), cardModel(nullptr), collator(NaturalSort::makeNaturalSortCollator()) { filterTree = nullptr; @@ -37,6 +37,7 @@ CardDatabaseDisplayModel::CardDatabaseDisplayModel(QObject *parent) void CardDatabaseDisplayModel::setSourceModel(QAbstractItemModel *model) { + cardModel = qobject_cast(model); QSortFilterProxyModel::setSourceModel(model); connect(model, &QAbstractItemModel::rowsInserted, this, [this]() { dirty(); }); @@ -46,6 +47,25 @@ void CardDatabaseDisplayModel::setSourceModel(QAbstractItemModel *model) connect(model, &QAbstractItemModel::modelReset, this, [this]() { dirty(); }); } +QVariant CardDatabaseDisplayModel::data(const QModelIndex &index, int role) const +{ + if (role == CardInfoRole) { + return QVariant::fromValue(cardInfoAt(index)); + } + + return QSortFilterProxyModel::data(index, role); +} + +CardInfoPtr CardDatabaseDisplayModel::cardInfoAt(const QModelIndex &index) const +{ + if (!index.isValid() || cardModel == nullptr) { + return {}; + } + + const QModelIndex sourceIndex = mapToSource(index); + return sourceIndex.isValid() ? cardModel->getCard(sourceIndex.row()) : CardInfoPtr(); +} + QMap CardDatabaseDisplayModel::characterTranslation = {{L'“', L'\"'}, {L'”', L'\"'}, {L'‘', L'\''}, diff --git a/libcockatrice_models/libcockatrice/models/database/card_database_display_model.h b/libcockatrice_models/libcockatrice/models/database/card_database_display_model.h index be074bba5..3a9bbc6ab 100644 --- a/libcockatrice_models/libcockatrice/models/database/card_database_display_model.h +++ b/libcockatrice_models/libcockatrice/models/database/card_database_display_model.h @@ -21,6 +21,7 @@ #include #include +class CardDatabaseModel; class FilterTree; class QObject; template class QMap; @@ -36,12 +37,25 @@ public: ShowAll }; + enum Role + { + /** + * @brief The CardInfoPtr behind a proxy row. + * + * Saves views from looking the card up again through its name, and keeps + * them from depending on the source model's column layout. + */ + CardInfoRole = Qt::UserRole + 1 + }; + private: FilterBool isToken; QString cardName, cardText; QSet cardNameSet, cardTypes, cardColors; FilterTree *filterTree; FilterString *filterString; + /// The source model, kept typed so CardInfoRole does not cast per row. + CardDatabaseModel *cardModel; QTimer dirtyTimer; CardSearchLanguage searchLanguage; QString searchText; @@ -84,6 +98,16 @@ public: dirtyTimer.start(20); } void clearFilterAll(); + [[nodiscard]] QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + + /** + * @brief Returns the card behind a proxy row, without the QVariant wrapper of CardInfoRole. + * + * The visual grid calls this for every row on each rebuild; CardInfoRole stays the + * generic way in for consumers that only see the model through data(). + */ + [[nodiscard]] CardInfoPtr cardInfoAt(const QModelIndex &index) const; + signals: void modelDirty(); diff --git a/libcockatrice_models/libcockatrice/models/database/card_database_visual_display_model.cpp b/libcockatrice_models/libcockatrice/models/database/card_database_visual_display_model.cpp new file mode 100644 index 000000000..f6a3a925c --- /dev/null +++ b/libcockatrice_models/libcockatrice/models/database/card_database_visual_display_model.cpp @@ -0,0 +1,123 @@ +#include "card_database_visual_display_model.h" + +#include "card_database_display_model.h" +#include "libcockatrice/card/database/card_database_querier.h" + +#include +#include +#include +#include + +/// Coalesces the bursts of proxy signals a filter, sort or database load produces. +static constexpr int REBUILD_COALESCE_MS = 30; + +CardDatabaseVisualDisplayModel::CardDatabaseVisualDisplayModel(CardDatabaseDisplayModel *_displayModel, + CardDatabaseQuerier *_querier, + QObject *parent) + : QAbstractListModel(parent), displayModel(_displayModel), querier(_querier) +{ + rebuildTimer.setSingleShot(true); + rebuildTimer.setInterval(REBUILD_COALESCE_MS); + connect(&rebuildTimer, &QTimer::timeout, this, &CardDatabaseVisualDisplayModel::rebuild); + + if (displayModel == nullptr) { + return; + } + + connect(displayModel, &QAbstractItemModel::modelReset, this, &CardDatabaseVisualDisplayModel::scheduleRebuild); + connect(displayModel, &QAbstractItemModel::layoutChanged, this, &CardDatabaseVisualDisplayModel::scheduleRebuild); + connect(displayModel, &QAbstractItemModel::rowsInserted, this, &CardDatabaseVisualDisplayModel::scheduleRebuild); + connect(displayModel, &QAbstractItemModel::rowsRemoved, this, &CardDatabaseVisualDisplayModel::scheduleRebuild); + connect(displayModel, &QAbstractItemModel::dataChanged, this, &CardDatabaseVisualDisplayModel::scheduleRebuild); +} + +void CardDatabaseVisualDisplayModel::setSetFilterTerms(const QStringList &terms) +{ + if (setFilterTerms == terms) { + return; + } + + setFilterTerms = terms; + refresh(); +} + +void CardDatabaseVisualDisplayModel::refresh() +{ + rebuildTimer.stop(); + rebuild(); +} + +void CardDatabaseVisualDisplayModel::scheduleRebuild() +{ + rebuildTimer.start(); +} + +void CardDatabaseVisualDisplayModel::rebuild() +{ + beginResetModel(); + entries.clear(); + + if (displayModel != nullptr) { + const int proxyRows = displayModel->rowCount(); + entries.reserve(proxyRows); + for (int row = 0; row < proxyRows; ++row) { + const CardInfoPtr info = displayModel->cardInfoAt(displayModel->index(row, 0)); + if (info) { + appendPrintingsFor(info); + } + } + } + + endResetModel(); +} + +void CardDatabaseVisualDisplayModel::appendPrintingsFor(const CardInfoPtr &info) +{ + if (setFilterTerms.isEmpty()) { + entries.append({info, PrintingInfo()}); + return; + } + + const auto &printingsBySet = info->getSets(); + for (const QString &term : setFilterTerms) { + const auto printings = printingsBySet.constFind(term); + if (printings == printingsBySet.constEnd()) { + continue; + } + for (const PrintingInfo &printing : printings.value()) { + entries.append({info, printing}); + } + } +} + +int CardDatabaseVisualDisplayModel::rowCount(const QModelIndex &parent) const +{ + return parent.isValid() ? 0 : static_cast(entries.size()); +} + +QVariant CardDatabaseVisualDisplayModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() >= entries.size()) { + return {}; + } + + if (role == Qt::DisplayRole || role == Qt::AccessibleTextRole) { + return cardAt(index.row()).getName(); + } + + return {}; +} + +ExactCard CardDatabaseVisualDisplayModel::cardAt(int row) const +{ + if (row < 0 || row >= entries.size()) { + return {}; + } + + const Entry &entry = entries.at(row); + if (entry.printing.isEmpty() && setFilterTerms.isEmpty()) { + return querier != nullptr ? querier->getPreferredCard(entry.info) : ExactCard(entry.info); + } + + return ExactCard(entry.info, entry.printing); +} diff --git a/libcockatrice_models/libcockatrice/models/database/card_database_visual_display_model.h b/libcockatrice_models/libcockatrice/models/database/card_database_visual_display_model.h new file mode 100644 index 000000000..1303b6464 --- /dev/null +++ b/libcockatrice_models/libcockatrice/models/database/card_database_visual_display_model.h @@ -0,0 +1,90 @@ +/** + * @file card_database_visual_display_model.h + * @ingroup CardDatabaseModels + * @brief A list model of the exact cards a visual card grid paints. + * + * The filtered and sorted CardDatabaseDisplayModel holds one row per card name. + * A visual grid shows one picture per *printing* instead, so this model flattens + * the proxy rows into ExactCards: the preferred printing per card normally, and + * every printing of the selected sets while set filters are active. + */ + +#ifndef COCKATRICE_CARD_DATABASE_VISUAL_DISPLAY_MODEL_H +#define COCKATRICE_CARD_DATABASE_VISUAL_DISPLAY_MODEL_H + +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "libcockatrice/card/printing/printing_info.h" + +#include +#include +#include +#include +#include +#include +#include + +class CardDatabaseDisplayModel; +class CardDatabaseQuerier; + +class CardDatabaseVisualDisplayModel : public QAbstractListModel +{ + Q_OBJECT + +public: + /** + * @brief Flattens _displayModel into the cards a visual grid displays. + * @param _displayModel The filtered and sorted proxy model of the card database. + * @param _querier Resolves the preferred printing of a card. + * @param parent Parent object. + */ + explicit CardDatabaseVisualDisplayModel(CardDatabaseDisplayModel *_displayModel, + CardDatabaseQuerier *_querier, + QObject *parent = nullptr); + + /** + * @brief Sets the set shorthand names of the active set filters. + * + * With no terms every card is shown with its preferred printing. With terms + * every printing belonging to one of the named sets is shown instead, in term + * order. Rebuilds immediately when the terms change. + */ + void setSetFilterTerms(const QStringList &terms); + + /** + * @brief Rebuilds the row list from the proxy model right away. + * + * Proxy model changes normally rebuild on a short coalescing timer; call this + * when the caller needs the new rows before returning. + */ + void refresh(); + + [[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override; + [[nodiscard]] QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + + /** + * @brief Returns the card painted at @p row, with its preferred printing resolved. + * @param row Row in this model, not in the proxy model. + */ + [[nodiscard]] ExactCard cardAt(int row) const; + +private: + struct Entry + { + CardInfoPtr info; + /// Empty when the entry follows the preferred printing rather than a set filter. + PrintingInfo printing; + }; + + void scheduleRebuild(); + void rebuild(); + void appendPrintingsFor(const CardInfoPtr &info); + + QPointer displayModel; + CardDatabaseQuerier *querier; + QStringList setFilterTerms; + QList entries; + QTimer rebuildTimer; +}; + +#endif // COCKATRICE_CARD_DATABASE_VISUAL_DISPLAY_MODEL_H diff --git a/tests/carddatabase/CMakeLists.txt b/tests/carddatabase/CMakeLists.txt index 22171a9e8..7d83640a1 100644 --- a/tests/carddatabase/CMakeLists.txt +++ b/tests/carddatabase/CMakeLists.txt @@ -35,6 +35,21 @@ if(WITH_ORACLE OR WITH_CLIENT) LIBS libcockatrice_filters ) + + # ------------------------ + # Visual Display Model Test + # ------------------------ + cockatrice_add_test( + NAME + visual_display_model_test + SOURCES + ${VERSION_STRING_CPP} + visual_display_model_test.cpp + mocks.cpp + LIBS + libcockatrice_card + libcockatrice_models + ) endif() # ------------------------ diff --git a/tests/carddatabase/visual_display_model_test.cpp b/tests/carddatabase/visual_display_model_test.cpp new file mode 100644 index 000000000..5fa554bb3 --- /dev/null +++ b/tests/carddatabase/visual_display_model_test.cpp @@ -0,0 +1,188 @@ +/** + * @file visual_display_model_test.cpp + * @brief Tests for the flattened model behind the visual card database grid. + */ + +#include "card_database_display_model.h" +#include "card_database_model.h" +#include "card_database_visual_display_model.h" +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/card/database/card_database.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "libcockatrice/card/printing/printing_info.h" +#include "libcockatrice/card/set/card_set.h" +#include "libcockatrice/interfaces/noop_card_preference_provider.h" +#include "libcockatrice/interfaces/noop_card_set_priority_controller.h" +#include "test_card_database_path_provider.h" + +#include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +// GTest's main() creates no QCoreApplication, which QTimer needs for its event loop. +const QCoreApplication &application() +{ + static int argc = 1; + static char executable[] = "visual_display_model_test"; + static char *argv[] = {executable, nullptr}; + static const QCoreApplication app(argc, argv); + return app; +} + +/// Lets the rebuild timer fire, as it would in the running client. +void waitForRebuild() +{ + QEventLoop loop; + QTimer::singleShot(100, &loop, &QEventLoop::quit); + loop.exec(); +} + +class CardDatabaseVisualDisplayModelTest : public ::testing::Test +{ +protected: + void SetUp() override + { + (void)application(); + + db = std::make_unique(nullptr, new NoopCardPreferenceProvider(), + new TestCardDatabasePathProvider(), new NoopCardSetPriorityController()); + db->loadCardDatabases(); + + cardModel = std::make_unique(db.get(), false); + displayModel = std::make_unique(); + displayModel->setSourceModel(cardModel.get()); + visualModel = std::make_unique(displayModel.get(), db->query()); + visualModel->refresh(); + } + + /** + * @brief Builds a card with @p printingCount printings in each named set. + * @param printingCountPerSet Set short name to the number of printings in that set. + */ + [[nodiscard]] CardInfoPtr makeCard(const QString &name, const QMap &printingCountPerSet) + { + SetToPrintingsMap setsInfo; + for (auto it = printingCountPerSet.constBegin(); it != printingCountPerSet.constEnd(); ++it) { + CardSetPtr set = CardSet::newInstance(&setController, it.key(), it.key() + " test set", "expansion", + QDate(), CardSet::PriorityPrimary); + for (int printing = 0; printing < it.value(); ++printing) { + QHash properties; + properties["uuid"] = QString("%1-%2-%3").arg(it.key(), name).arg(printing); + setsInfo[it.key()].append(PrintingInfo(set, LazyPropertiesHash(properties))); + } + } + + CardInfo::UiAttributes attributes = {.tableRow = 1}; + return CardInfo::newInstance(name, "", false, {}, {}, {}, setsInfo, attributes); + } + + NoopCardSetPriorityController setController; + std::unique_ptr db; + std::unique_ptr cardModel; + std::unique_ptr displayModel; + std::unique_ptr visualModel; +}; + +TEST_F(CardDatabaseVisualDisplayModelTest, ExposesTheCardInfoBehindAProxyRow) +{ + ASSERT_GT(displayModel->rowCount(), 0); + + const QVariant info = displayModel->data(displayModel->index(0, CardDatabaseModel::NameColumn), + CardDatabaseDisplayModel::CardInfoRole); + ASSERT_TRUE(info.isValid()); + EXPECT_FALSE(info.value().isNull()); +} + +TEST_F(CardDatabaseVisualDisplayModelTest, FlattensOneRowPerProxyCardWithoutSetFilters) +{ + ASSERT_GT(displayModel->rowCount(), 0); + EXPECT_EQ(displayModel->rowCount(), visualModel->rowCount()); + + for (int row = 0; row < visualModel->rowCount(); ++row) { + const ExactCard card = visualModel->cardAt(row); + ASSERT_TRUE(card) << "row " << row; + EXPECT_EQ( + displayModel->data(displayModel->index(row, CardDatabaseModel::NameColumn), Qt::DisplayRole).toString(), + card.getName()) + << "row " << row; + } +} + +TEST_F(CardDatabaseVisualDisplayModelTest, ShowsOneRowPerCardWhenNoSetIsFiltered) +{ + const int rowsBefore = visualModel->rowCount(); + + db->addCard(makeCard("Multi", {{"TST", 2}, {"OTH", 1}})); + visualModel->refresh(); + + EXPECT_EQ(rowsBefore + 1, visualModel->rowCount()); + EXPECT_EQ("Multi", visualModel->cardAt(visualModel->rowCount() - 1).getName()); +} + +TEST_F(CardDatabaseVisualDisplayModelTest, ShowsEveryPrintingOfAFilteredSet) +{ + db->addCard(makeCard("Multi", {{"TST", 2}, {"OTH", 1}})); + visualModel->refresh(); + + visualModel->setSetFilterTerms({"TST"}); + + ASSERT_EQ(2, visualModel->rowCount()); + for (int row = 0; row < visualModel->rowCount(); ++row) { + const ExactCard card = visualModel->cardAt(row); + ASSERT_TRUE(card) << "row " << row; + EXPECT_EQ("Multi", card.getName()); + ASSERT_FALSE(card.getPrinting().isEmpty()) << "row " << row; + EXPECT_EQ("TST", card.getPrinting().getSet()->getShortName()) << "row " << row; + } + EXPECT_NE(visualModel->cardAt(0).getPrinting(), visualModel->cardAt(1).getPrinting()); +} + +TEST_F(CardDatabaseVisualDisplayModelTest, HidesCardsWithoutAPrintingInTheFilteredSets) +{ + visualModel->setSetFilterTerms({"NO SUCH SET"}); + + EXPECT_EQ(0, visualModel->rowCount()); +} + +TEST_F(CardDatabaseVisualDisplayModelTest, RestoresOneRowPerCardWhenTheSetTermsClear) +{ + db->addCard(makeCard("Multi", {{"TST", 2}, {"OTH", 1}})); + visualModel->refresh(); + const int rowsBefore = visualModel->rowCount(); + + visualModel->setSetFilterTerms({"TST"}); + ASSERT_EQ(2, visualModel->rowCount()); + + visualModel->setSetFilterTerms({}); + EXPECT_EQ(rowsBefore, visualModel->rowCount()); +} + +TEST_F(CardDatabaseVisualDisplayModelTest, RebuildsByItselfWhenTheProxyGainsARow) +{ + const int rowsBefore = visualModel->rowCount(); + + db->addCard(makeCard("Late", {{"TST", 1}})); + waitForRebuild(); + + EXPECT_EQ(rowsBefore + 1, visualModel->rowCount()); +} + +TEST_F(CardDatabaseVisualDisplayModelTest, ReportsTheCardNameForDisplayAndAccessibleText) +{ + ASSERT_GT(visualModel->rowCount(), 0); + const QString expected = visualModel->cardAt(0).getName(); + + EXPECT_EQ(expected, visualModel->data(visualModel->index(0), Qt::DisplayRole).toString()); + EXPECT_EQ(expected, visualModel->data(visualModel->index(0), Qt::AccessibleTextRole).toString()); + EXPECT_FALSE(visualModel->data(QModelIndex(), Qt::DisplayRole).isValid()); +} +} // namespace diff --git a/vcpkg b/vcpkg index 9eec002df..9e593bb18 160000 --- a/vcpkg +++ b/vcpkg @@ -1 +1 @@ -Subproject commit 9eec002df1cd109f8f104cd966a763daf9751489 +Subproject commit 9e593bb18ea69cc5095e012465dcd675a822ed0d From 62e590d3005a0f8ca31ef45e97a9923a2552b085 Mon Sep 17 00:00:00 2001 From: RickyRister <42636155+RickyRister@users.noreply.github.com> Date: Mon, 2 Nov 2026 00:37:05 -0700 Subject: [PATCH 096/108] [ServerGame] Refactor out spectator/judge bools into RoleInfo (#7761) --- .../network/server/remote/CMakeLists.txt | 1 + .../network/server/remote/game/role_info.h | 13 +++++++ .../game/server_abstract_participant.cpp | 22 +++++------ .../remote/game/server_abstract_participant.h | 10 ++--- .../remote/game/server_abstract_player.cpp | 13 +++---- .../server/remote/game/server_game.cpp | 39 +++++++++++-------- .../network/server/remote/game/server_game.h | 7 ++-- .../server/remote/game/server_player.cpp | 6 +-- .../server/remote/game/server_spectator.cpp | 3 +- .../server/remote/server_protocolhandler.cpp | 3 +- .../network/server/remote/server_room.cpp | 9 +++-- tests/server_game_join_test.cpp | 31 ++++++++------- 12 files changed, 89 insertions(+), 68 deletions(-) create mode 100644 libcockatrice_network/libcockatrice/network/server/remote/game/role_info.h diff --git a/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt index 8586a7bc8..181218e1a 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt @@ -8,6 +8,7 @@ set(HEADERS game/server_cardzone.h game/server_counter.h game/game_config.h + game/role_info.h game/server_deck_validation_strategy.h game/server_game.h game/server_game_lifecycle_strategy.h diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/role_info.h b/libcockatrice_network/libcockatrice/network/server/remote/game/role_info.h new file mode 100644 index 000000000..ef8692361 --- /dev/null +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/role_info.h @@ -0,0 +1,13 @@ +#ifndef COCKATRICE_ROLE_INFO_H +#define COCKATRICE_ROLE_INFO_H + +/** + * @brief Represents information about a participant's roles. + */ +struct RoleInfo +{ + bool isJudge = false; ///< Whether the participant joined as a judge + bool isSpectator = false; ///< Whether the participant joined as a spectator +}; + +#endif // COCKATRICE_ROLE_INFO_H diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.cpp index 5b8d901bb..ff8ea14f3 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.cpp @@ -61,10 +61,10 @@ Server_AbstractParticipant::Server_AbstractParticipant(Server_Game *_game, int _playerId, const ServerInfo_User &_userInfo, - bool _judge, + const RoleInfo &_roleInfo, Server_AbstractUserInterface *_userInterface) : ServerInfo_User_Container(_userInfo), game(_game), userInterface(_userInterface), pingTime(0), - playerId(_playerId), judge(_judge) + playerId(_playerId), roleInfo(_roleInfo) { } @@ -98,8 +98,8 @@ void Server_AbstractParticipant::getProperties(ServerInfo_PlayerProperties &resu if (withUserInfo) { copyUserInfo(*(result.mutable_user_info()), true); } - result.set_spectator(spectator); - result.set_judge(judge); + result.set_spectator(isSpectator()); + result.set_judge(isJudge()); result.set_ping_seconds(pingTime); getPlayerProperties(result); } @@ -176,7 +176,7 @@ Response::ResponseCode Server_AbstractParticipant::cmdUnconcede(const Command_Un Response::ResponseCode Server_AbstractParticipant::cmdJudge(const Command_Judge &cmd, ResponseContainer &rc, GameEventStorage &ges) { - if (!judge) { + if (!isJudge()) { return Response::RespFunctionNotAllowed; } @@ -204,7 +204,7 @@ Response::ResponseCode Server_AbstractParticipant::cmdReadyStart(const Command_R Response::ResponseCode Server_AbstractParticipant::cmdGameSay(const Command_GameSay &cmd, ResponseContainer & /*rc*/, GameEventStorage &ges) { - if (spectator) { + if (isSpectator()) { /* Spectators can only talk if: * (a) the game creator allows it * (b) the spectator is a moderator/administrator @@ -365,7 +365,7 @@ Response::ResponseCode Server_AbstractParticipant::cmdNextTurn(const Command_Nex return Response::RespGameNotStarted; } - if (!judge) { + if (!isJudge()) { return Response::RespFunctionNotAllowed; } @@ -381,7 +381,7 @@ Response::ResponseCode Server_AbstractParticipant::cmdSetActivePhase(const Comma return Response::RespGameNotStarted; } - if (!judge) { + if (!isJudge()) { return Response::RespFunctionNotAllowed; } @@ -415,8 +415,8 @@ Response::ResponseCode Server_AbstractParticipant::cmdReverseTurn(const Command_ ResponseContainer & /*rc*/, GameEventStorage &ges) { - if (!judge) { - if (spectator) { + if (!isJudge()) { + if (isSpectator()) { return Response::RespFunctionNotAllowed; } @@ -577,7 +577,7 @@ void Server_AbstractParticipant::setUserInterface(Server_AbstractUserInterface * void Server_AbstractParticipant::disconnectClient() { bool isRegistered = userInfo->user_level() & ServerInfo_User::IsRegistered; - if (!isRegistered || spectator) { + if (!isRegistered || isSpectator()) { game->removeParticipant(this, Event_Leave::USER_DISCONNECTED); } else { setUserInterface(nullptr); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.h index 195da9b7f..b6d4a8025 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.h @@ -2,6 +2,7 @@ #define ABSTRACT_PARTICIPANT_H #include "../serverinfo_user_container.h" +#include "role_info.h" #include "server_arrowtarget.h" #include @@ -61,8 +62,7 @@ protected: Server_AbstractUserInterface *userInterface; int pingTime; int playerId; - bool spectator; - bool judge; + RoleInfo roleInfo; virtual void getPlayerProperties(ServerInfo_PlayerProperties &result); mutable QMutex playerMutex; @@ -70,7 +70,7 @@ public: Server_AbstractParticipant(Server_Game *_game, int _playerId, const ServerInfo_User &_userInfo, - bool _judge, + const RoleInfo &_roleInfo, Server_AbstractUserInterface *_handler); ~Server_AbstractParticipant() override; virtual void prepareDestroy() @@ -91,11 +91,11 @@ public: } bool isSpectator() const { - return spectator; + return roleInfo.isSpectator; } bool isJudge() const { - return judge; + return roleInfo.isJudge; } Server_Game *getGame() const { diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp index c03f1e966..b5a620798 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp @@ -81,10 +81,9 @@ Server_AbstractPlayer::Server_AbstractPlayer(Server_Game *_game, const ServerInfo_User &_userInfo, bool _judge, Server_AbstractUserInterface *_userInterface) - : Server_AbstractParticipant(_game, _playerId, _userInfo, _judge, _userInterface), conceded(false), deck(nullptr), - sideboardLocked(true), readyStart(false), nextCardId(0) + : Server_AbstractParticipant(_game, _playerId, _userInfo, {.isJudge = _judge}, _userInterface), conceded(false), + deck(nullptr), sideboardLocked(true), readyStart(false), nextCardId(0) { - spectator = false; } Server_AbstractPlayer::~Server_AbstractPlayer() = default; @@ -295,7 +294,7 @@ Response::ResponseCode Server_AbstractPlayer::moveCard(GameEventStorage &ges, { // Disallow controller change to other zones than the table. if (((targetzone->getType() != ServerInfo_Zone::PublicZone) || !targetzone->hasCoords()) && - (startzone->getPlayer() != targetzone->getPlayer()) && !judge) { + (startzone->getPlayer() != targetzone->getPlayer()) && !isJudge()) { return Response::RespContextError; } @@ -808,7 +807,7 @@ Server_AbstractPlayer::cmdMoveCard(const Command_MoveCard &cmd, ResponseContaine return Response::RespNameNotFound; } - if ((startPlayer != this) && (!startZone->getPlayersWithWritePermission().contains(playerId)) && !judge) { + if ((startPlayer != this) && (!startZone->getPlayersWithWritePermission().contains(playerId)) && !isJudge()) { return Response::RespContextError; } @@ -821,7 +820,7 @@ Server_AbstractPlayer::cmdMoveCard(const Command_MoveCard &cmd, ResponseContaine return Response::RespNameNotFound; } - if ((startPlayer != this) && (targetPlayer != this) && !judge) { + if ((startPlayer != this) && (targetPlayer != this) && !isJudge()) { return Response::RespContextError; } @@ -1598,7 +1597,7 @@ Server_AbstractPlayer::cmdRevealCards(const Command_RevealCards &cmd, ResponseCo zone->addWritePermission(cmd.player_id()); } - if (judge) { + if (isJudge()) { ges.setOverwriteOwnership(true); } diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp index b1863c91e..49db999f6 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp @@ -41,6 +41,7 @@ #include "libcockatrice/protocol/pb/serverinfo_replay_match.pb.h" #include "libcockatrice/protocol/pb/serverinfo_user.pb.h" #include "libcockatrice/protocol/pb/session_event.pb.h" +#include "role_info.h" #include "server_abstract_player.h" #include "server_abstractuserinterface.h" #include "server_arrow.h" @@ -479,9 +480,8 @@ void Server_Game::stopGameIfFinished() Response::ResponseCode Server_Game::checkJoin(ServerInfo_User *user, const QString &_password, - bool spectator, - bool overrideRestrictions, - bool asJudge) + const RoleInfo &roleInfo, + bool overrideRestrictions) { Server_DatabaseInterface *databaseInterface = room->getServer()->getDatabaseInterface(); for (auto *participant : participants.values()) { @@ -490,11 +490,11 @@ Response::ResponseCode Server_Game::checkJoin(ServerInfo_User *user, } } - if (asJudge && !(user->user_level() & ServerInfo_User::IsJudge)) { + if (roleInfo.isJudge && !(user->user_level() & ServerInfo_User::IsJudge)) { return Response::RespUserLevelTooLow; } if (!(overrideRestrictions && (user->user_level() & (ServerInfo_User::IsModerator | ServerInfo_User::IsJudge)))) { - if ((_password != password) && !(spectator && !spectatorsNeedPassword)) { + if ((_password != password) && !(roleInfo.isSpectator && !spectatorsNeedPassword)) { return Response::RespWrongPassword; } if (!(user->user_level() & ServerInfo_User::IsRegistered) && onlyRegistered) { @@ -510,13 +510,13 @@ Response::ResponseCode Server_Game::checkJoin(ServerInfo_User *user, QString::fromStdString(user->name()))) { return Response::RespInIgnoreList; } - if (spectator) { + if (roleInfo.isSpectator) { if (!spectatorsAllowed) { return Response::RespSpectatorsNotAllowed; } } } - if (!spectator && (gameStarted || (getPlayerCount() >= getMaxPlayers()))) { + if (!roleInfo.isSpectator && (gameStarted || (getPlayerCount() >= getMaxPlayers()))) { return Response::RespGameFull; } @@ -537,19 +537,18 @@ bool Server_Game::containsUser(const QString &userName) const void Server_Game::addPlayer(Server_AbstractUserInterface *userInterface, ResponseContainer &rc, - bool spectator, - bool judge, + const RoleInfo &roleInfo, bool broadcastUpdate) { QMutexLocker locker(&gameMutex); Server_AbstractParticipant *newParticipant; - if (spectator) { + if (roleInfo.isSpectator) { newParticipant = new Server_Spectator(this, nextPlayerId++, userInterface->copyUserInfo(true, true, true), - judge, userInterface); + roleInfo.isJudge, userInterface); } else { - newParticipant = new Server_Player(this, nextPlayerId++, userInterface->copyUserInfo(true, true, true), judge, - userInterface); + newParticipant = new Server_Player(this, nextPlayerId++, userInterface->copyUserInfo(true, true, true), + roleInfo.isJudge, userInterface); } newParticipant->moveToThread(thread()); @@ -560,7 +559,7 @@ void Server_Game::addPlayer(Server_AbstractUserInterface *userInterface, const QString playerName = QString::fromStdString(newParticipant->getUserInfo()->name()); participants.insert(newParticipant->getPlayerId(), newParticipant); - if (spectator) { + if (roleInfo.isSpectator) { allSpectatorsEver.insert(playerName); } else { allPlayersEver.insert(playerName); @@ -579,7 +578,7 @@ void Server_Game::addPlayer(Server_AbstractUserInterface *userInterface, emit gameInfoChanged(gameInfo); } - if ((newParticipant->getUserInfo()->user_level() & ServerInfo_User::IsRegistered) && !spectator) { + if ((newParticipant->getUserInfo()->user_level() & ServerInfo_User::IsRegistered) && !roleInfo.isSpectator) { room->getServer()->addPersistentPlayer(playerName, room->getId(), gameId, newParticipant->getPlayerId()); } @@ -871,6 +870,13 @@ void Server_Game::createGameJoinedEvent(Server_AbstractParticipant *joiningParti } } +bool Server_Game::canSeePrivateEventsFor(const Server_AbstractParticipant *participant, int privatePlayerId) const +{ + return participant->getPlayerId() == privatePlayerId // same player + || participant->isJudge() // judges see everything + || (participant->isSpectator() && spectatorsSeeEverything); // spectators see everything +} + void Server_Game::sendGameEventContainer(GameEventContainer *cont, GameEventStorageItem::EventRecipients recipients, int privatePlayerId) @@ -879,8 +885,7 @@ void Server_Game::sendGameEventContainer(GameEventContainer *cont, cont->set_game_id(gameId); for (auto *participant : participants.values()) { - const bool playerPrivate = (participant->getPlayerId() == privatePlayerId) || participant->isJudge() || - (participant->isSpectator() && spectatorsSeeEverything); + const bool playerPrivate = canSeePrivateEventsFor(participant, privatePlayerId); if ((recipients.testFlag(GameEventStorageItem::SendToPrivate) && playerPrivate) || (recipients.testFlag(GameEventStorageItem::SendToOthers) && !playerPrivate)) { participant->sendGameEvent(*cont); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h index c7f8d0929..363c0ca94 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h @@ -62,6 +62,7 @@ class Message; } // namespace protobuf } // namespace google struct GameConfig; +struct RoleInfo; class Server_Game : public QObject { @@ -109,6 +110,7 @@ private: bool omniscient, bool withUserInfo); void storeGameInformation(); + bool canSeePrivateEventsFor(const Server_AbstractParticipant *participant, int privatePlayerId) const; signals: void sigStartGameIfReady(bool override); void gameInfoChanged(ServerInfo_Game gameInfo); @@ -188,12 +190,11 @@ public: return shareDecklistsOnLoad; } Response::ResponseCode - checkJoin(ServerInfo_User *user, const QString &_password, bool spectator, bool overrideRestrictions, bool asJudge); + checkJoin(ServerInfo_User *user, const QString &_password, const RoleInfo &roleInfo, bool overrideRestrictions); bool containsUser(const QString &userName) const; void addPlayer(Server_AbstractUserInterface *userInterface, ResponseContainer &rc, - bool spectator, - bool judge, + const RoleInfo &roleInfo, bool broadcastUpdate = true); void removeParticipant(Server_AbstractParticipant *participant, Event_Leave::LeaveReason reason); void removeArrowsRelatedToPlayer(GameEventStorage &ges, Server_AbstractPlayer *player); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.cpp index 949d04b4b..b40dae09b 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.cpp @@ -573,7 +573,7 @@ Server_Player::cmdNextTurn(const Command_NextTurn & /*cmd*/, ResponseContainer & return Response::RespGameNotStarted; } - if (conceded && !judge) { + if (conceded && !isJudge()) { return Response::RespContextError; } @@ -589,7 +589,7 @@ Response::ResponseCode Server_Player::cmdSetActivePhase(const Command_SetActiveP return Response::RespGameNotStarted; } - if (!judge) { + if (!isJudge()) { if (conceded) { return Response::RespContextError; } @@ -623,7 +623,7 @@ Response::ResponseCode Server_Player::cmdReverseTurn(const Command_ReverseTurn &cmd, ResponseContainer &rc, GameEventStorage &ges) { - if (!judge && conceded) { + if (!isJudge() && conceded) { return Response::RespContextError; } return Server_AbstractParticipant::cmdReverseTurn(cmd, rc, ges); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_spectator.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_spectator.cpp index d1c369949..164a4af5e 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_spectator.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_spectator.cpp @@ -11,7 +11,6 @@ Server_Spectator::Server_Spectator(Server_Game *_game, const ServerInfo_User &_userInfo, bool _judge, Server_AbstractUserInterface *_userInterface) - : Server_AbstractParticipant(_game, _playerId, _userInfo, _judge, _userInterface) + : Server_AbstractParticipant(_game, _playerId, _userInfo, {.isJudge = _judge, .isSpectator = true}, _userInterface) { - spectator = true; } diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp index 39c1eb3e6..f9cadc9aa 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp @@ -1141,7 +1141,8 @@ Server_ProtocolHandler::cmdCreateGame(const Command_CreateGame &cmd, Server_Room auto *game = new Server_Game(config, room); - game->addPlayer(this, rc, asSpectator, asJudge, false); + RoleInfo roleInfo{.isJudge = asJudge, .isSpectator = asSpectator}; + game->addPlayer(this, rc, roleInfo, false); room->addGame(game); return Response::RespOk; diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_room.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_room.cpp index 68b137cd4..78ef2b289 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_room.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_room.cpp @@ -1,5 +1,6 @@ #include "server_room.h" +#include "game/role_info.h" #include "game/server_game.h" #include "libcockatrice/protocol/pb/response.pb.h" #include "libcockatrice/protocol/pb/room_event.pb.h" @@ -372,11 +373,11 @@ Response::ResponseCode Server_Room::processJoinGameCommand(const Command_JoinGam QMutexLocker gameLocker(&game->gameMutex); - Response::ResponseCode result = - game->checkJoin(userInterface->getUserInfo(), QString::fromStdString(cmd.password()), cmd.spectator(), - cmd.override_restrictions(), cmd.join_as_judge()); + RoleInfo roleInfo{.isJudge = cmd.join_as_judge(), .isSpectator = cmd.spectator()}; + Response::ResponseCode result = game->checkJoin( + userInterface->getUserInfo(), QString::fromStdString(cmd.password()), roleInfo, cmd.override_restrictions()); if (result == Response::RespOk) { - game->addPlayer(userInterface, rc, cmd.spectator(), cmd.join_as_judge()); + game->addPlayer(userInterface, rc, roleInfo); } return result; diff --git a/tests/server_game_join_test.cpp b/tests/server_game_join_test.cpp index 8a2ae8c45..93895ab5c 100644 --- a/tests/server_game_join_test.cpp +++ b/tests/server_game_join_test.cpp @@ -4,6 +4,7 @@ */ #include "game/game_config.h" +#include "game/role_info.h" #include "game/server_game.h" #include "libcockatrice/protocol/pb/response.pb.h" #include "server.h" @@ -119,13 +120,13 @@ TEST_F(GameJoinOverrideTest, StaffBypassPasswordRestriction) game = makeGame(true, false, false, true); // A plain user cannot override the password even with the override flag set. - EXPECT_EQ(game->checkJoin(&plainUser, "wrong", false, true, false), Response::RespWrongPassword); + EXPECT_EQ(game->checkJoin(&plainUser, "wrong", {}, true), Response::RespWrongPassword); // Moderators and judges may enter any game regardless of the password. - EXPECT_EQ(game->checkJoin(&moderator, "wrong", false, true, false), Response::RespOk); - EXPECT_EQ(game->checkJoin(&judge, "wrong", false, true, false), Response::RespOk); + EXPECT_EQ(game->checkJoin(&moderator, "wrong", {}, true), Response::RespOk); + EXPECT_EQ(game->checkJoin(&judge, "wrong", {}, true), Response::RespOk); // Without the override flag judges are still subject to the password. - EXPECT_EQ(game->checkJoin(&judge, "wrong", false, false, true), Response::RespWrongPassword); - EXPECT_EQ(game->checkJoin(&judge, "secret", false, false, true), Response::RespOk); + EXPECT_EQ(game->checkJoin(&judge, "wrong", {.isJudge = true}, false), Response::RespWrongPassword); + EXPECT_EQ(game->checkJoin(&judge, "secret", {.isJudge = true}, false), Response::RespOk); } TEST_F(GameJoinOverrideTest, StaffBypassRegisteredOnlyRestriction) @@ -133,9 +134,9 @@ TEST_F(GameJoinOverrideTest, StaffBypassRegisteredOnlyRestriction) game = makeGame(false, true, false, true); // Without the override flag the only-registered restriction still applies. - EXPECT_EQ(game->checkJoin(&unregisteredJudge, QString(), false, false, false), Response::RespUserLevelTooLow); + EXPECT_EQ(game->checkJoin(&unregisteredJudge, QString(), {}, false), Response::RespUserLevelTooLow); // An unregistered judge may enter when overriding restrictions. - EXPECT_EQ(game->checkJoin(&unregisteredJudge, QString(), false, true, false), Response::RespOk); + EXPECT_EQ(game->checkJoin(&unregisteredJudge, QString(), {}, true), Response::RespOk); } TEST_F(GameJoinOverrideTest, StaffBypassBuddiesOnlyRestriction) @@ -143,10 +144,10 @@ TEST_F(GameJoinOverrideTest, StaffBypassBuddiesOnlyRestriction) game = makeGame(false, false, true, true); // A plain user who is not on the creator's buddy list gets rejected. - EXPECT_EQ(game->checkJoin(&plainUser, QString(), false, true, false), Response::RespOnlyBuddies); + EXPECT_EQ(game->checkJoin(&plainUser, QString(), {}, true), Response::RespOnlyBuddies); // Moderators and judges bypass the buddies-only restriction. - EXPECT_EQ(game->checkJoin(&moderator, QString(), false, true, false), Response::RespOk); - EXPECT_EQ(game->checkJoin(&judge, QString(), false, true, false), Response::RespOk); + EXPECT_EQ(game->checkJoin(&moderator, QString(), {}, true), Response::RespOk); + EXPECT_EQ(game->checkJoin(&judge, QString(), {}, true), Response::RespOk); } TEST_F(GameJoinOverrideTest, StaffBypassSpectatorsNotAllowedRestriction) @@ -154,11 +155,11 @@ TEST_F(GameJoinOverrideTest, StaffBypassSpectatorsNotAllowedRestriction) game = makeGame(false, false, false, false); // A plain user cannot spectate when the game disallows spectators. - EXPECT_EQ(game->checkJoin(&plainUser, QString(), true, false, false), Response::RespSpectatorsNotAllowed); + EXPECT_EQ(game->checkJoin(&plainUser, QString(), {.isSpectator = true}, false), Response::RespSpectatorsNotAllowed); // Moderators and judges may spectate any game regardless of the password // and the spectator restriction. - EXPECT_EQ(game->checkJoin(&moderator, "wrong", true, true, false), Response::RespOk); - EXPECT_EQ(game->checkJoin(&judge, "wrong", true, true, false), Response::RespOk); + EXPECT_EQ(game->checkJoin(&moderator, "wrong", {.isSpectator = true}, true), Response::RespOk); + EXPECT_EQ(game->checkJoin(&judge, "wrong", {.isSpectator = true}, true), Response::RespOk); } TEST_F(GameJoinOverrideTest, JudgeOverrideDoesNotGrantJudgeJoinToPlainUser) @@ -166,8 +167,8 @@ TEST_F(GameJoinOverrideTest, JudgeOverrideDoesNotGrantJudgeJoinToPlainUser) game = makeGame(false, false, false, true); // joining with join_as_judge still requires the judge flag even when overriding. - EXPECT_EQ(game->checkJoin(&plainUser, QString(), false, true, true), Response::RespUserLevelTooLow); - EXPECT_EQ(game->checkJoin(&judge, QString(), false, true, true), Response::RespOk); + EXPECT_EQ(game->checkJoin(&plainUser, QString(), {.isJudge = true}, true), Response::RespUserLevelTooLow); + EXPECT_EQ(game->checkJoin(&judge, QString(), {.isJudge = true}, true), Response::RespOk); } } // namespace From 8b0c980e85515c5e7d8a5e04a3889943b29ac756 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 08:39:07 +0100 Subject: [PATCH 097/108] [Client] Guard dragged card ghost from painting or dropping deleted cards (#7688) * [Client] Guard dragged card ghost from painting or dropping deleted cards * [Game] Revert handling for cards destroyed mid-drag Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> * [DeckView] Access pointer through QPointer in DeckViewCardDragItem --------- Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> Co-authored-by: zeldazach <7460172+zeldazach@users.noreply.github.com> --- .../board/abstract_card_drag_item.cpp | 20 ++++++++++++++++++- .../board/abstract_card_drag_item.h | 10 +++++----- .../game_graphics/board/card_drag_item.cpp | 7 ++++--- .../src/game_graphics/deckview/deck_view.cpp | 2 +- 4 files changed, 29 insertions(+), 10 deletions(-) diff --git a/cockatrice/src/game_graphics/board/abstract_card_drag_item.cpp b/cockatrice/src/game_graphics/board/abstract_card_drag_item.cpp index 2e18fca2d..c707b4022 100644 --- a/cockatrice/src/game_graphics/board/abstract_card_drag_item.cpp +++ b/cockatrice/src/game_graphics/board/abstract_card_drag_item.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -51,7 +52,20 @@ AbstractCardDragItem::AbstractCardDragItem(AbstractCardItem *_item, update(); }); - connect(item, &QObject::destroyed, this, &AbstractCardDragItem::deleteLater); + // The source card can be destroyed mid-drag (e.g. moved by a server event). deleteLater() alone is not enough: + // a scene repaint can run before the deferred delete and paint the dead card. Detach from the scene immediately. + connect(_item, &QObject::destroyed, this, [this]() { + hide(); + if (QGraphicsScene *sc = scene()) { + sc->removeItem(this); + } + deleteLater(); + }); +} + +AbstractCardItem *AbstractCardDragItem::getItem() const +{ + return item.data(); } QPainterPath AbstractCardDragItem::shape() const @@ -65,6 +79,10 @@ QPainterPath AbstractCardDragItem::shape() const void AbstractCardDragItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) { + if (item.isNull()) { + return; + } + item->paint(painter, option, widget); // adds a mask to the card so it looks like the card hasnt been placed yet diff --git a/cockatrice/src/game_graphics/board/abstract_card_drag_item.h b/cockatrice/src/game_graphics/board/abstract_card_drag_item.h index 18afdcbb8..80e40ff1d 100644 --- a/cockatrice/src/game_graphics/board/abstract_card_drag_item.h +++ b/cockatrice/src/game_graphics/board/abstract_card_drag_item.h @@ -14,6 +14,7 @@ #include #include #include +#include #include #include @@ -24,7 +25,8 @@ class AbstractCardDragItem : public QObject, public QGraphicsItem Q_OBJECT Q_INTERFACES(QGraphicsItem) protected: - AbstractCardItem *item; + /// Guarded pointer: the source card may be destroyed while this drag item still exists. + QPointer item; QPointF hotSpot; QList childDrags; @@ -44,10 +46,8 @@ public: } [[nodiscard]] QPainterPath shape() const override; void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; - [[nodiscard]] AbstractCardItem *getItem() const - { - return item; - } + /// @return the source card, or nullptr if it has already been destroyed. + [[nodiscard]] AbstractCardItem *getItem() const; [[nodiscard]] QPointF getHotSpot() const { return hotSpot; diff --git a/cockatrice/src/game_graphics/board/card_drag_item.cpp b/cockatrice/src/game_graphics/board/card_drag_item.cpp index 265ea759c..b263e8c92 100644 --- a/cockatrice/src/game_graphics/board/card_drag_item.cpp +++ b/cockatrice/src/game_graphics/board/card_drag_item.cpp @@ -130,8 +130,9 @@ void CardDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) sc->removeItem(this); QList dragItemList; - CardZoneLogic *startZone = static_cast(item)->getZone(); - if (currentZone && !(static_cast(item)->getAttachedTo() && (startZone == currentZone->getLogic()))) { + CardZoneLogic *startZone = static_cast(item.data())->getZone(); + if (currentZone && + !(static_cast(item.data())->getAttachedTo() && (startZone == currentZone->getLogic()))) { if (!occupied) { dragItemList.append(this); } @@ -139,7 +140,7 @@ void CardDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) for (int i = 0; i < childDrags.size(); i++) { CardDragItem *c = static_cast(childDrags[i]); if (!occupied && - !(static_cast(c->item)->getAttachedTo() && (startZone == currentZone->getLogic())) && + !(static_cast(c->item.data())->getAttachedTo() && (startZone == currentZone->getLogic())) && !c->occupied) { dragItemList.append(c); } diff --git a/cockatrice/src/game_graphics/deckview/deck_view.cpp b/cockatrice/src/game_graphics/deckview/deck_view.cpp index 630530792..6e0b0eaaa 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view.cpp +++ b/cockatrice/src/game_graphics/deckview/deck_view.cpp @@ -77,7 +77,7 @@ void DeckViewCardDragItem::updatePosition(const QPointF &cursorScenePos) void DeckViewCardDragItem::handleDrop(DeckViewCardContainer *target) { - auto *card = static_cast(item); + auto *card = static_cast(item.data()); auto *start = static_cast(item->parentItem()); start->removeCard(card); target->addCard(card); From 9f30892dffb68a5551e20ecee65db5429be7f354 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:28:23 +0100 Subject: [PATCH 098/108] [Client] Fix crash when local game commands are sent after shutdown (#7714) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Client] Fix crash when local game commands are sent after shutdown * [Network] Fix formatting in local client debug log * [CI] Keep the vcpkg submodule pointer on the manifest baseline vcpkg.json asks for builtin-baseline 9eec002 (vcpkg 2026.10.16), but the submodule gitlink was still at 9e593bb. The runners check submodules out shallowly, so vcpkg-tool cannot read versions/baseline.json out of the baseline commit and every vcpkg leg (Windows and all macOS) fails in CMake configure with: failed to `git show` versions/baseline.json git phrases that as "path ... exists on disk, but not in " even when the commit is simply absent from the shallow clone, so it reads like a tooling or network failure rather than a mismatched pointer. Point the submodule back at 9eec002, and add a cheap job that fails when the gitlink and builtin-baseline disagree, before the five build legs pay for it. --------- Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> Co-authored-by: Lukas Brübach --- .github/workflows/desktop-build.yml | 32 +++- cockatrice/src/interface/window_main.cpp | 6 +- .../network/client/local/local_client.cpp | 10 +- .../network/client/local/local_client.h | 6 +- tests/CMakeLists.txt | 12 ++ tests/local_client_lifetime_test.cpp | 149 ++++++++++++++++++ vcpkg | 2 +- 7 files changed, 208 insertions(+), 9 deletions(-) create mode 100644 tests/local_client_lifetime_test.cpp diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 54068f5a0..df054ea26 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -101,6 +101,36 @@ jobs: --title "$release_name" \ --notes-file "$body_path" + vcpkg-baseline: + name: vcpkg baseline + runs-on: ubuntu-slim + steps: + - name: "Checkout" + uses: actions/checkout@v7 + + # vcpkg-tool reads versions/baseline.json out of the commit named by + # `builtin-baseline`, through the `vcpkg` submodule's git history. A submodule + # pointer that lags the manifest - e.g. a stale `vcpkg/` worktree captured by + # `git add` in a PR - makes every vcpkg leg fail in configure with "failed to + # `git show` versions/baseline.json". That reads like a baseline or network + # problem, when the pointer is what has to move. Check the two agree here in + # seconds instead of on five build legs. + - name: "Check the submodule pointer against the manifest baseline" + shell: bash + run: | + pointer="$(git rev-parse HEAD:vcpkg 2>/dev/null || true)" + baseline="$(sed -nE 's/.*"builtin-baseline"[[:space:]]*:[[:space:]]*"([0-9a-f]{40})".*/\1/p' vcpkg.json)" + if [[ -z "$pointer" || -z "$baseline" ]]; then + echo "::error file=vcpkg.json::could not read the vcpkg submodule pointer or the builtin-baseline" + exit 1 + fi + if [[ "$pointer" != "$baseline" ]]; then + echo "::error file=vcpkg.json::vcpkg submodule is at $pointer but builtin-baseline is $baseline" + echo "Move them together: git -C vcpkg checkout $baseline && git add vcpkg" + exit 1 + fi + echo "vcpkg submodule and builtin-baseline agree on $baseline" + build-linux: strategy: fail-fast: false @@ -466,7 +496,7 @@ jobs: vcpkg_upload: 1 # only one leg per vcpkg_scope hands its archives over name: ${{ matrix.os }} ${{ matrix.target }}${{ matrix.soc == 'Intel' && ' Intel' || '' }}${{ matrix.type == 'Debug' && ' Debug' || '' }} - needs: configure + needs: [configure, vcpkg-baseline] runs-on: ${{ matrix.runner }} timeout-minutes: 100 env: diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index f8ab5d3f6..d546676bc 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -254,7 +254,11 @@ void MainWindow::actWatchReplay() void MainWindow::localGameEnded() { - delete localServer; + // Defer deletion so the LocalClients (already scheduled with deleteLater() in TabSupervisor::stop) + // and any queued commands are processed before the server and its interfaces are destroyed. + if (localServer) { + localServer->deleteLater(); + } localServer = nullptr; aConnect->setEnabled(true); diff --git a/libcockatrice_network/libcockatrice/network/client/local/local_client.cpp b/libcockatrice_network/libcockatrice/network/client/local/local_client.cpp index f7d8645e8..b9a1566f8 100644 --- a/libcockatrice_network/libcockatrice/network/client/local/local_client.cpp +++ b/libcockatrice_network/libcockatrice/network/client/local/local_client.cpp @@ -33,16 +33,18 @@ LocalClient::LocalClient(LocalServerInterface *_lsi, sendCommand(prepareSessionCommand(joinCmd)); } -LocalClient::~LocalClient() -{ -} - void LocalClient::sendCommandContainer(const CommandContainer &cont) { if (LocalClientLog().isDebugEnabled()) { qCDebug(LocalClientLog).noquote() << userName << "OUT" << getSafeDebugString(cont); } + if (lsi.isNull()) { + qCWarning(LocalClientLog).noquote() + << userName << "dropping command, local server interface no longer exists:" << getSafeDebugString(cont); + return; + } + lsi->itemFromClient(cont); } diff --git a/libcockatrice_network/libcockatrice/network/client/local/local_client.h b/libcockatrice_network/libcockatrice/network/client/local/local_client.h index 3ae570255..15b36d3db 100644 --- a/libcockatrice_network/libcockatrice/network/client/local/local_client.h +++ b/libcockatrice_network/libcockatrice/network/client/local/local_client.h @@ -10,6 +10,7 @@ #include "../abstract/abstract_client.h" #include +#include #include #include @@ -24,14 +25,15 @@ class LocalClient : public AbstractClient { Q_OBJECT private: - LocalServerInterface *lsi; + // Guarded pointer: the LocalServer (and its interfaces) may be destroyed while this client + // still has pending commands queued on the event loop. + QPointer lsi; public: LocalClient(LocalServerInterface *_lsi, const QString &_playerName, const QString &_clientId, QObject *parent = nullptr); - ~LocalClient() override; void sendCommandContainer(const CommandContainer &cont) override; private slots: diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 261900c01..0bb9654a1 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -350,6 +350,18 @@ cockatrice_add_test( LABELS ci ) +# The local game runs one client and one server in the same process. +# Either of them can be destroyed first; the survivor has to stay usable. +cockatrice_add_test( + NAME + local_client_lifetime_test + SOURCES + local_client_lifetime_test.cpp + LIBS + libcockatrice_network + LABELS + ci +) cockatrice_add_test( NAME server_room_channels_test diff --git a/tests/local_client_lifetime_test.cpp b/tests/local_client_lifetime_test.cpp new file mode 100644 index 000000000..95a2b1380 --- /dev/null +++ b/tests/local_client_lifetime_test.cpp @@ -0,0 +1,149 @@ +/** + * @file local_client_lifetime_test.cpp + * @brief Tests that a LocalClient survives the death of the LocalServer it talks to. + * @ingroup Tests + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// The server library refers to this global; the real applications define it in main.cpp. +RNG_Abstract *rng = nullptr; + +namespace +{ + +// GTest's main() creates no QCoreApplication, which QObject::deleteLater() needs. +const QCoreApplication &application() +{ + static int argc = 1; + static char executable[] = "local_client_lifetime_test"; + static char *argv[] = {executable, nullptr}; + static const QCoreApplication app(argc, argv); + return app; +} + +/// Collects the warnings one logging category emits while this object is alive. +/// +/// The handler is process-global and Qt does not nest handlers, so only one capture can be +/// installed at a time; GTest running one case at a time is what makes that safe. +class CategoryWarningCapture +{ +public: + explicit CategoryWarningCapture(const char *_category) : category(_category) + { + previousHandler = qInstallMessageHandler(captureMessage); + s_active = this; + } + + ~CategoryWarningCapture() + { + qInstallMessageHandler(previousHandler); + s_active = nullptr; + } + + CategoryWarningCapture(const CategoryWarningCapture &) = delete; + CategoryWarningCapture &operator=(const CategoryWarningCapture &) = delete; + + const QStringList &warnings() const + { + return capturedWarnings; + } + +private: + static void captureMessage(QtMsgType type, const QMessageLogContext &context, const QString &message) + { + if (s_active != nullptr && type == QtWarningMsg && context.category != nullptr && + std::strcmp(s_active->category, context.category) == 0) { + s_active->capturedWarnings.append(message); + } + + // Forward everything else, so a failure elsewhere in the test is still visible. + if (previousHandler != nullptr) { + previousHandler(type, context, message); + return; + } + std::fprintf(stderr, "%s\n", qPrintable(qFormatLogMessage(type, context, message))); + } + + const char *category; + QStringList capturedWarnings; + + static CategoryWarningCapture *s_active; + static QtMessageHandler previousHandler; +}; + +CategoryWarningCapture *CategoryWarningCapture::s_active = nullptr; +QtMessageHandler CategoryWarningCapture::previousHandler = nullptr; + +class LocalClientLifetimeTest : public ::testing::Test +{ +protected: + void SetUp() override + { + (void)application(); + + server = std::make_unique(); + LocalServerInterface *lsi = server->newConnection(); + ASSERT_NE(lsi, nullptr); + // Building the client logs in and joins room 0 through that interface, exactly as + // MainWindow::startLocalGame() does. + client = std::make_unique(lsi, QStringLiteral("lifetime-test-player"), + QStringLiteral("lifetime-test-client-id")); + } + + void TearDown() override + { + // Either case may already have deleted one of these. + client.reset(); + server.reset(); + } + + std::unique_ptr server; + std::unique_ptr client; +}; + +TEST_F(LocalClientLifetimeTest, SendAfterServerDeletedIsDropped) +{ + server.reset(); // also destroys the LocalServerInterface the client was built on + + CategoryWarningCapture capture("local_client"); + // A default container stands in for a command still queued when the local game ended. + client->sendCommandContainer(CommandContainer()); + + ASSERT_EQ(capture.warnings().size(), 1); + EXPECT_TRUE(capture.warnings().first().contains(QStringLiteral("dropping command"))) + << capture.warnings().first().toStdString(); + + // The surviving client is intact: reading its state must not touch the deleted interface. + EXPECT_EQ(client->getUserName(), QStringLiteral("lifetime-test-player")); +} + +TEST_F(LocalClientLifetimeTest, DestroyingClientAfterServerIsSafe) +{ + server.reset(); + + CategoryWarningCapture capture("local_client"); + client.reset(); // must neither crash nor touch the destroyed interface + + // The destructor itself must have been silent. + EXPECT_TRUE(capture.warnings().isEmpty()); + + // A working capture proves the emptiness above is real rather than a broken handler. + qCWarning(LocalClientLog) << "local_client_lifetime_test capture self-check"; + EXPECT_EQ(capture.warnings().size(), 1); +} + +} // namespace diff --git a/vcpkg b/vcpkg index 9e593bb18..9eec002df 160000 --- a/vcpkg +++ b/vcpkg @@ -1 +1 @@ -Subproject commit 9e593bb18ea69cc5095e012465dcd675a822ed0d +Subproject commit 9eec002df1cd109f8f104cd966a763daf9751489 From b0a642df750c19c62470d13702eeade4b0d1966a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Tue, 20 Oct 2026 11:24:03 +0200 Subject: [PATCH 099/108] [Icons] Add SVG currentColor sentinel resolver Qt's SVG renderer has no CSS cascade, so a `currentColor` fill has to be substituted before the bytes reach QSvgRenderer. - Match and replace on bytes: both the sentinel and the replacement are ASCII, so the document never goes through a UTF-16 round trip, and files that are not valid UTF-8 survive unchanged. - Document that the match is a plain case-insensitive substring, so an identifier that merely starts with the sentinel gets rewritten too. --- libcockatrice_utility/CMakeLists.txt | 2 + .../libcockatrice/utility/svg_recolor.cpp | 44 +++++++++++++ .../libcockatrice/utility/svg_recolor.h | 31 +++++++++ tests/CMakeLists.txt | 1 + tests/svg_recolor_test.cpp | 66 +++++++++++++++++++ 5 files changed, 144 insertions(+) create mode 100644 libcockatrice_utility/libcockatrice/utility/svg_recolor.cpp create mode 100644 libcockatrice_utility/libcockatrice/utility/svg_recolor.h create mode 100644 tests/svg_recolor_test.cpp diff --git a/libcockatrice_utility/CMakeLists.txt b/libcockatrice_utility/CMakeLists.txt index 8d2cb5b92..98ad40e69 100644 --- a/libcockatrice_utility/CMakeLists.txt +++ b/libcockatrice_utility/CMakeLists.txt @@ -8,6 +8,7 @@ set(UTILITY_SOURCES libcockatrice/utility/passwordhasher.cpp libcockatrice/utility/report_categories.cpp libcockatrice/utility/server_rate_limiter.cpp + libcockatrice/utility/svg_recolor.cpp libcockatrice/utility/translation_loader.cpp libcockatrice/utility/user_data_backup.cpp libcockatrice/utility/warning_categories.cpp @@ -34,6 +35,7 @@ set(UTILITY_HEADERS libcockatrice/utility/zone_names.h libcockatrice/utility/days_years_between.h libcockatrice/utility/server_rate_limiter.h + libcockatrice/utility/svg_recolor.h libcockatrice/utility/translation_loader.h libcockatrice/utility/user_data_backup.h libcockatrice/utility/warning_categories.h diff --git a/libcockatrice_utility/libcockatrice/utility/svg_recolor.cpp b/libcockatrice_utility/libcockatrice/utility/svg_recolor.cpp new file mode 100644 index 000000000..041e289fe --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/svg_recolor.cpp @@ -0,0 +1,44 @@ +#include "svg_recolor.h" + +#include + +namespace +{ +constexpr QByteArrayView SENTINEL = "currentColor"; + +/// Case-insensitive byte search for the sentinel, starting at @p from. +/// QByteArray::indexOf() only matches case-sensitively for QByteArrayView, and +/// its char overload cannot match a word. +qsizetype findSentinel(const QByteArray &bytes, qsizetype from) +{ + // QByteArrayView::sliced() is a view, so the scan does not allocate a copy + // at every byte position (QByteArray::sliced() would). + const QByteArrayView view(bytes); + for (qsizetype i = from; i + SENTINEL.size() <= view.size(); ++i) { + if (view.sliced(i, SENTINEL.size()).compare(SENTINEL, Qt::CaseInsensitive) == 0) { + return i; + } + } + return -1; +} +} // namespace + +QByteArray resolveSvgColorSentinel(const QByteArray &svg, const QByteArray &color) +{ + // The sentinel and the replacement are both ASCII, so stay in bytes: a + // QString round trip would decode the whole document (and mangle bytes that + // are not valid UTF-8) even on the no-op path. + qsizetype pos = findSentinel(svg, 0); + if (pos < 0) { + return svg; + } + + QByteArray resolved = svg; + while (pos >= 0) { + resolved.replace(pos, SENTINEL.size(), color); + // Skip past the inserted color: a caller's color never contains the + // sentinel, and this keeps the scan linear. + pos = findSentinel(resolved, pos + color.size()); + } + return resolved; +} diff --git a/libcockatrice_utility/libcockatrice/utility/svg_recolor.h b/libcockatrice_utility/libcockatrice/utility/svg_recolor.h new file mode 100644 index 000000000..56d609b87 --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/svg_recolor.h @@ -0,0 +1,31 @@ +/** + * @file svg_recolor.h + * @ingroup Core + */ + +#ifndef SVG_RECOLOR_H +#define SVG_RECOLOR_H + +#include + +/** + * @brief Resolves the `currentColor` sentinel in authored SVG data. + * + * Qt's SVG renderer has no CSS cascade or ambient color context, so the + * sentinel is not resolved by Qt. Callers pass the concrete color they want + * (for example a QPalette role serialized to #RRGGBB) and receive SVG bytes + * that are safe to hand to QSvgRenderer. + * + * Matching is a plain, case-insensitive substring search, not a CSS parse: an + * identifier that merely starts with the sentinel (for example + * `id="currentColorStop"`) is rewritten too. That is acceptable for the assets + * this project authors, and keeps the resolver dependency-free. + * + * @param svg SVG source bytes, expected to be UTF-8. Invalid UTF-8 is preserved + * byte-for-byte, since the resolver never decodes the document. + * @param color Concrete SVG color (for example "#ff00ff"), as ASCII bytes. + * @return The recolored SVG source; the input is left untouched. + */ +QByteArray resolveSvgColorSentinel(const QByteArray &svg, const QByteArray &color); + +#endif // SVG_RECOLOR_H diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 0bb9654a1..5dff3a761 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -177,6 +177,7 @@ cockatrice_add_test(NAME test_age_formatting SOURCES test_age_formatting.cpp LIB cockatrice_add_test(NAME user_data_backup_test SOURCES user_data_backup_test.cpp LIBS libcockatrice_utility) cockatrice_add_test(NAME password_hash_test SOURCES password_hash_test.cpp LIBS libcockatrice_utility) cockatrice_add_test(NAME report_categories_test SOURCES report_categories_test.cpp LIBS libcockatrice_utility) +cockatrice_add_test(NAME svg_recolor_test SOURCES svg_recolor_test.cpp LIBS libcockatrice_utility) cockatrice_add_test(NAME warning_categories_test SOURCES warning_categories_test.cpp LIBS libcockatrice_utility) # The installer's holder probe is Windows-only, but the wording it produces and the # question which programs are the payload's own are plain C++ and are what a stuck user diff --git a/tests/svg_recolor_test.cpp b/tests/svg_recolor_test.cpp new file mode 100644 index 000000000..abe1e8d1a --- /dev/null +++ b/tests/svg_recolor_test.cpp @@ -0,0 +1,66 @@ +#include "libcockatrice/utility/svg_recolor.h" + +#include +#include + +namespace +{ + +TEST(SvgRecolor, ReplacesEverySentinel) +{ + const QByteArray svg = R"()"; + const QByteArray result = resolveSvgColorSentinel(svg, "#ff8800"); + + EXPECT_EQ(result.count("#ff8800"), 2); + EXPECT_FALSE(result.contains("currentColor")); +} + +TEST(SvgRecolor, ReplacesSentinelRegardlessOfCase) +{ + const QByteArray svg = R"()"; + + EXPECT_EQ(resolveSvgColorSentinel(svg, "#ff8800").count("#ff8800"), 2); +} + +TEST(SvgRecolor, LeavesTheRestOfTheDocumentIntact) +{ + const QByteArray svg = + R"()"; + const QByteArray result = resolveSvgColorSentinel(svg, "#123456"); + + EXPECT_TRUE(result.contains(R"(viewBox="0 0 24 24")")); + EXPECT_TRUE(result.contains(R"(d="M0 0h24v24")")); + EXPECT_TRUE(result.contains("an ordinary comment")); +} + +TEST(SvgRecolor, DoesNotModifyTheInput) +{ + const QByteArray svg = R"()"; + const QByteArray original = svg; + + resolveSvgColorSentinel(svg, "#000000"); + + EXPECT_EQ(svg, original); +} + +TEST(SvgRecolor, PreservesBytesThatAreNotValidUtf8) +{ + QByteArray svg = ""; + svg.append("\xff\xfe"); + svg.append(R"()"); + + const QByteArray result = resolveSvgColorSentinel(svg, "#ff8800"); + + EXPECT_TRUE(result.contains("\xff\xfe")); + EXPECT_FALSE(result.contains("currentColor")); + EXPECT_EQ(result.count("#ff8800"), 1); +} + +TEST(SvgRecolor, NoSentinelIsANoOp) +{ + const QByteArray svg = R"()"; + + EXPECT_EQ(resolveSvgColorSentinel(svg, "#ffffff"), svg); +} + +} // namespace From d789f7c3ad353704e7f06c522c09992e7672586c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Tue, 20 Oct 2026 11:32:16 +0200 Subject: [PATCH 100/108] [Icons] Add palette-aware themed icon engine A QPixmap snapshot cannot follow palette changes, so expose a QIcon whose engine resolves the `currentColor` sentinel from the live palette on every pixmap request. Icons stay legible across light/dark switches without recreating actions or buttons. - Resolve the asset, its bytes and its intrinsic size once, and drop them on ThemeManager::themeChanged/paletteChanged. A repaint does no file I/O and no theme.cfg parse; the palette color stays part of the render cache key. - Centre the glyph on QRectF(rect).center(): QRect::center() floors and uses right = left + width - 1, which shifted every 16x16 item-view glyph up and left by a logical pixel and clipped its last row and column. - Take a raster override's intrinsic size from QImageReader instead of the 24x24 SVG default, so a non-square override reports the box it draws. - Set the device pixel ratio before inserting into the render cache, so a cache hit no longer detaches the pixmap on every paint. --- cockatrice/CMakeLists.txt | 1 + cockatrice/src/interface/theme_icon.cpp | 300 ++++++++++++++++++++++++ cockatrice/src/interface/theme_icon.h | 50 ++++ oracle/CMakeLists.txt | 1 + 4 files changed, 352 insertions(+) create mode 100644 cockatrice/src/interface/theme_icon.cpp create mode 100644 cockatrice/src/interface/theme_icon.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 642d998a0..ade7c3976 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -149,6 +149,7 @@ set(cockatrice_SOURCES src/interface/widgets/utility/reversed_completer_model.cpp src/interface/pixel_map_generator.cpp src/interface/theme_config.cpp + src/interface/theme_icon.cpp src/interface/theme_manager.cpp src/interface/palette_editor/color_button.cpp src/interface/palette_editor/palette_generator.cpp diff --git a/cockatrice/src/interface/theme_icon.cpp b/cockatrice/src/interface/theme_icon.cpp new file mode 100644 index 000000000..161c436ca --- /dev/null +++ b/cockatrice/src/interface/theme_icon.cpp @@ -0,0 +1,300 @@ +#include "theme_icon.h" + +#include "libcockatrice/utility/svg_recolor.h" +#include "theme_manager.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +/** + * @brief Palette color that replaces `currentColor`, following the icon mode. + */ +QByteArray sentinelColor(QIcon::Mode mode, QPalette::ColorRole role) +{ + const QPalette::ColorGroup group = mode == QIcon::Disabled ? QPalette::Disabled : QPalette::Active; + const QPalette::ColorRole effective = + mode == QIcon::Selected && role == QPalette::WindowText ? QPalette::HighlightedText : role; + return qApp->palette().color(group, effective).name(QColor::HexRgb).toLatin1(); +} + +/** + * @brief Reads an SVG through the `theme:` search path. + */ +QByteArray readSvg(const QString &resolvedPath) +{ + QFile file(QStringLiteral("theme:") + resolvedPath); + if (!file.open(QIODevice::ReadOnly)) { + return {}; + } + return file.readAll(); +} + +/** + * @brief Renders SVG bytes, scaled to @p size when one is given. + * + * An empty size means the asset's intrinsic size; @p devicePixelRatio only + * applies in that case so HiDPI callers still get a crisp glyph. + */ +QPixmap renderSvg(const QByteArray &data, const QSize &size, qreal devicePixelRatio) +{ + QSvgRenderer renderer(data); + if (!renderer.isValid()) { + return {}; + } + + QSize renderSize = size; + if (renderSize.isEmpty()) { + renderSize = renderer.defaultSize(); + renderSize = + QSize(qRound(renderSize.width() * devicePixelRatio), qRound(renderSize.height() * devicePixelRatio)); + } + if (!renderSize.isValid() || renderSize.isEmpty()) { + return {}; + } + + QPixmap pixmap(renderSize); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + renderer.render(&painter); + return pixmap; +} + +bool hasSentinel(const QByteArray &svg) +{ + return !svg.isEmpty() && QString::fromUtf8(svg).contains(QStringLiteral("currentColor"), Qt::CaseInsensitive); +} + +QString cacheKey(const QString &path, const QByteArray &variant, const QSize &size, qreal devicePixelRatio) +{ + return path + QLatin1Char('|') + QString::fromLatin1(variant) + QLatin1Char('|') + QString::number(size.width()) + + QLatin1Char('x') + QString::number(size.height()) + QLatin1Char('@') + QString::number(devicePixelRatio); +} + +/** + * @brief QIcon engine rendering a `theme:` SVG with `currentColor` resolved on demand. + * + * Qt's SVG renderer has no ambient color, so the sentinel is substituted from + * the current palette on every pixmap request. Asset resolution, the file bytes + * and the intrinsic size are cached until the theme changes: a repaint then + * does no file I/O at all. The render cache is keyed by resolved path and pixel + * size plus a variant: the palette color for sentinel SVGs, the icon mode for + * assets that carry their own colors. A palette change therefore produces new + * keys instead of reusing art built under the previous palette, and the + * non-sentinel path (raster overrides, plain SVGs) is cached like any other. + */ +class ThemeSvgIconEngine final : public QObject, public QIconEngine +{ +public: + ThemeSvgIconEngine(QString _prefix, QPalette::ColorRole _role) : prefix(std::move(_prefix)), role(_role) + { + if (themeManager) { + // A theme switch changes the resolved asset (scheme-qualified + // variants, raster overrides) and a palette change invalidates + // every cached colour, so both drop the caches instead of + // re-reading theme.cfg and the assets on every repaint. + connect(themeManager, &ThemeManager::themeChanged, this, &ThemeSvgIconEngine::invalidate); + connect(themeManager, &ThemeManager::paletteChanged, this, &ThemeSvgIconEngine::invalidate); + } + } + + QIconEngine *clone() const override + { + return new ThemeSvgIconEngine(prefix, role); + } + + void paint(QPainter *painter, const QRect &rect, QIcon::Mode mode, QIcon::State) override + { + const qreal devicePixelRatio = painter->device() ? painter->device()->devicePixelRatioF() : 1.0; + const QSize logicalSize = fitToSize(rect.size()); + if (logicalSize.isEmpty()) { + return; + } + const QSize pixelSize(qRound(logicalSize.width() * devicePixelRatio), + qRound(logicalSize.height() * devicePixelRatio)); + const QPixmap pixmap = render(pixelSize, devicePixelRatio, mode); + if (!pixmap.isNull()) { + // QRect::center() would floor and drop the last row/column of a + // 16x16 glyph; the center has to be fractional so glyphs stay + // unshifted and unsclipped in item views. + const QSizeF contentSize = pixmap.deviceIndependentSize(); + const QRectF targetRect( + QRectF(rect).center() - QPointF(contentSize.width() / 2.0, contentSize.height() / 2.0), contentSize); + painter->drawPixmap(targetRect, pixmap, QRectF(pixmap.rect())); + } + } + + QPixmap pixmap(const QSize &size, QIcon::Mode mode, QIcon::State) override + { + return render(fitToSize(size), 1.0, mode); + } + + QPixmap scaledPixmap(const QSize &size, QIcon::Mode mode, QIcon::State, qreal scale) override + { + const QSize logicalSize = fitToSize(size); +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + const QSize pixelSize(qRound(logicalSize.width() * scale), qRound(logicalSize.height() * scale)); +#else + // Qt < 6.8 passes the device-dependent size to the engine. + const QSize pixelSize = logicalSize; +#endif + return render(pixelSize, scale, mode); + } + + QSize actualSize(const QSize &size, QIcon::Mode, QIcon::State) override + { + return fitToSize(size); + } + +private: + void invalidate() + { + hasResolvedAsset = false; + hasIntrinsicSize = false; + cache.clear(); + } + + void ensureResolved() const + { + if (hasResolvedAsset && (!themeManager || themeManager->getCurrentThemePath() == resolvedThemePath)) { + return; + } + resolvedThemePath = themeManager ? themeManager->getCurrentThemePath() : QString(); + resolvedPath = themeManager ? themeManager->assetPath(prefix) : QString(); + resolvedAssetIsSvg = resolvedPath.endsWith(QStringLiteral(".svg"), Qt::CaseInsensitive); + resolvedSvg = resolvedAssetIsSvg ? readSvg(resolvedPath) : QByteArray(); + resolvedHasSentinel = hasSentinel(resolvedSvg); + hasResolvedAsset = true; + hasIntrinsicSize = false; + } + + QSize intrinsicSize() const + { + ensureResolved(); + if (!hasIntrinsicSize) { + if (resolvedAssetIsSvg) { + QSvgRenderer renderer(resolvedSvg); + intrinsicSizeCache = renderer.isValid() ? renderer.defaultSize() : QSize(); + } else { + // A theme may override a glyph with a raster; take its real + // aspect ratio instead of assuming the 24x24 SVG default, so + // actualSize() and the rendered pixmap agree. + QImageReader reader(QStringLiteral("theme:") + resolvedPath); + intrinsicSizeCache = reader.size(); + } + if (intrinsicSizeCache.isEmpty()) { + intrinsicSizeCache = QSize(24, 24); + } + hasIntrinsicSize = true; + } + return intrinsicSizeCache; + } + + QSize fitToSize(const QSize &size) const + { + if (size.isEmpty()) { + return size; + } + return intrinsicSize().scaled(size, Qt::KeepAspectRatio); + } + + QPixmap fromAsset(const QSize &pixelSize, QIcon::Mode mode) const + { + QPixmap pixmap(QStringLiteral("theme:") + resolvedPath); + if (!pixelSize.isEmpty() && pixmap.size() != pixelSize) { + pixmap = pixmap.scaled(pixelSize, Qt::KeepAspectRatio, Qt::SmoothTransformation); + } + if (mode == QIcon::Disabled && !pixmap.isNull()) { + pixmap = QApplication::style()->generatedIconPixmap(QIcon::Disabled, pixmap, nullptr); + } + return pixmap; + } + + QPixmap render(const QSize &pixelSize, qreal devicePixelRatio, QIcon::Mode mode) const + { + ensureResolved(); + + // Same cache for both paths: sentinel art varies by palette color, art + // with its own colors varies by mode (Disabled is generated). A color + // variant is never a bare digit, so the two key spaces cannot collide. + const QByteArray variant = + resolvedHasSentinel ? sentinelColor(mode, role) : QByteArray::number(static_cast(mode)); + const QString key = cacheKey(resolvedPath, variant, pixelSize, devicePixelRatio); + const auto cached = cache.constFind(key); + if (cached != cache.constEnd()) { + return cached.value(); + } + + QPixmap pixmap; + if (resolvedHasSentinel) { + pixmap = renderSvg(resolveSvgColorSentinel(resolvedSvg, variant), pixelSize, devicePixelRatio); + } else { + // Authored art carries its own colors: use Qt's normal loader so + // raster overrides and non-SVG assets keep working. + pixmap = fromAsset(pixelSize, mode); + } + if (!pixmap.isNull()) { + // Set the ratio before caching: assigning it to a cached pixmap + // afterwards detaches (deep-copies) it on every paint. + pixmap.setDevicePixelRatio(devicePixelRatio); + } + if (cache.size() >= 64) { + cache.clear(); + } + cache.insert(key, pixmap); + return pixmap; + } + + QString prefix; + QPalette::ColorRole role; + mutable QHash cache; + mutable QString resolvedPath; + mutable QString resolvedThemePath; + mutable QByteArray resolvedSvg; + mutable QSize intrinsicSizeCache; + mutable bool resolvedAssetIsSvg = false; + mutable bool resolvedHasSentinel = false; + mutable bool hasResolvedAsset = false; + mutable bool hasIntrinsicSize = false; +}; +} // namespace + +QIcon loadThemedIcon(QStringView prefix, QPalette::ColorRole role) +{ + return QIcon(new ThemeSvgIconEngine(prefix.toString(), role)); +} + +QPixmap loadSentinelSvgPixmap(const QString &resolvedPath, QPalette::ColorRole role, const QSize &size) +{ + if (!resolvedPath.endsWith(QStringLiteral(".svg"), Qt::CaseInsensitive)) { + return {}; + } + + const QByteArray svg = readSvg(resolvedPath); + if (!hasSentinel(svg)) { + return {}; + } + + const QByteArray color = qApp->palette().color(role).name(QColor::HexRgb).toLatin1(); + return renderSvg(resolveSvgColorSentinel(svg, color), size, 1.0); +} diff --git a/cockatrice/src/interface/theme_icon.h b/cockatrice/src/interface/theme_icon.h new file mode 100644 index 000000000..e26015ea1 --- /dev/null +++ b/cockatrice/src/interface/theme_icon.h @@ -0,0 +1,50 @@ +/** + * @file theme_icon.h + * @ingroup Interface + */ + +#ifndef THEME_ICON_H +#define THEME_ICON_H + +#include +#include +#include +#include +#include +#include + +/** + * @brief Builds a palette-aware icon from a `theme:` asset. + * + * SVG glyphs may use the `currentColor` sentinel. A plain QPixmap snapshot + * cannot follow palette changes, so this returns a QIcon whose engine resolves + * the sentinel from the current palette every time it is asked for a pixmap. + * Icons therefore stay legible when the user switches between light and dark + * themes without recreating any actions or buttons. + * + * @param prefix Asset prefix relative to the `theme:` search path, with no file + * extension (for example `icons/search`), matching themePixmap(). + * @param role Palette role whose color replaces the sentinel. Selected glyphs + * use HighlightedText unless a role other than WindowText is given. + * @return An icon that follows the application palette. + */ +QIcon loadThemedIcon(QStringView prefix, QPalette::ColorRole role = QPalette::WindowText); + +/** + * @brief Renders a resolved theme SVG when it carries the `currentColor` sentinel. + * + * Non-SVG assets and SVGs without the sentinel return a null pixmap so callers + * can fall back to their usual loading path (including legacy inversion). + * + * @param resolvedPath Asset path relative to the `theme:` search path, as + * returned by ThemeManager::assetPath (includes extension). + * @param role Palette role whose color replaces the sentinel. + * @param size Pixel size to render at; an empty size uses the SVG's intrinsic size. + * @return The recolored pixmap, or a null pixmap when the asset is not a + * sentinel-carrying SVG. + */ +QPixmap loadSentinelSvgPixmap(const QString &resolvedPath, + QPalette::ColorRole role = QPalette::WindowText, + const QSize &size = {}); + +#endif // THEME_ICON_H diff --git a/oracle/CMakeLists.txt b/oracle/CMakeLists.txt index 587463572..202b7c75f 100644 --- a/oracle/CMakeLists.txt +++ b/oracle/CMakeLists.txt @@ -30,6 +30,7 @@ set(oracle_SOURCES ../cockatrice/src/client/network/update/client/release_channel.cpp ../cockatrice/src/interface/pixel_map_generator.cpp ../cockatrice/src/interface/theme_config.cpp + ../cockatrice/src/interface/theme_icon.cpp ../cockatrice/src/interface/theme_manager.cpp ${VERSION_STRING_CPP} ) From cec1579ebf117f9071f15552035e2d5bf90d85d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Tue, 20 Oct 2026 11:41:26 +0200 Subject: [PATCH 101/108] [Icons] Migrate UI glyphs to palette-token SVGs Runtime inversion is not a theming system: it rotates hue, breaks duotone art and mishandles alpha, and a QPixmap snapshot cannot follow a palette change. Redraw the top-level UI glyph set on a 24x24 grid with `currentColor` as the paint token, drop Inkscape/Sodipodi cruft, and delete the icons whose metaphor was duplicated (cogwheel/gear -> settings, scale_balanced -> scales, update.png -> reload). Load the glyphs through `loadThemedIcon` so the engine resolves the token from the palette on every request, and let `themePixmap` use the same resolution for one-shot pixmaps. `loadColorAdjustedPixmap` is gone; every caller now keeps a QIcon, which `SettingsButtonWidget::setButtonIcon` accepts instead of a QPixmap. Content art (mana, counters, countries) keeps its authored colors. Add a render test covering aspect ratio, palette flips, icon modes and high-DPI pixmaps. --- cockatrice/cockatrice.qrc | 18 +- .../resources/icons/arrow_bottom_green.svg | 793 ------------------ cockatrice/resources/icons/arrow_down.svg | 4 + .../resources/icons/arrow_down_green.svg | 793 ------------------ cockatrice/resources/icons/arrow_history.svg | 13 +- cockatrice/resources/icons/arrow_left.svg | 4 + .../resources/icons/arrow_left_green.svg | 791 ----------------- cockatrice/resources/icons/arrow_redo.svg | 44 +- cockatrice/resources/icons/arrow_right.svg | 4 + .../resources/icons/arrow_right_blue.svg | 789 ----------------- .../resources/icons/arrow_right_green.svg | 789 ----------------- .../resources/icons/arrow_right_sideboard.svg | 5 + .../resources/icons/arrow_to_bottom.svg | 5 + cockatrice/resources/icons/arrow_to_top.svg | 5 + .../resources/icons/arrow_top_green.svg | 793 ------------------ cockatrice/resources/icons/arrow_undo.svg | 44 +- cockatrice/resources/icons/arrow_up.svg | 4 + cockatrice/resources/icons/arrow_up_green.svg | 792 ----------------- .../resources/icons/circle_half_stroke.svg | 5 +- cockatrice/resources/icons/clearsearch.svg | 278 +----- cockatrice/resources/icons/cogwheel.svg | 29 - cockatrice/resources/icons/conceded.svg | 105 +-- cockatrice/resources/icons/decrement.svg | 206 +---- cockatrice/resources/icons/delete.svg | 360 +------- cockatrice/resources/icons/dragon.svg | 7 +- .../resources/icons/dropdown_collapsed.svg | 11 +- .../resources/icons/dropdown_expanded.svg | 11 +- cockatrice/resources/icons/filter.svg | 15 +- cockatrice/resources/icons/floppy_disk.svg | 6 +- .../resources/icons/forgot_password.svg | 10 +- cockatrice/resources/icons/gear.svg | 1 - cockatrice/resources/icons/increment.svg | 201 +---- cockatrice/resources/icons/info.svg | 46 +- cockatrice/resources/icons/lock.svg | 752 +---------------- .../resources/icons/not_ready_start.svg | 370 +------- cockatrice/resources/icons/pen_to_square.svg | 5 +- cockatrice/resources/icons/pencil.svg | 125 +-- cockatrice/resources/icons/pin.svg | 26 +- cockatrice/resources/icons/player.svg | 148 +--- cockatrice/resources/icons/ready_start.svg | 82 +- cockatrice/resources/icons/reload.svg | 11 +- cockatrice/resources/icons/remove_row.svg | 358 +------- cockatrice/resources/icons/rename.svg | 15 +- cockatrice/resources/icons/scale_balanced.svg | 1 - cockatrice/resources/icons/scales.svg | 11 +- cockatrice/resources/icons/scroll.svg | 7 +- cockatrice/resources/icons/search.svg | 202 +---- cockatrice/resources/icons/settings.svg | 282 +------ cockatrice/resources/icons/share.svg | 30 +- .../resources/icons/sort_arrow_down.svg | 8 +- cockatrice/resources/icons/spectator.svg | 108 +-- cockatrice/resources/icons/swap.svg | 18 +- cockatrice/resources/icons/sync.svg | 10 +- cockatrice/resources/icons/tab_changed.svg | 118 +-- cockatrice/resources/icons/update.png | Bin 7262 -> 0 bytes cockatrice/resources/icons/view.svg | 33 +- cockatrice/src/filters/filter_builder.cpp | 4 +- .../player/player_list_widget.cpp | 15 +- .../game_graphics/zones/view_zone_widget.cpp | 5 +- .../src/interface/pixel_map_generator.cpp | 42 +- .../src/interface/pixel_map_generator.h | 10 +- .../abstract_analytics_panel_widget.cpp | 4 +- .../deck_editor_database_display_widget.cpp | 10 +- .../deck_editor_deck_dock_widget.cpp | 10 +- .../deck_editor_filter_dock_widget.cpp | 6 +- .../deck_list_history_manager_widget.cpp | 8 +- .../interface/widgets/dialogs/dlg_connect.cpp | 8 +- .../widgets/dialogs/dlg_edit_tokens.cpp | 6 +- .../widgets/dialogs/dlg_manage_sets.cpp | 12 +- .../widgets/dialogs/dlg_register.cpp | 6 +- .../widgets/dialogs/settings_view.cpp | 4 +- .../quick_settings/settings_button_widget.cpp | 10 +- .../quick_settings/settings_button_widget.h | 4 +- .../widgets/server/game_selector.cpp | 6 +- .../remote/remote_replay_list_tree_widget.cpp | 4 +- .../deck_editor_settings_page.cpp | 10 +- .../settings_page/messages_settings_page.cpp | 8 +- .../settings_page/shortcut_settings_page.cpp | 6 +- .../tabs/api/archidekt/tab_archidekt.cpp | 4 +- .../commander_bracket_widget.cpp | 4 +- .../widgets/tabs/tab_deck_storage.cpp | 20 +- .../widgets/tabs/tab_public_decks.cpp | 7 +- .../interface/widgets/tabs/tab_replays.cpp | 20 +- .../src/interface/widgets/tabs/tab_room.cpp | 4 +- .../interface/widgets/tabs/tab_supervisor.cpp | 3 +- .../widgets/utility/sequence_edit.cpp | 6 +- ...database_display_filter_toolbar_widget.cpp | 20 +- .../visual_database_display_widget.cpp | 10 +- .../visual_deck_display_options_widget.cpp | 8 +- .../visual_deck_editor_widget.cpp | 4 +- .../visual_deck_storage_search_widget.cpp | 6 +- .../visual_deck_storage_widget.cpp | 6 +- cockatrice/src/interface/window_main.cpp | 5 +- tests/theme_migration/CMakeLists.txt | 4 + .../theme_icon_render_test.cpp | 186 ++++ 95 files changed, 536 insertions(+), 9680 deletions(-) delete mode 100644 cockatrice/resources/icons/arrow_bottom_green.svg create mode 100644 cockatrice/resources/icons/arrow_down.svg delete mode 100644 cockatrice/resources/icons/arrow_down_green.svg create mode 100644 cockatrice/resources/icons/arrow_left.svg delete mode 100644 cockatrice/resources/icons/arrow_left_green.svg create mode 100644 cockatrice/resources/icons/arrow_right.svg delete mode 100644 cockatrice/resources/icons/arrow_right_blue.svg delete mode 100644 cockatrice/resources/icons/arrow_right_green.svg create mode 100644 cockatrice/resources/icons/arrow_right_sideboard.svg create mode 100644 cockatrice/resources/icons/arrow_to_bottom.svg create mode 100644 cockatrice/resources/icons/arrow_to_top.svg delete mode 100644 cockatrice/resources/icons/arrow_top_green.svg create mode 100644 cockatrice/resources/icons/arrow_up.svg delete mode 100644 cockatrice/resources/icons/arrow_up_green.svg delete mode 100644 cockatrice/resources/icons/cogwheel.svg delete mode 100644 cockatrice/resources/icons/gear.svg delete mode 100644 cockatrice/resources/icons/scale_balanced.svg delete mode 100644 cockatrice/resources/icons/update.png create mode 100644 tests/theme_migration/theme_icon_render_test.cpp diff --git a/cockatrice/cockatrice.qrc b/cockatrice/cockatrice.qrc index 4ec3d2385..229cdd125 100644 --- a/cockatrice/cockatrice.qrc +++ b/cockatrice/cockatrice.qrc @@ -6,19 +6,18 @@ resources/hand.svg resources/hr.jpg - resources/icons/arrow_bottom_green.svg - resources/icons/arrow_down_green.svg + resources/icons/arrow_down.svg resources/icons/arrow_history.svg - resources/icons/arrow_left_green.svg + resources/icons/arrow_left.svg resources/icons/arrow_redo.svg - resources/icons/arrow_right_blue.svg - resources/icons/arrow_right_green.svg - resources/icons/arrow_top_green.svg - resources/icons/arrow_up_green.svg + resources/icons/arrow_right.svg + resources/icons/arrow_right_sideboard.svg + resources/icons/arrow_to_bottom.svg + resources/icons/arrow_to_top.svg + resources/icons/arrow_up.svg resources/icons/arrow_undo.svg resources/icons/circle_half_stroke.svg resources/icons/clearsearch.svg - resources/icons/cogwheel.svg resources/icons/conceded.svg resources/icons/decrement.svg resources/icons/delete.svg @@ -28,7 +27,6 @@ resources/icons/filter.svg resources/icons/floppy_disk.svg resources/icons/forgot_password.svg - resources/icons/gear.svg resources/icons/increment.svg resources/icons/info.svg resources/icons/lock.svg @@ -41,7 +39,6 @@ resources/icons/reload.svg resources/icons/remove_row.svg resources/icons/rename.svg - resources/icons/scale_balanced.svg resources/icons/scales.svg resources/icons/scroll.svg resources/icons/search.svg @@ -52,7 +49,6 @@ resources/icons/swap.svg resources/icons/sync.svg resources/icons/tab_changed.svg - resources/icons/update.png resources/icons/view.svg resources/icons/mana/B.svg diff --git a/cockatrice/resources/icons/arrow_bottom_green.svg b/cockatrice/resources/icons/arrow_bottom_green.svg deleted file mode 100644 index 3a45053ad..000000000 --- a/cockatrice/resources/icons/arrow_bottom_green.svg +++ /dev/null @@ -1,793 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - diff --git a/cockatrice/resources/icons/arrow_down.svg b/cockatrice/resources/icons/arrow_down.svg new file mode 100644 index 000000000..0ff237f52 --- /dev/null +++ b/cockatrice/resources/icons/arrow_down.svg @@ -0,0 +1,4 @@ + + + + diff --git a/cockatrice/resources/icons/arrow_down_green.svg b/cockatrice/resources/icons/arrow_down_green.svg deleted file mode 100644 index 574c12060..000000000 --- a/cockatrice/resources/icons/arrow_down_green.svg +++ /dev/null @@ -1,793 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - diff --git a/cockatrice/resources/icons/arrow_history.svg b/cockatrice/resources/icons/arrow_history.svg index d130758e6..29374d23a 100644 --- a/cockatrice/resources/icons/arrow_history.svg +++ b/cockatrice/resources/icons/arrow_history.svg @@ -1,8 +1,5 @@ - - - - history - - - - \ No newline at end of file + + + + + diff --git a/cockatrice/resources/icons/arrow_left.svg b/cockatrice/resources/icons/arrow_left.svg new file mode 100644 index 000000000..7a88deddb --- /dev/null +++ b/cockatrice/resources/icons/arrow_left.svg @@ -0,0 +1,4 @@ + + + + diff --git a/cockatrice/resources/icons/arrow_left_green.svg b/cockatrice/resources/icons/arrow_left_green.svg deleted file mode 100644 index 1b9eb0684..000000000 --- a/cockatrice/resources/icons/arrow_left_green.svg +++ /dev/null @@ -1,791 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - diff --git a/cockatrice/resources/icons/arrow_redo.svg b/cockatrice/resources/icons/arrow_redo.svg index dd89e3314..141260269 100644 --- a/cockatrice/resources/icons/arrow_redo.svg +++ b/cockatrice/resources/icons/arrow_redo.svg @@ -1,40 +1,4 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + diff --git a/cockatrice/resources/icons/arrow_right.svg b/cockatrice/resources/icons/arrow_right.svg new file mode 100644 index 000000000..f0ac5d5bf --- /dev/null +++ b/cockatrice/resources/icons/arrow_right.svg @@ -0,0 +1,4 @@ + + + + diff --git a/cockatrice/resources/icons/arrow_right_blue.svg b/cockatrice/resources/icons/arrow_right_blue.svg deleted file mode 100644 index 334cd68fd..000000000 --- a/cockatrice/resources/icons/arrow_right_blue.svg +++ /dev/null @@ -1,789 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - diff --git a/cockatrice/resources/icons/arrow_right_green.svg b/cockatrice/resources/icons/arrow_right_green.svg deleted file mode 100644 index db491985d..000000000 --- a/cockatrice/resources/icons/arrow_right_green.svg +++ /dev/null @@ -1,789 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - diff --git a/cockatrice/resources/icons/arrow_right_sideboard.svg b/cockatrice/resources/icons/arrow_right_sideboard.svg new file mode 100644 index 000000000..79a8cd5d3 --- /dev/null +++ b/cockatrice/resources/icons/arrow_right_sideboard.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/cockatrice/resources/icons/arrow_to_bottom.svg b/cockatrice/resources/icons/arrow_to_bottom.svg new file mode 100644 index 000000000..291a527d2 --- /dev/null +++ b/cockatrice/resources/icons/arrow_to_bottom.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/cockatrice/resources/icons/arrow_to_top.svg b/cockatrice/resources/icons/arrow_to_top.svg new file mode 100644 index 000000000..aac38cc33 --- /dev/null +++ b/cockatrice/resources/icons/arrow_to_top.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/cockatrice/resources/icons/arrow_top_green.svg b/cockatrice/resources/icons/arrow_top_green.svg deleted file mode 100644 index c515c6a1d..000000000 --- a/cockatrice/resources/icons/arrow_top_green.svg +++ /dev/null @@ -1,793 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - diff --git a/cockatrice/resources/icons/arrow_undo.svg b/cockatrice/resources/icons/arrow_undo.svg index 2e182ffb2..c748b37b8 100644 --- a/cockatrice/resources/icons/arrow_undo.svg +++ b/cockatrice/resources/icons/arrow_undo.svg @@ -1,40 +1,4 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + diff --git a/cockatrice/resources/icons/arrow_up.svg b/cockatrice/resources/icons/arrow_up.svg new file mode 100644 index 000000000..ac32b5c92 --- /dev/null +++ b/cockatrice/resources/icons/arrow_up.svg @@ -0,0 +1,4 @@ + + + + diff --git a/cockatrice/resources/icons/arrow_up_green.svg b/cockatrice/resources/icons/arrow_up_green.svg deleted file mode 100644 index 1d0f61112..000000000 --- a/cockatrice/resources/icons/arrow_up_green.svg +++ /dev/null @@ -1,792 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - diff --git a/cockatrice/resources/icons/circle_half_stroke.svg b/cockatrice/resources/icons/circle_half_stroke.svg index 42e4dbca2..c16f84858 100644 --- a/cockatrice/resources/icons/circle_half_stroke.svg +++ b/cockatrice/resources/icons/circle_half_stroke.svg @@ -1 +1,4 @@ - \ No newline at end of file + + + + diff --git a/cockatrice/resources/icons/clearsearch.svg b/cockatrice/resources/icons/clearsearch.svg index be50d68d6..49d883c68 100644 --- a/cockatrice/resources/icons/clearsearch.svg +++ b/cockatrice/resources/icons/clearsearch.svg @@ -1,276 +1,4 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - + + + diff --git a/cockatrice/resources/icons/cogwheel.svg b/cockatrice/resources/icons/cogwheel.svg deleted file mode 100644 index 8c7aad617..000000000 --- a/cockatrice/resources/icons/cogwheel.svg +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/cockatrice/resources/icons/conceded.svg b/cockatrice/resources/icons/conceded.svg index a487f18bb..b9faf047c 100644 --- a/cockatrice/resources/icons/conceded.svg +++ b/cockatrice/resources/icons/conceded.svg @@ -1,103 +1,4 @@ - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - - + + + diff --git a/cockatrice/resources/icons/decrement.svg b/cockatrice/resources/icons/decrement.svg index 0ceddf69f..033245f85 100644 --- a/cockatrice/resources/icons/decrement.svg +++ b/cockatrice/resources/icons/decrement.svg @@ -1,205 +1,3 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - + + diff --git a/cockatrice/resources/icons/delete.svg b/cockatrice/resources/icons/delete.svg index 008d8d429..d79312f1e 100644 --- a/cockatrice/resources/icons/delete.svg +++ b/cockatrice/resources/icons/delete.svg @@ -1,357 +1,5 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - + + + + diff --git a/cockatrice/resources/icons/dragon.svg b/cockatrice/resources/icons/dragon.svg index f45af39ef..cc48d1f5c 100644 --- a/cockatrice/resources/icons/dragon.svg +++ b/cockatrice/resources/icons/dragon.svg @@ -1 +1,6 @@ - \ No newline at end of file + + + + + + diff --git a/cockatrice/resources/icons/dropdown_collapsed.svg b/cockatrice/resources/icons/dropdown_collapsed.svg index 52e833e65..429c24974 100644 --- a/cockatrice/resources/icons/dropdown_collapsed.svg +++ b/cockatrice/resources/icons/dropdown_collapsed.svg @@ -1,10 +1,3 @@ - - - - - ]> - - + + diff --git a/cockatrice/resources/icons/dropdown_expanded.svg b/cockatrice/resources/icons/dropdown_expanded.svg index fa3ee205c..0b24aeb48 100644 --- a/cockatrice/resources/icons/dropdown_expanded.svg +++ b/cockatrice/resources/icons/dropdown_expanded.svg @@ -1,10 +1,3 @@ - - - - - ]> - - + + diff --git a/cockatrice/resources/icons/filter.svg b/cockatrice/resources/icons/filter.svg index d53d9ba29..d9cdf879c 100644 --- a/cockatrice/resources/icons/filter.svg +++ b/cockatrice/resources/icons/filter.svg @@ -1,12 +1,3 @@ - - - - - - - - \ No newline at end of file + + + diff --git a/cockatrice/resources/icons/floppy_disk.svg b/cockatrice/resources/icons/floppy_disk.svg index 3c5b5054d..10b9e8172 100644 --- a/cockatrice/resources/icons/floppy_disk.svg +++ b/cockatrice/resources/icons/floppy_disk.svg @@ -1 +1,5 @@ - \ No newline at end of file + + + + + diff --git a/cockatrice/resources/icons/forgot_password.svg b/cockatrice/resources/icons/forgot_password.svg index f938ca3f4..3bfb3bdb1 100644 --- a/cockatrice/resources/icons/forgot_password.svg +++ b/cockatrice/resources/icons/forgot_password.svg @@ -1,6 +1,6 @@ - - - - - + + + + + diff --git a/cockatrice/resources/icons/gear.svg b/cockatrice/resources/icons/gear.svg deleted file mode 100644 index fdee3a297..000000000 --- a/cockatrice/resources/icons/gear.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/cockatrice/resources/icons/increment.svg b/cockatrice/resources/icons/increment.svg index 6d5f7a85a..52dbdb2aa 100644 --- a/cockatrice/resources/icons/increment.svg +++ b/cockatrice/resources/icons/increment.svg @@ -1,199 +1,4 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - + + + diff --git a/cockatrice/resources/icons/info.svg b/cockatrice/resources/icons/info.svg index 00b23cc2c..1305f6b3d 100644 --- a/cockatrice/resources/icons/info.svg +++ b/cockatrice/resources/icons/info.svg @@ -1,43 +1,5 @@ - - - - - - - - - - + + + + diff --git a/cockatrice/resources/icons/lock.svg b/cockatrice/resources/icons/lock.svg index b7ebf1a53..f150ece49 100644 --- a/cockatrice/resources/icons/lock.svg +++ b/cockatrice/resources/icons/lock.svg @@ -1,750 +1,4 @@ - - - - - - - - - padlock - - - - icon - - lock - security - secure - padlock - signs_and_symbols - - - - - AJ Ashton - - - - - AJ Ashton - - - - - AJ Ashton - - - - image/svg+xml - - - en - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + diff --git a/cockatrice/resources/icons/not_ready_start.svg b/cockatrice/resources/icons/not_ready_start.svg index 73afda9db..c20a5cfe6 100644 --- a/cockatrice/resources/icons/not_ready_start.svg +++ b/cockatrice/resources/icons/not_ready_start.svg @@ -1,368 +1,4 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - + + + diff --git a/cockatrice/resources/icons/pen_to_square.svg b/cockatrice/resources/icons/pen_to_square.svg index 60ff1d9c4..7d3e161ac 100644 --- a/cockatrice/resources/icons/pen_to_square.svg +++ b/cockatrice/resources/icons/pen_to_square.svg @@ -1 +1,4 @@ - \ No newline at end of file + + + + diff --git a/cockatrice/resources/icons/pencil.svg b/cockatrice/resources/icons/pencil.svg index c8b81f698..d274e98db 100644 --- a/cockatrice/resources/icons/pencil.svg +++ b/cockatrice/resources/icons/pencil.svg @@ -1,123 +1,4 @@ - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - + + + diff --git a/cockatrice/resources/icons/pin.svg b/cockatrice/resources/icons/pin.svg index 71d4c9c9c..9c173286b 100644 --- a/cockatrice/resources/icons/pin.svg +++ b/cockatrice/resources/icons/pin.svg @@ -1,24 +1,4 @@ - - - - - + + + - diff --git a/cockatrice/resources/icons/player.svg b/cockatrice/resources/icons/player.svg index e83b24654..9fbe39a11 100644 --- a/cockatrice/resources/icons/player.svg +++ b/cockatrice/resources/icons/player.svg @@ -1,142 +1,8 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - + + + + + + + diff --git a/cockatrice/resources/icons/ready_start.svg b/cockatrice/resources/icons/ready_start.svg index 238d2aa46..4c4ea5d2d 100644 --- a/cockatrice/resources/icons/ready_start.svg +++ b/cockatrice/resources/icons/ready_start.svg @@ -1,81 +1,3 @@ - - - - - - - - - - - - image/svg+xml - - - - - - - - - + + diff --git a/cockatrice/resources/icons/reload.svg b/cockatrice/resources/icons/reload.svg index 102995876..e09b1b7ef 100644 --- a/cockatrice/resources/icons/reload.svg +++ b/cockatrice/resources/icons/reload.svg @@ -1,7 +1,4 @@ - - - - Svg Vector Icons : http://www.onlinewebfonts.com/icon - - \ No newline at end of file + + + + diff --git a/cockatrice/resources/icons/remove_row.svg b/cockatrice/resources/icons/remove_row.svg index 6f133cdb6..5d00ed57c 100644 --- a/cockatrice/resources/icons/remove_row.svg +++ b/cockatrice/resources/icons/remove_row.svg @@ -1,356 +1,4 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - + + + diff --git a/cockatrice/resources/icons/rename.svg b/cockatrice/resources/icons/rename.svg index c68a00246..bab850808 100644 --- a/cockatrice/resources/icons/rename.svg +++ b/cockatrice/resources/icons/rename.svg @@ -1,10 +1,7 @@ - - - - - - - + + + + + + diff --git a/cockatrice/resources/icons/scale_balanced.svg b/cockatrice/resources/icons/scale_balanced.svg deleted file mode 100644 index fae468d38..000000000 --- a/cockatrice/resources/icons/scale_balanced.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/cockatrice/resources/icons/scales.svg b/cockatrice/resources/icons/scales.svg index a410921dd..4b2e16fcc 100644 --- a/cockatrice/resources/icons/scales.svg +++ b/cockatrice/resources/icons/scales.svg @@ -1,6 +1,7 @@ - - - - - + + + + + + diff --git a/cockatrice/resources/icons/scroll.svg b/cockatrice/resources/icons/scroll.svg index f4828e679..edcf29d11 100644 --- a/cockatrice/resources/icons/scroll.svg +++ b/cockatrice/resources/icons/scroll.svg @@ -1 +1,6 @@ - \ No newline at end of file + + + + + + diff --git a/cockatrice/resources/icons/search.svg b/cockatrice/resources/icons/search.svg index 05439a12e..be4a87f00 100644 --- a/cockatrice/resources/icons/search.svg +++ b/cockatrice/resources/icons/search.svg @@ -1,198 +1,4 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - - \ No newline at end of file + + + + diff --git a/cockatrice/resources/icons/settings.svg b/cockatrice/resources/icons/settings.svg index 77a2ccfa9..3942a0916 100644 --- a/cockatrice/resources/icons/settings.svg +++ b/cockatrice/resources/icons/settings.svg @@ -1,280 +1,4 @@ - - - - - - - - Ruota dentata grigia - - - - unsorted - - - - - Open Clip Art Library - - - - - Architetto Francesco Rollandin - - - - - Architetto Francesco Rollandin - - - - image/svg+xml - - - en - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + diff --git a/cockatrice/resources/icons/share.svg b/cockatrice/resources/icons/share.svg index a84f55847..9d6ff21a0 100644 --- a/cockatrice/resources/icons/share.svg +++ b/cockatrice/resources/icons/share.svg @@ -1,25 +1,7 @@ - - - - - - - - - + + + + + + diff --git a/cockatrice/resources/icons/sort_arrow_down.svg b/cockatrice/resources/icons/sort_arrow_down.svg index adf10f9f0..e10b401f2 100644 --- a/cockatrice/resources/icons/sort_arrow_down.svg +++ b/cockatrice/resources/icons/sort_arrow_down.svg @@ -1 +1,7 @@ - \ No newline at end of file + + + + + + + diff --git a/cockatrice/resources/icons/spectator.svg b/cockatrice/resources/icons/spectator.svg index c580c448d..19242f058 100644 --- a/cockatrice/resources/icons/spectator.svg +++ b/cockatrice/resources/icons/spectator.svg @@ -1,106 +1,4 @@ - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - + + + diff --git a/cockatrice/resources/icons/swap.svg b/cockatrice/resources/icons/swap.svg index a18ca2f06..4eddb8c08 100644 --- a/cockatrice/resources/icons/swap.svg +++ b/cockatrice/resources/icons/swap.svg @@ -1,12 +1,6 @@ - - - - - - - - \ No newline at end of file + + + + + + diff --git a/cockatrice/resources/icons/sync.svg b/cockatrice/resources/icons/sync.svg index e287b1993..fa00b3024 100644 --- a/cockatrice/resources/icons/sync.svg +++ b/cockatrice/resources/icons/sync.svg @@ -1,6 +1,6 @@ - - - - - + + + + + diff --git a/cockatrice/resources/icons/tab_changed.svg b/cockatrice/resources/icons/tab_changed.svg index ae09ef8fd..1d9b8d09c 100644 --- a/cockatrice/resources/icons/tab_changed.svg +++ b/cockatrice/resources/icons/tab_changed.svg @@ -1,116 +1,4 @@ - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - - + + + diff --git a/cockatrice/resources/icons/update.png b/cockatrice/resources/icons/update.png deleted file mode 100644 index 825c0aee757322230b41bc9ef0be3ed08260a5d9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7262 zcmV-k9HHZhP)004R> z004l5008;`004mK004C`008P>0026e000+ooVrmw00004XF*Lt006O$eEU(800001 zb5ch_0Itp)=>Px#24YJ`L;(K){{a7>y{D4^000SaNLh0L01m_e01m_fl`9S#000}c zNklkj_AbmvK$L*N79VLB$^l{SrNbeV4Yql9C0D@>IeUZ40 z6}OzmcjOHG1|yFLL^5D+BfVQBi%EbG1HORtWN{lCB5?hAEIl?zdOPVYVjnRH5O|9t zeF^DHNspgFaHm2rv<~&8H;~?A62O1ZQ$)~1F|tG98niwW$xwGSk|9_GKtZ;c^fc0w zrg>yf_h+J;s3g6Sbk}?*0Qzw!>8YYhpN%6sR0KlsYe+8>Ba3-OfJD->L_x>RB5*?i zzTWrdMb}VHdh69r01VSsh(11K;oA1?+mr6T`|jmgSy{;o7A#1Njg7V2Y&J(sOpGHe zEG!~AIvTg(%0DnPG}P%PZD3%av%kN;x390Sr@p?vskODW=lJpC4F?V!Xd4_HoCqOd z&@Egbz0>FkeRSV9a)0ySA9H6r0npT6D*|r`=E!~U!3USzb=O_XSFc{3m6n#aoB*B{ z5fNbz#g82yA0KINZ?7YysIIB0X?XL^H!mDHa-=H+772;)nGx6U=)MPeaC3RT*)cOD z&?5lG?%ATao#uOxZde>UKbx9zjP)|Ah8myRaI3N3kwS`KL7ml z=Ltc^g5++{HB^)SIe4;>)bAX7B=k--@0Kl#Z|R_@rb{``sEyLTTWuA(jot^vcx zGo*LO2=Ip-0e-;>2wN`i_tQuMC5v&?Un7QeQ&z^gxw$JJd+f1qq@<)=cQqtSh-N4z zL^wcPL}`Go0WH8e(u=*;00|&60E8XU9sK^G_j9L{0GPe|66jCHX#PktQf_(Rfd{_f za5&Q8Ds0`|-Oc; zN#woy>Z{j3{P4ryBno~x%!Ag|)z!FX&z|>w_`@H55r8#7vH9skd;hN73%cijaVuJa zy^s7kq$I$(^^Ia^=QsFIKKW$Ud+)vX1PSAEh>u?g^Q1*bN82}S*zlD{9(e=_-+JcE zneHjL1{`qd*FCWM9u@fBFoJ_kYwlZVz4Srp(NGfL8iU~bj$tVG(MKQU5I_GNV(!y8 zjkk z+qT4G$Btd@?Cdm@P+Cg};1hr1=lMt=6NRv8^*z^Giw~3^4TS~JWb31kKAQUOyYK$k z>2%&aU(G!WeC4qvOO|91L3|5|7|M%^ihA|V?YjF{ZU+hY>5I~#XRuckf<8WYx5OO; zmjp@zsJ14}S1;TwGj|3A-A6sTJ_Hh=_>jEnBwSx_G$!!jKvv{xi}Mi@c^&X2kxDJyL;QS!6bmuD8K}rGU92+3?|^@;7Z<}u{d2If*O3@ zs}FbH|Hig}a|Bl1FF4Oz%Rn<+>1%|>-a2RyXg4j%eL z&aY{iK3}p&A^N05LMwo~wJo)iVInUh-LCsz-x~JkfBx{9KnTE@;7>L;KLMrfC2+Cz z3n=d>6~I@IQpJ`5zRY(kf_!c8nVcc))il*k5=2TA9(LaQ`c~LKMG{#3 z%wAo!ZFp=5j@2E7(+$U=xxaZ5Fr^7rf-k|0mmw;34J!O6!RM8Pkh&3xC2#}OG}p*e z#o)^x?!5O`Ux)qwa`2fz2{7-#9~~csk1GEHg#`Q|*Pv>YD#2BYa>)T+GCHLOfab>) z$sReCBt#HjlDQ;@Gr$_?pd^P=UGNew_AA^r7{tDeAUDQ#^OGO?HD5(!u z=JKWPf|2RdvnZ3+LbL=+oWgE(Q;mA!Ncc_s&h5Ya`ov$JJZM-PTIQqa?;P%ce?R>* z*n8=>67bbPfntlWLry{h>^yA> z87+aE-?{BS?s(Q83ozx8-_z3rm6ervvBa!5`F}2Z7xonW7Yq@>r!`7>L!(p*B+c(@ z&hW9_iP8Wttb#MeDRf@(;>qvI?cDYYeG*s=CoD7?T7dfcdVvJV$;l8C6Eo|;|J{YR z;7sF5-BGFpH#$5995L}=kB*1vh)9UEMuWvS9U>KplRzj(t}JOJ6L)OUr}~$2+_^MU z_$}2}YCM8f;i^dBuygCHx50=1mjBFDM4;c+ps%k_=m`l4uz2xeh>D7u72v;7^h+VG z*Bhl?pyH$KkYrm7@dWe;YdEL^G+qg_pWs8_;Cl~dE_IUMNQ7ZywJOmhJm9#UTVJ_t z;=}Ld=T4gl?CR=*l9Cdrt*ynYYiAVvcP{=9Tx>fp!HuoX7sMvQ+NEn@_0l!4$hJrV zzS15m;N!K^#;SM7j)OM%DjBGD6IrR5LYG-JiLT%|*!A_7Z+q6L2n?Ru?(Bq1moCBO z%a@_Ix7VLsc~IJc^7r9X;|bkNUSwDVtX#4RvXisH7G& z`aRCnEaDQJD#7V{Imhq3^W{y?8qokjC4}4U7Qjcb7#SG}iHV7jl$0dwhg$6)sW~K1 zx=>QGsg9+jr-H>|(FU1k7#9Z1l9m&*zd(P4^J){UCHh$4)6%?J zieFm~$VrGApsJyoPI$op@OcNHD##7G2y{3cg76z3ANLESp`jrd92^v)hv?{Nu-olI z$IlDII-uWw`-?rEP>&({tVG3UC$A*t-mU>Mbxist`dHuxBKl~y1;MWt!B-L9b5f|_ zYlVS^M?Q{TtJMnq{rv){Q?(4(+uPgmLJFbB#l;Dqjby+c5fLFg#^=Jr!v&MAJn<+v zk2f5J%I*rf8N?q<-nS}wHN-~7DZpoV;b4e9@I&`^p(UY6emAy`-7E&0pS3QT%EO@lx;=0?{WN z1@QB8^%6lr^ZVYBik4ttV8Cy;5U}tYE`p(n0cx@b|4BV=xURI`Y)e$E>N)W(rtY(TnBj6hmfm3Se)6rZ{ClOrkQK%RxRpizu z@wZ3XVX0%Ov9M13XJ~8~itCGE*fkv33?F6gfLG4|%ZS5k1d2ZJ6%$O8uP_F_o(K$> z5J&{H3ZtW=g5bk#s9O1GDU9(h>O4ID2;;E?^DPfM%qAeJV0A6u&3V zuTlMNiN*07{Gf|Kzy2@@9gz&^CIkgfLO8S#NDO@JPpccPVUE&;wnakdry15sj(7BR zLEmtnT2=FGzi;G%5heJlh|X(9D5B2?KJY;WU&{haD;dx-AQ8Ob9gYNa5l9H!M?&CZ z&y**xvKXyn&CF4{#GY(e@#ChxvtIkMF8zD>WMLai^vO&=PszR*ivoh7 z8!*{}T`v#NIobi;&7DkjezxJ9-H%{3Q5PzaJFvu~}e_UlH6j+R470TM>8D)HxWqoEt~JQSd2N zK==Gg-{S$lTK*j*;B!F)-%Efw3qH2?p;omj_ai0C1Yg`b!V(0tZ*&gBU%4X<0eRo0 zUGzi~g8yLJ0&eZu~6#U#ULc$D+w?sUBSRu ze@O4)OOJN?mz6XQKBxNI%J1L>-_3^vd?^9u?43Zulxzs^;fu4{{07}9_#iVrJk{@q z5`0!Ba8428nJ>TKyB@%sFw%OgJQnDjgqGhIpYsoViQf-3_$&=z7KaFc2s+h5DCd@^ zi@s?9-$)c-k|8j^uNws@6HemcX#xML5P`E1dxn>< z5IXHl=ShF80M*jYB=}k)&;;KA_@9zK#fqV`=G@N9B2l3PpJ4@N?fJBrUE6Sqx}(>B zA*en2b2nLeH#{!?4hk+Zt|jz6sefg3kGW`+(&u-cpqqN3ENdpUI=2BK=(lCHSjYa6 zY}PC9SM=wycik%JJXdKk!S}S!rQlQiK7#LDlBo&4K`o$rZtr+@!yP;aFUcx@(dqz6 z{64#85xY`}n};$p3J}3RO#|P%dRK}Ud{C8U*RlePC_vNfGy$k0|^CO z)opQ<%i=A-6dl0PcNh z9$;RX{xj_-;ADFtvtSb)d|vI2DWg=Cl~XW6YmFjSrVm@Z-408VljR_5l$*s>O&c1o zFteB(5Br2@W4=WJr`nFgiMC_(DxKnjOqnT8W^G>9J$!zLX1uym2}|^ag<0WC>(+bf zCm8@ATUA9BjE#>`Y9I*zlzsm4g-f(d;8hL&CoRX|M9VQ|mCnqR3=w=r$tE7C_wdnD zvdjvhtk8^+>_PCaziz!yc~!l56i~q*7#xJ^+8UbQ7sEgP9q)VRO)B_=4}5x+PAT}n z0N>|CnN>O!eqR-u(NnUlWKVQ-3|znNI*5z2sfxzZtD1W@mx_ylce^Py5QKk6ED-TJ zjQ~>u-)6H3^?-v}`7`JvzqtSkn~nmjN~aHe)*>!4lg~=YD)IsvqL1qLCE$lyERdd> z0n40Dp@beq_o)=WcfW>={Em(eb`1nK|K{0Oi#k{q!0(ZdrU}cyVrh3Q13TN=aRU6Y zEBU~x(kTO<2fcN@bp1Yn@szChJ8+x@VxprUAwB^T5*(1UAPK^(VN?^ZwQNjBM>~|2 zl~ZaU_z2&_aS?ga^AnUP02gn-^#YI#NQ9YODNNc53GfA1jsUAlCnk6Vwa=){D+AM; zrv1uoGO%gu2CCK`FtmVFV5^AWsx4>FegSTwpe9gP6(u15yy%t5(eEWdP}d8VvZ zv2a8nt)THR$J_*{|n`LLOZio3w)>3*|}!Tn$={KTaBK4tni6TzM%g?YoJy8S70V%B@m;>q$Omr zAFV$ON9qqTFXoVguO<4x*wS(Ig~8a%#xT;wJkJj6RG%X5)YR3%`3o0d0?Xu|yaKw5-YTZ( zbHv4i!85Pr>*Mr*SH{Tb5>O-H@^Dxk93P`jm{h}o6Cl%`<*B&Y zQ$7&{g~lq;*9f3w&+Sr?2>XdV8R>v<%A1%VEsrqR#Ni3D5sA-nQ_` z+JaiQ76I1zlMFg>;zZ|LZ@pD;!wok;dV2aYGNQw@z-JKx!N;fp$$)wR*AKvsmcd`E zpws|J0+5hEd1X&`e>X0#4heRLrs8M&_vsRSJh7Hz(A?4jB_*X$QBeVX{rwu%PGxL5 zJUYyO>$8_W8_^o1N)f08oT>|m%6Gs0_S>aRO-)}AftZ+(kdVv*U#ohSbmXIDK&yac zz~qv5nHh8+NDOa6M7ds|mjKe|@b%X$%od)j?7U3Ng$pFWodkP=fn{V2iatIt(dTlF z!0_-0baZt>3jw{NvI2@rN}##5l^h>3Q03hB!S9a_yLS}-s(3)pN+ZC_NdL0IkdXTL zx#yl+`}pIJ=PX>fFkKJqK%evXGY(w(6MS&-eX1qE$GG6Ku;{Wu(f9EuhuPQVQzm(| zX82U||J1nyUVot}>FDR-b(^j4y9K$mBS!QY4l5iA4WuWL9_=sq*z)u9+g^I!&$xEcN?}{xT1pF#pu3M~0*(iVJ;9LTud1sK$_yW^Jgw2W z1I7&B0PlAH!`W9a{Z=2q;nS)Mtarf(rOG+twaXUU_pc{kC~uld z##Afqn=J~&sHx}z<*}!pdMZ07C+Dj~G~P%AU3e({zSgn*L=Wu0_$T`Hcc=+cosgC0 zWQaZ<4jn(Nd+|po?%`8tg%PrSljp5@%G`dS7C>nYszd@Lh`@4FaSiy-$&)8L_Uzen z?u|FzI7DLqYNGOoV`F1uNGKRPoo?aUB#$O2Yb#Mz>BMb!uNWf6Cr}l?&jlE~tV?F| z1GtAzbPcuS-`9Azbw|-F=l;}us(A>h}Tar|#VZb48NV)l3DFHv+089@M_X+5@V*^;zr)26g#%a$!6 z8p3X~+2TmIlZL}Gj988Y67yS>CwXviuzO-+!rkBB-`z)APft%@U0q#UYin!oxpU`Q z3knL_$uZvvyz#O1cQ0JEU@MDAS!r32g(Q#&!b8VCHV~SDf7c3nc3Q}`gFLDvPgHWe zyL{Z@KK=O%o@@6))uuZ2i;==AiA4w${D24(hy}XgvGrRQtxVji9Hm}Rv(lX+3A_Y2 zBzs-{)1Q1tbd?uKZ-hD4!lwAXLDFl{C5k6CUh!F6h`|rq2oA_CYx%jWuv$zzu}u??;`1aFb7*$fbVySVPlzi0wP4c5KN##@fot`o6fdY z*wbvBj06%BEs%h06C8<9g(6V$d~JTik6;eBupoU7&Ng+4$)p-Fu@p&q%q$q`hThYB zuC*dQRU`o=B=eAf{K%eenVu&E$TJBbZwP*iXcel($5CRMIn=HppwDbR*Ctwm=oPfk ztgEL>mFzJIV4Mu4Vs2qvv<{IoA^J2PqYJBVIoGz&`L*;u&H@{a+`~8531FSZzcnJd zhZ^y5v=~;Vh^c4nb}+t(MId1JiXDe^gSZ_MT^StNdHlzB{Pfl_;P_p-$(}jhtTXtx zheZK-MPZS=WC$0xQKFUz7fFKziKRpx6XIv`BH6qhf$bOf5p;Cox|b^-{!w8rApvq? zm+lOoNr0d=1hG@>Gv1H{w>`fB>9vbB!#CHP1lOK59&?$x`>0gr2qf`07*qoM6N<$f^ByXwEzGB diff --git a/cockatrice/resources/icons/view.svg b/cockatrice/resources/icons/view.svg index e5676d825..19242f058 100644 --- a/cockatrice/resources/icons/view.svg +++ b/cockatrice/resources/icons/view.svg @@ -1,29 +1,4 @@ - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - \ No newline at end of file + + + + diff --git a/cockatrice/src/filters/filter_builder.cpp b/cockatrice/src/filters/filter_builder.cpp index 22679abb1..9c2a91a5b 100644 --- a/cockatrice/src/filters/filter_builder.cpp +++ b/cockatrice/src/filters/filter_builder.cpp @@ -1,6 +1,6 @@ #include "filter_builder.h" -#include "../interface/pixel_map_generator.h" +#include "../interface/theme_icon.h" #include "../interface/widgets/utility/custom_line_edit.h" #include @@ -30,7 +30,7 @@ FilterBuilder::FilterBuilder(QWidget *parent) : QWidget(parent) typeCombo->addItem(CardFilter::typeName(static_cast(i)), QVariant(i)); } - QPushButton *ok = new QPushButton(themePixmap(QStringLiteral("icons/increment")), QString()); + QPushButton *ok = new QPushButton(loadThemedIcon(QStringLiteral("icons/increment")), QString()); ok->setObjectName("ok"); ok->setMaximumSize(20, 20); diff --git a/cockatrice/src/game_graphics/player/player_list_widget.cpp b/cockatrice/src/game_graphics/player/player_list_widget.cpp index 7b358010f..6895e7db8 100644 --- a/cockatrice/src/game_graphics/player/player_list_widget.cpp +++ b/cockatrice/src/game_graphics/player/player_list_widget.cpp @@ -1,6 +1,7 @@ #include "player_list_widget.h" #include "../../interface/pixel_map_generator.h" +#include "../../interface/theme_icon.h" #include "../../interface/widgets/server/user/user_context_menu.h" #include "libcockatrice/protocol/pb/serverinfo_user.pb.h" #include "user_level.h" @@ -67,13 +68,13 @@ PlayerListWidget::PlayerListWidget(TabSupervisor *_tabSupervisor, QWidget *parent) : QTreeWidget(parent), tabSupervisor(_tabSupervisor), client(_client), game(_game), gameStarted(false) { - readyIcon = themePixmap(QStringLiteral("icons/ready_start")); - notReadyIcon = themePixmap(QStringLiteral("icons/not_ready_start")); - concededIcon = themePixmap(QStringLiteral("icons/conceded")); - playerIcon = loadColorAdjustedPixmap("theme:icons/player"); - judgeIcon = loadColorAdjustedPixmap("theme:icons/scales"); - spectatorIcon = loadColorAdjustedPixmap("theme:icons/spectator"); - lockIcon = themePixmap(QStringLiteral("icons/lock")); + readyIcon = loadThemedIcon(QStringLiteral("icons/ready_start")); + notReadyIcon = loadThemedIcon(QStringLiteral("icons/not_ready_start")); + concededIcon = loadThemedIcon(QStringLiteral("icons/conceded")); + playerIcon = loadThemedIcon(QStringLiteral("icons/player")); + judgeIcon = loadThemedIcon(QStringLiteral("icons/scales")); + spectatorIcon = loadThemedIcon(QStringLiteral("icons/spectator")); + lockIcon = loadThemedIcon(QStringLiteral("icons/lock")); if (tabSupervisor) { itemDelegate = new PlayerListItemDelegate(this); diff --git a/cockatrice/src/game_graphics/zones/view_zone_widget.cpp b/cockatrice/src/game_graphics/zones/view_zone_widget.cpp index b1ca42ee1..3c6cf9d02 100644 --- a/cockatrice/src/game_graphics/zones/view_zone_widget.cpp +++ b/cockatrice/src/game_graphics/zones/view_zone_widget.cpp @@ -3,6 +3,7 @@ #include "../../client/settings/cache_settings.h" #include "../../filters/syntax_help.h" #include "../../interface/pixel_map_generator.h" +#include "../../interface/theme_icon.h" #include "../card_dimensions.h" #include "../z_values.h" #include "libcockatrice/utility/macros.h" @@ -93,8 +94,8 @@ ZoneViewWidget::ZoneViewWidget(PlayerLogic *_player, searchEdit.setFocusPolicy(Qt::ClickFocus); searchEdit.setPlaceholderText(tr("Search by card name (or search expressions)")); searchEdit.setClearButtonEnabled(true); - searchEdit.addAction(loadColorAdjustedPixmap("theme:icons/search"), QLineEdit::LeadingPosition); - auto help = searchEdit.addAction(themePixmap(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); + searchEdit.addAction(loadThemedIcon(QStringLiteral("icons/search")), QLineEdit::LeadingPosition); + auto help = searchEdit.addAction(loadThemedIcon(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); connect(help, &QAction::triggered, this, [this] { createSearchSyntaxHelpWindow(&searchEdit); }); diff --git a/cockatrice/src/interface/pixel_map_generator.cpp b/cockatrice/src/interface/pixel_map_generator.cpp index 2f5c4b6dd..f024ba580 100644 --- a/cockatrice/src/interface/pixel_map_generator.cpp +++ b/cockatrice/src/interface/pixel_map_generator.cpp @@ -1,9 +1,9 @@ #include "pixel_map_generator.h" #include "libcockatrice/network/server/remote/user_level.h" +#include "theme_icon.h" #include "theme_manager.h" -#include #include #include #include @@ -15,7 +15,6 @@ #include #include #include -#include #include #include #include @@ -444,8 +443,11 @@ QPixmap LockPixmapGenerator::generatePixmap(int height) return pmCache.value(key); } - QPixmap pixmap = tryLoadImage(QStringLiteral("theme:") + themeManager->assetPath(QStringLiteral("icons/lock")), - QSize(height, height), true); + const QString resolved = themeManager->assetPath(QStringLiteral("icons/lock")); + QPixmap pixmap = loadSentinelSvgPixmap(resolved, QPalette::WindowText, QSize(height, height)); + if (pixmap.isNull()) { + pixmap = tryLoadImage(QStringLiteral("theme:") + resolved, QSize(height, height), true); + } pmCache.insert(key, pixmap); return pixmap; } @@ -459,9 +461,12 @@ QPixmap DropdownIconPixmapGenerator::generatePixmap(int height, bool expanded) return pmCache.value(key); } - QString name = expanded ? "dropdown_expanded" : "dropdown_collapsed"; - QPixmap pixmap = tryLoadImage(QStringLiteral("theme:") + themeManager->assetPath(QStringLiteral("icons/") + name), - QSize(height, height), true); + const QString prefix = QStringLiteral("icons/") + (expanded ? "dropdown_expanded" : "dropdown_collapsed"); + const QString resolved = themeManager->assetPath(prefix); + QPixmap pixmap = loadSentinelSvgPixmap(resolved, QPalette::WindowText, QSize(height, height)); + if (pixmap.isNull()) { + pixmap = tryLoadImage(QStringLiteral("theme:") + resolved, QSize(height, height), true); + } pmCache.insert(key, pixmap); return pixmap; @@ -520,30 +525,11 @@ QPixmap ManaSymbolPixmapGenerator::generatePixmap(const QString &symbol, const Q QHash ManaSymbolPixmapGenerator::masterCache; QHash ManaSymbolPixmapGenerator::scaledCache; -QPixmap loadColorAdjustedPixmap(const QString &name) -{ - // Prefer an authored scheme-qualified variant when one exists for this asset. - const QString variant = themeManager->schemeVariantPath(QStringView(name).mid(QStringLiteral("theme:").size())); - if (!variant.isEmpty()) { - return QPixmap(QStringLiteral("theme:") + variant); - } - - // Legacy fallback: runtime-invert for dark mode when no authored variant. - if (qApp->palette().windowText().color().lightness() > 200) { - QImage img(name); - img.invertPixels(); - QPixmap result; - result.convertFromImage(img); - return result; - } else { - return QPixmap(name); - } -} - QPixmap themePixmap(QStringView prefix) { const QString resolved = themeManager->assetPath(prefix); - return QPixmap(QStringLiteral("theme:") + resolved); + const QPixmap sentinel = loadSentinelSvgPixmap(resolved); + return sentinel.isNull() ? QPixmap(QStringLiteral("theme:") + resolved) : sentinel; } void clearPixmapGeneratorCaches() diff --git a/cockatrice/src/interface/pixel_map_generator.h b/cockatrice/src/interface/pixel_map_generator.h index 7c01bb332..299f72609 100644 --- a/cockatrice/src/interface/pixel_map_generator.h +++ b/cockatrice/src/interface/pixel_map_generator.h @@ -160,13 +160,11 @@ public: } }; -QPixmap loadColorAdjustedPixmap(const QString &name); - -// Loads a "theme:" asset (with no file extension in prefix), preferring the +// Returns a "theme:" asset (with no file extension in prefix), preferring the // scheme-qualified variant (prefix-dark / prefix-light, resolved via -// ThemeManager::assetPath) and falling back to the plain asset. Callers load -// the returned path directly. Use for scheme-sensitive pixmaps like -// backgrounds, the card back, and the app logo. +// ThemeManager::assetPath) and falling back to the plain asset. Token-driven +// SVGs resolve their `currentColor` sentinel from the palette. Use for +// scheme-sensitive pixmaps like backgrounds, the card back, and the app logo. QPixmap themePixmap(QStringView prefix); // Clears every PixmapGenerator's static cache so scheme variants are diff --git a/cockatrice/src/interface/widgets/deck_analytics/abstract_analytics_panel_widget.cpp b/cockatrice/src/interface/widgets/deck_analytics/abstract_analytics_panel_widget.cpp index 730043d95..c8571ab08 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/abstract_analytics_panel_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/abstract_analytics_panel_widget.cpp @@ -1,6 +1,6 @@ #include "abstract_analytics_panel_widget.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../general/display/banner_widget.h" #include "deck_list_statistics_analyzer.h" @@ -28,7 +28,7 @@ AbstractAnalyticsPanelWidget::AbstractAnalyticsPanelWidget(QWidget *parent, Deck // config button configureButton = new QPushButton(this); - configureButton->setIcon(themePixmap(QStringLiteral("icons/cogwheel"))); + configureButton->setIcon(loadThemedIcon(QStringLiteral("icons/settings"))); configureButton->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum); connect(configureButton, &QPushButton::clicked, this, &AbstractAnalyticsPanelWidget::applyConfigFromDialog); bannerAndSettingsLayout->addWidget(configureButton, 0); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp index 7ed398535..f25e6c0ff 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp @@ -3,7 +3,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../../filters/syntax_help.h" #include "../../key_signals.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../utility/custom_line_edit.h" #include "card_database_display_model.h" #include "card_database_model.h" @@ -40,8 +40,8 @@ DeckEditorDatabaseDisplayWidget::DeckEditorDatabaseDisplayWidget(QWidget *parent searchEdit->setObjectName("searchEdit"); searchEdit->setPlaceholderText(tr("Search by card name (or search expressions)")); searchEdit->setClearButtonEnabled(true); - searchEdit->addAction(loadColorAdjustedPixmap("theme:icons/search"), QLineEdit::LeadingPosition); - auto help = searchEdit->addAction(themePixmap(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); + searchEdit->addAction(loadThemedIcon(QStringLiteral("icons/search")), QLineEdit::LeadingPosition); + auto help = searchEdit->addAction(loadThemedIcon(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); setFocusProxy(searchEdit); setFocusPolicy(Qt::ClickFocus); @@ -83,13 +83,13 @@ DeckEditorDatabaseDisplayWidget::DeckEditorDatabaseDisplayWidget(QWidget *parent &DeckEditorDatabaseDisplayWidget::onRelatedCardClicked); aAddCard = new QAction(QString(), this); - aAddCard->setIcon(themePixmap(QStringLiteral("icons/arrow_right_green"))); + aAddCard->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_right"))); connect(aAddCard, &QAction::triggered, this, &DeckEditorDatabaseDisplayWidget::actAddCardToMainDeck); auto *tbAddCard = new QToolButton(this); tbAddCard->setDefaultAction(aAddCard); aAddCardToSideboard = new QAction(QString(), this); - aAddCardToSideboard->setIcon(themePixmap(QStringLiteral("icons/arrow_right_blue"))); + aAddCardToSideboard->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_right_sideboard"))); connect(aAddCardToSideboard, &QAction::triggered, this, &DeckEditorDatabaseDisplayWidget::actAddCardToSideboard); auto *tbAddCardToSideboard = new QToolButton(this); tbAddCardToSideboard->setDefaultAction(aAddCardToSideboard); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp index 37dd57459..f09d8dd1b 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp @@ -3,7 +3,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" #include "../../key_signals.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../playmat/playmat_settings_dialog.h" #include "../quick_settings/settings_button_widget.h" #include "../tabs/abstract_tab_deck_editor.h" @@ -240,25 +240,25 @@ void DeckEditorDeckDockWidget::createDeckDock() &DeckEditorDeckDockWidget::applyActiveGroupCriteria); aIncrement = new QAction(QString(), this); - aIncrement->setIcon(themePixmap(QStringLiteral("icons/increment"))); + aIncrement->setIcon(loadThemedIcon(QStringLiteral("icons/increment"))); connect(aIncrement, &QAction::triggered, this, &DeckEditorDeckDockWidget::actIncrementSelection); auto *tbIncrement = new QToolButton(this); tbIncrement->setDefaultAction(aIncrement); aDecrement = new QAction(QString(), this); - aDecrement->setIcon(themePixmap(QStringLiteral("icons/decrement"))); + aDecrement->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"))); connect(aDecrement, &QAction::triggered, this, &DeckEditorDeckDockWidget::actDecrementSelection); auto *tbDecrement = new QToolButton(this); tbDecrement->setDefaultAction(aDecrement); aRemoveCard = new QAction(QString(), this); - aRemoveCard->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); + aRemoveCard->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"))); connect(aRemoveCard, &QAction::triggered, this, &DeckEditorDeckDockWidget::actRemoveCard); auto *tbRemoveCard = new QToolButton(this); tbRemoveCard->setDefaultAction(aRemoveCard); aSwapCard = new QAction(QString(), this); - aSwapCard->setIcon(themePixmap(QStringLiteral("icons/swap"))); + aSwapCard->setIcon(loadThemedIcon(QStringLiteral("icons/swap"))); connect(aSwapCard, &QAction::triggered, this, &DeckEditorDeckDockWidget::actSwapSelection); auto *tbSwapCard = new QToolButton(this); tbSwapCard->setDefaultAction(aSwapCard); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp index adeb5c3e1..8122d1847 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp @@ -5,7 +5,7 @@ #include "../../../filters/filter_builder.h" #include "../../../filters/filter_tree_model.h" #include "../../key_signals.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../tabs/abstract_tab_deck_editor.h" #include "libcockatrice/filters/filter_card.h" @@ -64,11 +64,11 @@ void DeckEditorFilterDockWidget::createFiltersDock() connect(filterBuilder, &FilterBuilder::add, filterModel, &FilterTreeModel::addFilter); aClearFilterOne = new QAction(QString(), this); - aClearFilterOne->setIcon(themePixmap(QStringLiteral("icons/decrement"))); + aClearFilterOne->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"))); connect(aClearFilterOne, &QAction::triggered, this, &DeckEditorFilterDockWidget::actClearFilterOne); aClearFilterAll = new QAction(QString(), this); - aClearFilterAll->setIcon(themePixmap(QStringLiteral("icons/clearsearch"))); + aClearFilterAll->setIcon(loadThemedIcon(QStringLiteral("icons/clearsearch"))); connect(aClearFilterAll, &QAction::triggered, this, &DeckEditorFilterDockWidget::actClearFilterAll); auto *filterDelOne = new QToolButton(); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_list_history_manager_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_list_history_manager_widget.cpp index 209dc7273..a1914e1b5 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_list_history_manager_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_list_history_manager_widget.cpp @@ -1,6 +1,6 @@ #include "deck_list_history_manager_widget.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../quick_settings/settings_button_widget.h" #include "deck_state_manager.h" #include "libcockatrice/deck_list/deck_list_history_manager.h" @@ -32,7 +32,7 @@ DeckListHistoryManagerWidget::DeckListHistoryManagerWidget(DeckStateManager *_de layout = new QHBoxLayout(this); aUndo = new QAction(QString(), this); - aUndo->setIcon(themePixmap(QStringLiteral("icons/arrow_undo"))); + aUndo->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_undo"))); aUndo->setShortcut(QKeySequence::Undo); aUndo->setShortcutContext(Qt::ApplicationShortcut); connect(aUndo, &QAction::triggered, this, &DeckListHistoryManagerWidget::doUndo); @@ -41,7 +41,7 @@ DeckListHistoryManagerWidget::DeckListHistoryManagerWidget(DeckStateManager *_de undoButton->setDefaultAction(aUndo); aRedo = new QAction(QString(), this); - aRedo->setIcon(themePixmap(QStringLiteral("icons/arrow_redo"))); + aRedo->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_redo"))); aRedo->setShortcut(QKeySequence::Redo); aRedo->setShortcutContext(Qt::ApplicationShortcut); connect(aRedo, &QAction::triggered, this, &DeckListHistoryManagerWidget::doRedo); @@ -53,7 +53,7 @@ DeckListHistoryManagerWidget::DeckListHistoryManagerWidget(DeckStateManager *_de layout->addWidget(redoButton); historyButton = new SettingsButtonWidget(this); - historyButton->setButtonIcon(themePixmap(QStringLiteral("icons/arrow_history"))); + historyButton->setButtonIcon(loadThemedIcon(QStringLiteral("icons/arrow_history"))); historyLabel = new QLabel(this); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp index 1f567989e..1e0f37971 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp @@ -1,7 +1,7 @@ #include "dlg_connect.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../server/handle_public_servers.h" #include "../server/user/user_info_connection.h" #include "libcockatrice/utility/macros.h" @@ -37,7 +37,7 @@ DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent) previousHosts = new QComboBox(this); btnDeleteServer = new QPushButton(this); - btnDeleteServer->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); + btnDeleteServer->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"))); btnDeleteServer->setToolTip(tr("Delete the currently selected saved server")); btnDeleteServer->setFixedWidth(30); @@ -45,7 +45,7 @@ DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent) hps = new HandlePublicServers(this); btnRefreshServers = new QPushButton(this); - btnRefreshServers->setIcon(themePixmap(QStringLiteral("icons/sync"))); + btnRefreshServers->setIcon(loadThemedIcon(QStringLiteral("icons/sync"))); btnRefreshServers->setToolTip(tr("Refresh the server list with known public servers")); btnRefreshServers->setFixedWidth(30); @@ -115,7 +115,7 @@ DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent) updateDisplayInfo(previousHosts->currentText()); btnForgotPassword = new QPushButton(this); - btnForgotPassword->setIcon(themePixmap(QStringLiteral("icons/forgot_password"))); + btnForgotPassword->setIcon(loadThemedIcon(QStringLiteral("icons/forgot_password"))); btnForgotPassword->setToolTip(tr("Reset Password")); btnForgotPassword->setFixedWidth(30); connect(btnForgotPassword, &QPushButton::released, this, &DlgConnect::actForgotPassword); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp index cbf61a394..42a7595e5 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp @@ -1,6 +1,6 @@ #include "dlg_edit_tokens.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../interface/widgets/utility/get_text_with_max.h" #include "card_database_display_model.h" #include "libcockatrice/card/card_info.h" @@ -107,10 +107,10 @@ DlgEditTokens::DlgEditTokens(QWidget *parent) : QDialog(parent), currentCard(nul &DlgEditTokens::tokenSelectionChanged); QAction *aAddToken = new QAction(tr("Add token"), this); - aAddToken->setIcon(themePixmap(QStringLiteral("icons/increment"))); + aAddToken->setIcon(loadThemedIcon(QStringLiteral("icons/increment"))); connect(aAddToken, &QAction::triggered, this, &DlgEditTokens::actAddToken); QAction *aRemoveToken = new QAction(tr("Remove token"), this); - aRemoveToken->setIcon(themePixmap(QStringLiteral("icons/decrement"))); + aRemoveToken->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"))); connect(aRemoveToken, &QAction::triggered, this, &DlgEditTokens::actRemoveToken); auto *databaseToolBar = new QToolBar; diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp index dbfe38e26..937c7b5be 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp @@ -1,7 +1,7 @@ #include "dlg_manage_sets.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../interface/card_picture_loader/card_picture_loader.h" #include "../interface/widgets/utility/custom_line_edit.h" #include "libcockatrice/card/database/card_database.h" @@ -57,28 +57,28 @@ WndSets::WndSets(QWidget *parent) : QMainWindow(parent) setsEditToolBar->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum); aTop = new QAction(QString(), this); - aTop->setIcon(themePixmap(QStringLiteral("icons/arrow_top_green"))); + aTop->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_to_top"))); aTop->setToolTip(tr("Move selected set to the top")); aTop->setEnabled(false); connect(aTop, &QAction::triggered, this, &WndSets::actTop); setsEditToolBar->addAction(aTop); aUp = new QAction(QString(), this); - aUp->setIcon(themePixmap(QStringLiteral("icons/arrow_up_green"))); + aUp->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_up"))); aUp->setToolTip(tr("Move selected set up")); aUp->setEnabled(false); connect(aUp, &QAction::triggered, this, &WndSets::actUp); setsEditToolBar->addAction(aUp); aDown = new QAction(QString(), this); - aDown->setIcon(themePixmap(QStringLiteral("icons/arrow_down_green"))); + aDown->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_down"))); aDown->setToolTip(tr("Move selected set down")); aDown->setEnabled(false); connect(aDown, &QAction::triggered, this, &WndSets::actDown); setsEditToolBar->addAction(aDown); aBottom = new QAction(QString(), this); - aBottom->setIcon(themePixmap(QStringLiteral("icons/arrow_bottom_green"))); + aBottom->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_to_bottom"))); aBottom->setToolTip(tr("Move selected set to the bottom")); aBottom->setEnabled(false); connect(aBottom, &QAction::triggered, this, &WndSets::actBottom); @@ -88,7 +88,7 @@ WndSets::WndSets(QWidget *parent) : QMainWindow(parent) searchField = new LineEditUnfocusable; searchField->setObjectName("searchEdit"); searchField->setPlaceholderText(tr("Search by set name, code, type, or release date")); - searchField->addAction(themePixmap(QStringLiteral("icons/search")), LineEditUnfocusable::LeadingPosition); + searchField->addAction(loadThemedIcon(QStringLiteral("icons/search")), LineEditUnfocusable::LeadingPosition); searchField->setClearButtonEnabled(true); setFocusProxy(searchField); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp index 9e32e87d1..09afee32d 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp @@ -1,7 +1,7 @@ #include "dlg_register.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../server/handle_public_servers.h" #include "../server/user/user_info_connection.h" @@ -34,7 +34,7 @@ DlgRegister::DlgRegister(QWidget *parent) : QDialog(parent) previousHosts = new QComboBox(this); btnDeleteServer = new QPushButton(this); - btnDeleteServer->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); + btnDeleteServer->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"))); btnDeleteServer->setToolTip(tr("Delete the currently selected saved server")); btnDeleteServer->setFixedWidth(30); @@ -42,7 +42,7 @@ DlgRegister::DlgRegister(QWidget *parent) : QDialog(parent) hps = new HandlePublicServers(this); btnRefreshServers = new QPushButton(this); - btnRefreshServers->setIcon(themePixmap(QStringLiteral("icons/sync"))); + btnRefreshServers->setIcon(loadThemedIcon(QStringLiteral("icons/sync"))); btnRefreshServers->setToolTip(tr("Refresh the server list with known public servers")); btnRefreshServers->setFixedWidth(30); diff --git a/cockatrice/src/interface/widgets/dialogs/settings_view.cpp b/cockatrice/src/interface/widgets/dialogs/settings_view.cpp index d22a8d0b3..6ab754f4e 100644 --- a/cockatrice/src/interface/widgets/dialogs/settings_view.cpp +++ b/cockatrice/src/interface/widgets/dialogs/settings_view.cpp @@ -6,7 +6,7 @@ #include "settings_view.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../settings_page/abstract_settings_page.h" #include "../settings_page/appearance_settings_page.h" #include "../settings_page/deck_editor_settings_page.h" @@ -132,7 +132,7 @@ void SettingsView::setupUi() // Search bar searchEdit = new QLineEdit; searchEdit->setClearButtonEnabled(true); - searchEdit->addAction(themePixmap(QStringLiteral("icons/search")), QLineEdit::LeadingPosition); + searchEdit->addAction(loadThemedIcon(QStringLiteral("icons/search")), QLineEdit::LeadingPosition); searchEdit->installEventFilter(this); connect(searchEdit, &QLineEdit::textChanged, this, &SettingsView::onSearchTextChanged); diff --git a/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.cpp b/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.cpp index ea0d2db5a..c6a6d989f 100644 --- a/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.cpp +++ b/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.cpp @@ -1,12 +1,12 @@ #include "settings_button_widget.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "settings_popup_widget.h" #include #include +#include #include -#include #include #include #include @@ -21,7 +21,7 @@ SettingsButtonWidget::SettingsButtonWidget(QWidget *parent) : QWidget(parent), button(new QToolButton(this)), popup(new SettingsPopupWidget(nullptr)) { - button->setIcon(themePixmap(QStringLiteral("icons/cogwheel"))); + button->setIcon(loadThemedIcon(QStringLiteral("icons/settings"))); button->setCheckable(true); button->setFixedSize(32, 32); connect(button, &QToolButton::clicked, this, &SettingsButtonWidget::togglePopup); @@ -49,9 +49,9 @@ void SettingsButtonWidget::removeSettingsWidget(QWidget *toRemove) const popup->removeSettingsWidget(toRemove); } -void SettingsButtonWidget::setButtonIcon(QPixmap iconMap) +void SettingsButtonWidget::setButtonIcon(const QIcon &icon) { - button->setIcon(iconMap); + button->setIcon(icon); } void SettingsButtonWidget::setButtonText(const QString &text) diff --git a/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.h b/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.h index 015300bf8..56d542d37 100644 --- a/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.h +++ b/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.h @@ -13,7 +13,7 @@ #include class QHBoxLayout; -class QPixmap; +class QIcon; class QToolButton; class SettingsPopupWidget; @@ -28,7 +28,7 @@ public: void addSettingsWidget(QWidget *toAdd) const; void removeSettingsWidget(QWidget *toRemove) const; - void setButtonIcon(QPixmap iconMap); + void setButtonIcon(const QIcon &icon); void setButtonText(const QString &buttonText); void setCompact(bool compact); bool isCompact() const diff --git a/cockatrice/src/interface/widgets/server/game_selector.cpp b/cockatrice/src/interface/widgets/server/game_selector.cpp index 2702a527d..2c0050afe 100644 --- a/cockatrice/src/interface/widgets/server/game_selector.cpp +++ b/cockatrice/src/interface/widgets/server/game_selector.cpp @@ -1,7 +1,7 @@ #include "game_selector.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../interface/widgets/dialogs/dlg_create_game.h" #include "../interface/widgets/dialogs/dlg_filter_games.h" #include "../interface/widgets/tabs/tab_room.h" @@ -130,10 +130,10 @@ GameSelector::GameSelector(AbstractClient *_client, } filterButton = new QPushButton; - filterButton->setIcon(themePixmap(QStringLiteral("icons/search"))); + filterButton->setIcon(loadThemedIcon(QStringLiteral("icons/search"))); connect(filterButton, &QPushButton::clicked, this, &GameSelector::actSetFilter); clearFilterButton = new QPushButton; - clearFilterButton->setIcon(themePixmap(QStringLiteral("icons/clearsearch"))); + clearFilterButton->setIcon(loadThemedIcon(QStringLiteral("icons/clearsearch"))); bool filtersSetToDefault = showFilters && gameListProxyModel->areFilterParametersSetToDefaults(); clearFilterButton->setEnabled(!filtersSetToDefault); connect(clearFilterButton, &QPushButton::clicked, this, &GameSelector::actClearFilter); diff --git a/cockatrice/src/interface/widgets/server/remote/remote_replay_list_tree_widget.cpp b/cockatrice/src/interface/widgets/server/remote/remote_replay_list_tree_widget.cpp index c131c3cbf..c1db6138d 100644 --- a/cockatrice/src/interface/widgets/server/remote/remote_replay_list_tree_widget.cpp +++ b/cockatrice/src/interface/widgets/server/remote/remote_replay_list_tree_widget.cpp @@ -1,6 +1,6 @@ #include "remote_replay_list_tree_widget.h" -#include "../../../pixel_map_generator.h" +#include "../../../theme_icon.h" #include "libcockatrice/protocol/pb/response.pb.h" #include "libcockatrice/protocol/pb/serverinfo_replay_match.pb.h" @@ -50,7 +50,7 @@ RemoteReplayList_TreeModel::RemoteReplayList_TreeModel(AbstractClient *_client, QFileIconProvider fip; dirIcon = fip.icon(QFileIconProvider::Folder); fileIcon = fip.icon(QFileIconProvider::File); - lockIcon = themePixmap(QStringLiteral("icons/lock")); + lockIcon = loadThemedIcon(QStringLiteral("icons/lock")); } RemoteReplayList_TreeModel::~RemoteReplayList_TreeModel() diff --git a/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp index 31178ba4c..dbc255ea5 100644 --- a/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp @@ -1,7 +1,7 @@ #include "deck_editor_settings_page.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../dialogs/path_safety_warning.h" #include "update/card_spoiler/spoiler_background_updater.h" @@ -79,19 +79,19 @@ DeckEditorSettingsPage::DeckEditorSettingsPage() } aAdd = new QAction(this); - aAdd->setIcon(themePixmap(QStringLiteral("icons/increment"))); + aAdd->setIcon(loadThemedIcon(QStringLiteral("icons/increment"))); connect(aAdd, &QAction::triggered, this, &DeckEditorSettingsPage::actAddURL); aEdit = new QAction(this); - aEdit->setIcon(themePixmap(QStringLiteral("icons/pencil"))); + aEdit->setIcon(loadThemedIcon(QStringLiteral("icons/pencil"))); connect(aEdit, &QAction::triggered, this, &DeckEditorSettingsPage::actEditURL); aRemove = new QAction(this); - aRemove->setIcon(themePixmap(QStringLiteral("icons/decrement"))); + aRemove->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"))); connect(aRemove, &QAction::triggered, this, &DeckEditorSettingsPage::actRemoveURL); aRateLimit = new QAction(this); - aRateLimit->setIcon(themePixmap(QStringLiteral("icons/cogwheel"))); + aRateLimit->setIcon(loadThemedIcon(QStringLiteral("icons/settings"))); connect(aRateLimit, &QAction::triggered, this, &DeckEditorSettingsPage::actAdjustRateLimit); auto *urlToolBar = new QToolBar; diff --git a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp index 6128a7830..09dd9e9b0 100644 --- a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp @@ -1,7 +1,7 @@ #include "messages_settings_page.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../interface/widgets/utility/get_text_with_max.h" #include "libcockatrice/utility/macros.h" @@ -146,15 +146,15 @@ MessagesSettingsPage::MessagesSettingsPage() } aAdd = new QAction(this); - aAdd->setIcon(themePixmap(QStringLiteral("icons/increment"))); + aAdd->setIcon(loadThemedIcon(QStringLiteral("icons/increment"))); connect(aAdd, &QAction::triggered, this, &MessagesSettingsPage::actAdd); aEdit = new QAction(this); - aEdit->setIcon(themePixmap(QStringLiteral("icons/pencil"))); + aEdit->setIcon(loadThemedIcon(QStringLiteral("icons/pencil"))); connect(aEdit, &QAction::triggered, this, &MessagesSettingsPage::actEdit); aRemove = new QAction(this); - aRemove->setIcon(themePixmap(QStringLiteral("icons/decrement"))); + aRemove->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"))); connect(aRemove, &QAction::triggered, this, &MessagesSettingsPage::actRemove); auto *messageToolBar = new QToolBar; diff --git a/cockatrice/src/interface/widgets/settings_page/shortcut_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/shortcut_settings_page.cpp index a77c0c2c3..83c2ec585 100644 --- a/cockatrice/src/interface/widgets/settings_page/shortcut_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/shortcut_settings_page.cpp @@ -3,7 +3,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcut_treeview.h" #include "../../../client/settings/shortcuts_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../interface/widgets/utility/custom_line_edit.h" #include "../interface/widgets/utility/sequence_edit.h" @@ -58,8 +58,8 @@ ShortcutSettingsPage::ShortcutSettingsPage() btnResetAll = new QPushButton(this); btnClearAll = new QPushButton(this); - btnResetAll->setIcon(themePixmap(QStringLiteral("icons/update"))); - btnClearAll->setIcon(themePixmap(QStringLiteral("icons/clearsearch"))); + btnResetAll->setIcon(loadThemedIcon(QStringLiteral("icons/reload"))); + btnClearAll->setIcon(loadThemedIcon(QStringLiteral("icons/clearsearch"))); // layout auto *_editLayout = new QGridLayout; diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp index 7f7a2d3fd..5697308a9 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp @@ -1,7 +1,7 @@ #include "tab_archidekt.h" #include "../../../../../client/settings/cache_settings.h" -#include "../../../../pixel_map_generator.h" +#include "../../../../theme_icon.h" #include "../../../cards/additional_info/mana_symbol_widget.h" #include "../../../cards/card_size_widget.h" #include "../../../quick_settings/settings_button_widget.h" @@ -229,7 +229,7 @@ void TabArchidekt::setupFilterWidgets() // Format filter (collapsible) formatButton = new SettingsButtonWidget(secondaryToolbar); formatButton->setButtonText(tr("Formats")); - formatButton->setButtonIcon(themePixmap(QStringLiteral("icons/scale_balanced"))); + formatButton->setButtonIcon(loadThemedIcon(QStringLiteral("icons/scales"))); QWidget *formatContainer = new QWidget(secondaryToolbar); QGridLayout *formatLayout = new QGridLayout(formatContainer); diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp index 5279165af..7f1727131 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp @@ -1,7 +1,7 @@ #include "commander_bracket_widget.h" #include "../../../../../client/settings/cache_settings.h" -#include "../../../../pixel_map_generator.h" +#include "../../../../theme_icon.h" #include "commander_bracket_service.h" #include "libcockatrice/deck_list/deck_list.h" @@ -40,7 +40,7 @@ CommanderBracketWidget::CommanderBracketWidget(QWidget *parent) : QWidget(parent bracketInfoButton->setEnabled(false); bracketRefreshButton = new QToolButton(this); - bracketRefreshButton->setIcon(themePixmap(QStringLiteral("icons/reload"))); + bracketRefreshButton->setIcon(loadThemedIcon(QStringLiteral("icons/reload"))); bracketRefreshButton->setAutoRaise(true); connect(bracketRefreshButton, &QToolButton::clicked, this, &CommanderBracketWidget::requestBracketEstimate); diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp index 3dfdc956b..8af9d5e6e 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp @@ -2,7 +2,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../deck_loader/deck_loader.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../cards/additional_info/deck_color_identity.h" #include "../deck_share/deck_share_utils.h" #include "../deck_share/share_bar_widget.h" @@ -187,19 +187,19 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, // Left side actions aOpenLocalDeck = new QAction(this); - aOpenLocalDeck->setIcon(themePixmap(QStringLiteral("icons/pencil"))); + aOpenLocalDeck->setIcon(loadThemedIcon(QStringLiteral("icons/pencil"))); connect(aOpenLocalDeck, &QAction::triggered, this, &TabDeckStorage::actOpenLocalDeck); aRenameLocal = new QAction(this); - aRenameLocal->setIcon(themePixmap(QStringLiteral("icons/rename"))); + aRenameLocal->setIcon(loadThemedIcon(QStringLiteral("icons/rename"))); connect(aRenameLocal, &QAction::triggered, this, &TabDeckStorage::actRenameLocal); aUpload = new QAction(this); - aUpload->setIcon(themePixmap(QStringLiteral("icons/arrow_right_green"))); + aUpload->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_right"))); connect(aUpload, &QAction::triggered, this, &TabDeckStorage::actUpload); aNewLocalFolder = new QAction(this); aNewLocalFolder->setIcon(qApp->style()->standardIcon(QStyle::SP_FileDialogNewFolder)); connect(aNewLocalFolder, &QAction::triggered, this, &TabDeckStorage::actNewLocalFolder); aDeleteLocalDeck = new QAction(this); - aDeleteLocalDeck->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); + aDeleteLocalDeck->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"))); connect(aDeleteLocalDeck, &QAction::triggered, this, &TabDeckStorage::actDeleteLocalDeck); aOpenDecksFolder = new QAction(this); @@ -208,24 +208,24 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, // Right side actions aOpenRemoteDeck = new QAction(this); - aOpenRemoteDeck->setIcon(themePixmap(QStringLiteral("icons/pencil"))); + aOpenRemoteDeck->setIcon(loadThemedIcon(QStringLiteral("icons/pencil"))); connect(aOpenRemoteDeck, &QAction::triggered, this, &TabDeckStorage::actOpenRemoteDeck); aDownload = new QAction(this); - aDownload->setIcon(themePixmap(QStringLiteral("icons/arrow_left_green"))); + aDownload->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_left"))); connect(aDownload, &QAction::triggered, this, &TabDeckStorage::actDownload); aNewFolder = new QAction(this); aNewFolder->setIcon(qApp->style()->standardIcon(QStyle::SP_FileDialogNewFolder)); connect(aNewFolder, &QAction::triggered, this, &TabDeckStorage::actNewFolder); aDeleteRemoteDeck = new QAction(this); - aDeleteRemoteDeck->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); + aDeleteRemoteDeck->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"))); connect(aDeleteRemoteDeck, &QAction::triggered, this, &TabDeckStorage::actDeleteRemoteDeck); aShareDecks = new QAction(this); - aShareDecks->setIcon(themePixmap(QStringLiteral("icons/share"))); + aShareDecks->setIcon(loadThemedIcon(QStringLiteral("icons/share"))); connect(aShareDecks, &QAction::triggered, this, &TabDeckStorage::actShareDecks); aPublishDeck = new QAction(this); - aPublishDeck->setIcon(QPixmap("theme:icons/lock")); + aPublishDeck->setIcon(loadThemedIcon(QStringLiteral("icons/lock"))); connect(aPublishDeck, &QAction::triggered, this, &TabDeckStorage::actPublishDeck); // Add actions to toolbars diff --git a/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp b/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp index ce37ce440..73d4da420 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp @@ -2,6 +2,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../deck_loader/deck_loader.h" +#include "../../theme_icon.h" #include "../cards/card_size_widget.h" #include "../general/layout_containers/flow_widget.h" #include "../visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.h" @@ -52,8 +53,8 @@ TabPublicDecks::TabPublicDecks(TabSupervisor *_tabSupervisor, AbstractClient *_c headerLayout->addStretch(1); // Filter/toolbar row, matching the Visual Deck Storage: color identity filter - // first, the search bar stretching in the middle, and the quick settings - // cogwheel at the end. The card size slider lives inside the cogwheel popup. + // first, the search bar stretching in the middle, and the quick-settings button + // at the end. The card size slider lives inside its popup. emptyLabel = new QLabel(tr("This user has not published any decks."), this); emptyLabel->setAlignment(Qt::AlignCenter); emptyLabel->setVisible(false); @@ -68,7 +69,7 @@ TabPublicDecks::TabPublicDecks(TabSupervisor *_tabSupervisor, AbstractClient *_c colorIdentityFilter = new DeckPreviewColorIdentityFilterWidget(this); searchWidget = new VisualDeckStorageSearchWidget(this); refreshButton = new QToolButton(this); - refreshButton->setIcon(QPixmap("theme:icons/reload")); + refreshButton->setIcon(loadThemedIcon(QStringLiteral("icons/reload"))); refreshButton->setFixedSize(32, 32); quickSettingsWidget = new VisualDeckStorageQuickSettingsWidget(this); quickSettingsWidget->setPublicDecksMode(true); diff --git a/cockatrice/src/interface/widgets/tabs/tab_replays.cpp b/cockatrice/src/interface/widgets/tabs/tab_replays.cpp index d37bff3d8..bd2a69017 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_replays.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_replays.cpp @@ -1,7 +1,7 @@ #include "tab_replays.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../interface/widgets/server/remote/remote_replay_list_tree_widget.h" #include "libcockatrice/protocol/pb/commands.pb.h" #include "libcockatrice/protocol/pb/serverinfo_replay.pb.h" @@ -145,17 +145,17 @@ QGroupBox *TabReplays::createLeftLayout() // Left side actions aOpenLocalReplay = new QAction(this); - aOpenLocalReplay->setIcon(themePixmap(QStringLiteral("icons/view"))); + aOpenLocalReplay->setIcon(loadThemedIcon(QStringLiteral("icons/view"))); connect(aOpenLocalReplay, &QAction::triggered, this, &TabReplays::actOpenLocalReplay); connect(localDirView, &QTreeView::doubleClicked, this, &TabReplays::actOpenLocalReplay); aRenameLocal = new QAction(this); - aRenameLocal->setIcon(themePixmap(QStringLiteral("icons/rename"))); + aRenameLocal->setIcon(loadThemedIcon(QStringLiteral("icons/rename"))); connect(aRenameLocal, &QAction::triggered, this, &TabReplays::actRenameLocal); aNewLocalFolder = new QAction(this); aNewLocalFolder->setIcon(qApp->style()->standardIcon(QStyle::SP_FileDialogNewFolder)); connect(aNewLocalFolder, &QAction::triggered, this, &TabReplays::actNewLocalFolder); aDeleteLocalReplay = new QAction(this); - aDeleteLocalReplay->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); + aDeleteLocalReplay->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"))); connect(aDeleteLocalReplay, &QAction::triggered, this, &TabReplays::actDeleteLocalReplay); aOpenReplaysFolder = new QAction(this); @@ -207,24 +207,24 @@ QGroupBox *TabReplays::createRightLayout() // Right side actions aOpenRemoteReplay = new QAction(this); - aOpenRemoteReplay->setIcon(themePixmap(QStringLiteral("icons/view"))); + aOpenRemoteReplay->setIcon(loadThemedIcon(QStringLiteral("icons/view"))); connect(aOpenRemoteReplay, &QAction::triggered, this, &TabReplays::actOpenRemoteReplay); connect(serverDirView, &QTreeView::doubleClicked, this, &TabReplays::actOpenRemoteReplay); aDownload = new QAction(this); - aDownload->setIcon(themePixmap(QStringLiteral("icons/arrow_left_green"))); + aDownload->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_left"))); connect(aDownload, &QAction::triggered, this, &TabReplays::actDownload); aKeep = new QAction(this); - aKeep->setIcon(themePixmap(QStringLiteral("icons/lock"))); + aKeep->setIcon(loadThemedIcon(QStringLiteral("icons/lock"))); connect(aKeep, &QAction::triggered, this, &TabReplays::actKeepRemoteReplay); aDeleteRemoteReplay = new QAction(this); - aDeleteRemoteReplay->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); + aDeleteRemoteReplay->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"))); connect(aDeleteRemoteReplay, &QAction::triggered, this, &TabReplays::actDeleteRemoteReplay); aGetReplayCode = new QAction(this); - aGetReplayCode->setIcon(themePixmap(QStringLiteral("icons/share"))); + aGetReplayCode->setIcon(loadThemedIcon(QStringLiteral("icons/share"))); connect(aGetReplayCode, &QAction::triggered, this, &TabReplays::actGetReplayCode); aSubmitReplayCode = new QAction(this); - aSubmitReplayCode->setIcon(themePixmap(QStringLiteral("icons/search"))); + aSubmitReplayCode->setIcon(loadThemedIcon(QStringLiteral("icons/search"))); connect(aSubmitReplayCode, &QAction::triggered, this, &TabReplays::actSubmitReplayCode); // Add actions to toolbars diff --git a/cockatrice/src/interface/widgets/tabs/tab_room.cpp b/cockatrice/src/interface/widgets/tabs/tab_room.cpp index f63b77445..329732c64 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_room.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_room.cpp @@ -2,7 +2,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../interface/widgets/dialogs/dlg_settings.h" #include "../interface/widgets/server/chat_view/chat_view.h" #include "../interface/widgets/server/game_link.h" @@ -198,7 +198,7 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor, connect(aOpenChatSettings, &QAction::triggered, this, &TabRoom::actOpenChatSettings); auto *chatSettingsButton = new QToolButton; - chatSettingsButton->setIcon(themePixmap(QStringLiteral("icons/settings"))); + chatSettingsButton->setIcon(loadThemedIcon(QStringLiteral("icons/settings"))); chatSettingsButton->setMenu(chatSettingsMenu); chatSettingsButton->setPopupMode(QToolButton::InstantPopup); diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index 3316232a4..2f5b21280 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -7,6 +7,7 @@ #include "../../intents/contexts/context_join_room.h" #include "../../intents/intent.h" #include "../../intents/intent_join_server_game.h" +#include "../../theme_icon.h" #include "../interface/pixel_map_generator.h" #include "../interface/widgets/server/game_link.h" #include "../interface/widgets/server/user/user_list_manager.h" @@ -1347,7 +1348,7 @@ void TabSupervisor::tabUserEvent(bool globalEvent) auto *tab = static_cast(sender()); if (tab != currentWidget()) { tab->setContentsChanged(true); - setTabIcon(indexOf(tab), themePixmap(QStringLiteral("icons/tab_changed"))); + setTabIcon(indexOf(tab), loadThemedIcon(QStringLiteral("icons/tab_changed"))); } if (globalEvent && SettingsCache::instance().userInterface().getNotificationsEnabled()) { QApplication::alert(this); diff --git a/cockatrice/src/interface/widgets/utility/sequence_edit.cpp b/cockatrice/src/interface/widgets/utility/sequence_edit.cpp index 44e6581e9..b09e6da1e 100644 --- a/cockatrice/src/interface/widgets/utility/sequence_edit.cpp +++ b/cockatrice/src/interface/widgets/utility/sequence_edit.cpp @@ -2,7 +2,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include #include @@ -24,8 +24,8 @@ SequenceEdit::SequenceEdit(const QString &_shortcutName, QWidget *parent) : QWid defaultButton = new QPushButton("", this); lineEdit->setMinimumWidth(70); - clearButton->setIcon(themePixmap(QStringLiteral("icons/clearsearch"))); - defaultButton->setIcon(themePixmap(QStringLiteral("icons/update"))); + clearButton->setIcon(loadThemedIcon(QStringLiteral("icons/clearsearch"))); + defaultButton->setIcon(loadThemedIcon(QStringLiteral("icons/reload"))); auto *layout = QtUtils::makeLayout(this, 1); layout->addWidget(lineEdit); diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp index 5063cce14..ecb949b67 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp @@ -1,6 +1,6 @@ #include "visual_database_display_filter_toolbar_widget.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../deck_editor/card_database_view.h" #include "../quick_settings/settings_button_widget.h" #include "card_database_display_model.h" @@ -45,13 +45,13 @@ VisualDatabaseDisplayFilterToolbarWidget::VisualDatabaseDisplayFilterToolbarWidg sortGroupBox = new QGroupBox(this); filterGroupBox = new QGroupBox(this); - auto scalePixmap = [](const QString &fileName) { return QIcon(QPixmap(fileName)).pixmap({20, 20}); }; + auto scalePixmap = [](const QString &prefix) { return loadThemedIcon(prefix).pixmap({20, 20}); }; sortByLabel = new QLabel(this); - sortByLabel->setPixmap(scalePixmap("theme:icons/sort_arrow_down")); + sortByLabel->setPixmap(scalePixmap("icons/sort_arrow_down")); filterByLabel = new QLabel(this); - filterByLabel->setPixmap(scalePixmap("theme:icons/filter")); + filterByLabel->setPixmap(scalePixmap("icons/filter")); sortColumnCombo = new QComboBox(this); sortColumnCombo->setSizeAdjustPolicy(QComboBox::SizeAdjustPolicy::AdjustToContents); @@ -89,22 +89,22 @@ VisualDatabaseDisplayFilterToolbarWidget::VisualDatabaseDisplayFilterToolbarWidg }); quickFilterSaveLoadWidget = new SettingsButtonWidget(this); - quickFilterSaveLoadWidget->setButtonIcon(themePixmap(QStringLiteral("icons/floppy_disk"))); + quickFilterSaveLoadWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/floppy_disk"))); quickFilterNameWidget = new SettingsButtonWidget(this); - quickFilterNameWidget->setButtonIcon(themePixmap(QStringLiteral("icons/pen_to_square"))); + quickFilterNameWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/pen_to_square"))); quickFilterMainTypeWidget = new SettingsButtonWidget(this); - quickFilterMainTypeWidget->setButtonIcon(themePixmap(QStringLiteral("icons/circle_half_stroke"))); + quickFilterMainTypeWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/circle_half_stroke"))); quickFilterSubTypeWidget = new SettingsButtonWidget(this); - quickFilterSubTypeWidget->setButtonIcon(themePixmap(QStringLiteral("icons/dragon"))); + quickFilterSubTypeWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/dragon"))); quickFilterSetWidget = new SettingsButtonWidget(this); - quickFilterSetWidget->setButtonIcon(themePixmap(QStringLiteral("icons/scroll"))); + quickFilterSetWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/scroll"))); quickFilterFormatLegalityWidget = new SettingsButtonWidget(this); - quickFilterFormatLegalityWidget->setButtonIcon(themePixmap(QStringLiteral("icons/scale_balanced"))); + quickFilterFormatLegalityWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/scales"))); retranslateUi(); } diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp index 6037418ad..af5c74969 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp @@ -4,7 +4,9 @@ #include "../../../filters/filter_tree_model.h" #include "../../../filters/syntax_help.h" #include "../../key_signals.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" +#include "../cards/card_info_picture_widget.h" +#include "../cards/card_info_picture_with_text_overlay_widget.h" #include "../cards/card_size_widget.h" #include "../deck_editor/card_database_view.h" #include "../general/layout_containers/flow_widget.h" @@ -79,8 +81,8 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, searchEdit->setObjectName("searchEdit"); searchEdit->setPlaceholderText(tr("Search by card name (or search expressions)")); searchEdit->setClearButtonEnabled(true); - searchEdit->addAction(loadColorAdjustedPixmap("theme:icons/search"), QLineEdit::LeadingPosition); - auto help = searchEdit->addAction(themePixmap(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); + searchEdit->addAction(loadThemedIcon(QStringLiteral("icons/search")), QLineEdit::LeadingPosition); + auto help = searchEdit->addAction(loadThemedIcon(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); connect(help, &QAction::triggered, this, [this] { createSearchSyntaxHelpWindow(searchEdit); }); setFocusProxy(searchEdit); @@ -154,7 +156,7 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, clearFilterWidget = new QToolButton(); clearFilterWidget->setFixedSize(32, 32); - clearFilterWidget->setIcon(themePixmap(QStringLiteral("icons/delete"))); + clearFilterWidget->setIcon(loadThemedIcon(QStringLiteral("icons/delete"))); connect(clearFilterWidget, &QToolButton::clicked, this, [this] { filterModel->blockSignals(true); filterModel->filterTree()->blockSignals(true); diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp index 63ef07835..f0c054c3a 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp @@ -1,6 +1,6 @@ #include "visual_deck_display_options_widget.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../deck_editor/deck_editor_deck_dock_widget.h" #include "../quick_settings/settings_button_widget.h" #include "../tabs/visual_deck_editor/tab_deck_editor_visual.h" @@ -65,7 +65,7 @@ VisualDeckDisplayOptionsWidget::VisualDeckDisplayOptionsWidget(QWidget *parent) sortByLabel = new QLabel(this); sortCriteriaButton = new SettingsButtonWidget(this); - sortCriteriaButton->setButtonIcon(themePixmap(QStringLiteral("icons/sort_arrow_down"))); + sortCriteriaButton->setButtonIcon(loadThemedIcon(QStringLiteral("icons/sort_arrow_down"))); sortLabel = new QLabel(sortCriteriaButton); sortLabel->setWordWrap(true); @@ -119,11 +119,11 @@ void VisualDeckDisplayOptionsWidget::updateDisplayTypeButton() switch (currentDisplayType) { case DisplayType::Flat: displayTypeButton->setButtonText(tr("Toggle Layout: Flat")); - displayTypeButton->setButtonIcon(themePixmap(QStringLiteral("icons/scroll"))); + displayTypeButton->setButtonIcon(loadThemedIcon(QStringLiteral("icons/scroll"))); break; case DisplayType::Overlap: displayTypeButton->setButtonText(tr("Toggle Layout: Overlap")); - displayTypeButton->setButtonIcon(themePixmap(QStringLiteral("icons/scales"))); + displayTypeButton->setButtonIcon(loadThemedIcon(QStringLiteral("icons/scales"))); break; } } diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.cpp index 506ffa2b4..0e0b5e4b4 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.cpp @@ -1,7 +1,7 @@ #include "visual_deck_editor_widget.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../cards/card_size_widget.h" #include "../cards/deck_card_zone_display_widget.h" #include "../general/layout_containers/flow_widget.h" @@ -144,7 +144,7 @@ void VisualDeckEditorWidget::initializeSearchBarAndCompleter() // Search button functionality searchPushButton = new CompactPushButton(searchContainer); - searchPushButton->setButtonIcon(themePixmap(QStringLiteral("icons/search"))); + searchPushButton->setButtonIcon(loadThemedIcon(QStringLiteral("icons/search"))); connect(searchPushButton, &QPushButton::clicked, this, [this] { addCardFromSearch(); }); searchLayout->addWidget(searchPushButton); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp index f2669f55d..10a3bf33a 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp @@ -1,7 +1,7 @@ #include "visual_deck_storage_search_widget.h" #include "../../../filters/syntax_help.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include #include @@ -27,9 +27,9 @@ VisualDeckStorageSearchWidget::VisualDeckStorageSearchWidget(QWidget *parent) : searchBar = new QLineEdit(this); searchBar->setPlaceholderText(tr("Search by filename (or search expression)")); searchBar->setClearButtonEnabled(true); - searchBar->addAction(loadColorAdjustedPixmap("theme:icons/search"), QLineEdit::LeadingPosition); + searchBar->addAction(loadThemedIcon(QStringLiteral("icons/search")), QLineEdit::LeadingPosition); - auto help = searchBar->addAction(themePixmap(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); + auto help = searchBar->addAction(loadThemedIcon(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); connect(help, &QAction::triggered, this, [this] { createDeckSearchSyntaxHelpWindow(searchBar); }); layout->addWidget(searchBar); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp index 4e1d6293f..bbf68adb9 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp @@ -1,7 +1,7 @@ #include "visual_deck_storage_widget.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "deck_preview/deck_preview_color_identity_filter_widget.h" #include "deck_preview/deck_preview_widget.h" #include "libcockatrice/card/database/card_database.h" @@ -62,12 +62,12 @@ VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(pare searchWidget = new VisualDeckStorageSearchWidget(this); refreshButton = new QToolButton(this); - refreshButton->setIcon(themePixmap(QStringLiteral("icons/reload"))); + refreshButton->setIcon(loadThemedIcon(QStringLiteral("icons/reload"))); refreshButton->setFixedSize(32, 32); connect(refreshButton, &QPushButton::clicked, this, &VisualDeckStorageWidget::refreshIfPossible); shareButton = new QToolButton(this); - shareButton->setIcon(themePixmap(QStringLiteral("icons/share"))); + shareButton->setIcon(loadThemedIcon(QStringLiteral("icons/share"))); shareButton->setFixedSize(32, 32); shareButton->setVisible(false); connect(shareButton, &QPushButton::clicked, this, &VisualDeckStorageWidget::shareRequested); diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index d546676bc..4e2e90556 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -50,6 +50,7 @@ #include "latency_tracker.h" #include "logger.h" #include "pixel_map_generator.h" +#include "theme_icon.h" #include "version_string.h" #include "widgets/dialogs/dlg_connect.h" #include "widgets/server/handle_public_servers.h" @@ -360,10 +361,10 @@ void MainWindow::retranslateUi() aRegister->setText(tr("&Register to server...")); aForgotPassword->setText(tr("&Restore password...")); aSettings->setText(tr("&Settings...")); - aSettings->setIcon(themePixmap(QStringLiteral("icons/settings"))); + aSettings->setIcon(loadThemedIcon(QStringLiteral("icons/settings"))); settingsButton->setToolTip(tr("Settings")); settingsButton->setAccessibleName(tr("Settings")); - settingsButton->setIcon(themePixmap(QStringLiteral("icons/cogwheel"))); + settingsButton->setIcon(loadThemedIcon(QStringLiteral("icons/settings"))); aExit->setText(tr("&Exit")); #if defined(__APPLE__) /* For OSX */ diff --git a/tests/theme_migration/CMakeLists.txt b/tests/theme_migration/CMakeLists.txt index 93493ee5f..818c1bb16 100644 --- a/tests/theme_migration/CMakeLists.txt +++ b/tests/theme_migration/CMakeLists.txt @@ -5,12 +5,14 @@ cockatrice_add_test( ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_manager.cpp ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_config.cpp ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/pixel_map_generator.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_icon.cpp ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/cache_settings.cpp ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/card_counter_settings.cpp ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/shortcuts_settings.cpp ${CMAKE_SOURCE_DIR}/cockatrice/src/client/network/update/client/release_channel.cpp ${VERSION_STRING_CPP} theme_migration_test.cpp + theme_icon_render_test.cpp LIBS libcockatrice_settings libcockatrice_network @@ -18,4 +20,6 @@ cockatrice_add_test( libcockatrice_protocol INCLUDE_DIRS ${CMAKE_SOURCE_DIR}/cockatrice/src + DEFINITIONS + COCKATRICE_SOURCE_DIR="${CMAKE_SOURCE_DIR}" ) diff --git a/tests/theme_migration/theme_icon_render_test.cpp b/tests/theme_migration/theme_icon_render_test.cpp new file mode 100644 index 000000000..5d0c13e3f --- /dev/null +++ b/tests/theme_migration/theme_icon_render_test.cpp @@ -0,0 +1,186 @@ +#include "interface/theme_icon.h" +#include "interface/theme_manager.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +/** + * @brief Creates the GUI application QPixmap needs, using the offscreen plugin. + * + * The theme-migration binary is headless; creating this lazily keeps its other + * tests free of a display dependency. + */ +void ensureGuiApplication() +{ + static int argc = 1; + static char name[] = "theme_icon_test"; + static char *argv[] = {name, nullptr}; + static QApplication app(argc, argv); + Q_UNUSED(app); +} + +QString resourcesDir() +{ + return QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(QStringLiteral("cockatrice/resources")); +} + +int countOpaquePixels(const QPixmap &pixmap, const QColor &expected) +{ + const QImage image = pixmap.toImage(); + int count = 0; + for (int y = 0; y < image.height(); ++y) { + for (int x = 0; x < image.width(); ++x) { + const QColor pixel = image.pixelColor(x, y); + if (pixel.alpha() > 200 && pixel.red() == expected.red() && pixel.green() == expected.green() && + pixel.blue() == expected.blue()) { + ++count; + } + } + } + return count; +} + +QByteArray svgWith(const QByteArray &colorToken) +{ + return QByteArrayLiteral(R"()"); +} + +/** + * @brief Restores the global theme search paths and palette after each test. + * + * The icon engine reads both process-wide values, so a failing assertion must + * not leak a temporary theme directory or palette into the next test. + */ +class ThemeIconRenderTest : public ::testing::Test +{ +protected: + void SetUp() override + { + qputenv("QT_QPA_PLATFORM", "offscreen"); + ensureGuiApplication(); + savedSearchPaths = QDir::searchPaths(QStringLiteral("theme")); + savedPalette = qApp->palette(); + themeManager = new ThemeManager; + QDir::setSearchPaths(QStringLiteral("theme"), {resourcesDir()}); + } + + void TearDown() override + { + delete themeManager; + themeManager = nullptr; + QDir::setSearchPaths(QStringLiteral("theme"), savedSearchPaths); + qApp->setPalette(savedPalette); + } + + QStringList savedSearchPaths; ///< Search paths captured before the test. + QPalette savedPalette; ///< Application palette captured before the test. +}; + +/** @brief A non-square request must letterbox, not squash, a 24×24 glyph. */ +TEST_F(ThemeIconRenderTest, NonSquareRequestPreservesAspectRatio) +{ + const QIcon icon = loadThemedIcon(QStringLiteral("icons/player")); + ASSERT_FALSE(icon.isNull()); + + EXPECT_EQ(icon.actualSize(QSize(20, 15)), QSize(15, 15)); + + const QPixmap pixmap = icon.pixmap(QSize(20, 15)); + ASSERT_FALSE(pixmap.isNull()); + EXPECT_EQ(pixmap.deviceIndependentSize(), QSizeF(15, 15)); +} + +/** @brief The engine must follow palette flips, icon modes, and DPR. */ +TEST_F(ThemeIconRenderTest, ThemedIconFollowsPaletteModeAndDpr) +{ + QPalette palette = qApp->palette(); + palette.setColor(QPalette::WindowText, QColor(QStringLiteral("#123456"))); + qApp->setPalette(palette); + + const QIcon icon = loadThemedIcon(QStringLiteral("icons/search")); + ASSERT_FALSE(icon.isNull()); + + const QPixmap normal = icon.pixmap(QSize(24, 24), 1.0); + ASSERT_FALSE(normal.isNull()); + EXPECT_EQ(normal.size(), QSize(24, 24)); + EXPECT_DOUBLE_EQ(normal.devicePixelRatio(), 1.0); + EXPECT_GT(countOpaquePixels(normal, QColor(QStringLiteral("#123456"))), 0); + + const QPixmap hidpi = icon.pixmap(QSize(24, 24), 2.0); + ASSERT_FALSE(hidpi.isNull()); + EXPECT_DOUBLE_EQ(hidpi.devicePixelRatio(), 2.0); + EXPECT_EQ(hidpi.deviceIndependentSize(), QSizeF(24, 24)); + + palette.setColor(QPalette::WindowText, QColor(QStringLiteral("#abcdef"))); + qApp->setPalette(palette); + const QPixmap flipped = icon.pixmap(QSize(24, 24), 1.0); + EXPECT_GT(countOpaquePixels(flipped, QColor(QStringLiteral("#abcdef"))), 0); + + QPalette selectedPalette = qApp->palette(); + selectedPalette.setColor(QPalette::HighlightedText, QColor(QStringLiteral("#00ff00"))); + qApp->setPalette(selectedPalette); + const QPixmap selected = icon.pixmap(QSize(24, 24), 1.0, QIcon::Selected, QIcon::Off); + EXPECT_GT(countOpaquePixels(selected, QColor(QStringLiteral("#00ff00"))), 0); +} +} // namespace + +TEST_F(ThemeIconRenderTest, SentinelUsesPaletteColor) +{ + QPalette palette; + palette.setColor(QPalette::WindowText, QColor(Qt::white)); + qApp->setPalette(palette); + + const QPixmap pixmap = loadSentinelSvgPixmap(QStringLiteral("icons/search.svg")); + + ASSERT_FALSE(pixmap.isNull()); + EXPECT_EQ(pixmap.size(), QSize(24, 24)); + EXPECT_GT(countOpaquePixels(pixmap, Qt::white), 0); +} + +TEST_F(ThemeIconRenderTest, NonSentinelSvgReturnsNull) +{ + QTemporaryDir themeDir; + ASSERT_TRUE(themeDir.isValid()); + QFile file(QDir(themeDir.path()).absoluteFilePath(QStringLiteral("plain.svg"))); + ASSERT_TRUE(file.open(QIODevice::WriteOnly)); + file.write(svgWith("#000000")); + file.close(); + QDir::addSearchPath(QStringLiteral("theme"), themeDir.path()); + + EXPECT_TRUE(loadSentinelSvgPixmap(QStringLiteral("plain.svg")).isNull()); + EXPECT_TRUE(loadSentinelSvgPixmap(QStringLiteral("icons/search.png")).isNull()); +} + +TEST_F(ThemeIconRenderTest, LowercaseSentinelIsResolved) +{ + QTemporaryDir themeDir; + ASSERT_TRUE(themeDir.isValid()); + QFile file(QDir(themeDir.path()).absoluteFilePath(QStringLiteral("lower.svg"))); + ASSERT_TRUE(file.open(QIODevice::WriteOnly)); + file.write(svgWith("currentcolor")); + file.close(); + + QDir::addSearchPath(QStringLiteral("theme"), themeDir.path()); + + QPalette palette; + palette.setColor(QPalette::WindowText, QColor(QStringLiteral("#123456"))); + qApp->setPalette(palette); + + const QPixmap pixmap = loadSentinelSvgPixmap(QStringLiteral("lower.svg")); + + ASSERT_FALSE(pixmap.isNull()); + EXPECT_GT(countOpaquePixels(pixmap, QColor(QStringLiteral("#123456"))), 0); +} From 005a2cc131b3502474558295db39b8bb56ddfc5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Fri, 23 Oct 2026 09:58:35 +0200 Subject: [PATCH 102/108] [Tests] Keep the icon render test QApplication alive until exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creating it as a function-local static tears it down before Qt's library statics at exit, and libQt6Network then runs QObject destructors against freed memory — a SEGV the sanitizer leg reports after the tests have already passed. Leak the application instead, the usual pattern for a test binary. --- tests/theme_migration/theme_icon_render_test.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/theme_migration/theme_icon_render_test.cpp b/tests/theme_migration/theme_icon_render_test.cpp index 5d0c13e3f..f12db5b16 100644 --- a/tests/theme_migration/theme_icon_render_test.cpp +++ b/tests/theme_migration/theme_icon_render_test.cpp @@ -28,7 +28,10 @@ void ensureGuiApplication() static int argc = 1; static char name[] = "theme_icon_test"; static char *argv[] = {name, nullptr}; - static QApplication app(argc, argv); + // Leaked on purpose: a function-local static QApplication is destroyed before Qt's + // own library statics at exit, and the network module's destructors then run + // against a torn-down QObject layer (SEGV under AddressSanitizer). + static QApplication *app = new QApplication(argc, argv); Q_UNUSED(app); } From a7d93ad089bfb2bb4d590f4f30f73070f79a3992 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Sun, 1 Nov 2026 17:59:19 +0100 Subject: [PATCH 103/108] [Icons] Render lock and dropdown glyphs through the icon engine Both generators rendered the sentinel SVG to a fixed height x height pixmap at DPR 1. The pre-migration path rendered larger and let QIcon scale it, so on a 2x display the games-list lock (13px) and the banner dropdown arrow (24px) came back crisp; the new path hands the widget a DPR-1 bitmap that gets upscaled and blurred. - The games list asks the themed engine for `icons/lock` directly, so the item view paints it at the device pixel ratio and the lock also follows HighlightedText on a selected row. - The dropdown goes through `loadThemedIcon(...).pixmap(size, dpr)`, which keeps the glyph's aspect ratio and renders at the requested ratio. - LockPixmapGenerator has no consumer left, so it and its cache are gone. --- .../src/interface/pixel_map_generator.cpp | 34 ++++--------------- .../src/interface/pixel_map_generator.h | 13 ------- .../interface/widgets/server/games_model.cpp | 8 +++-- .../interface/widgets/server/games_model.h | 2 ++ 4 files changed, 15 insertions(+), 42 deletions(-) diff --git a/cockatrice/src/interface/pixel_map_generator.cpp b/cockatrice/src/interface/pixel_map_generator.cpp index f024ba580..037293af9 100644 --- a/cockatrice/src/interface/pixel_map_generator.cpp +++ b/cockatrice/src/interface/pixel_map_generator.cpp @@ -4,6 +4,7 @@ #include "theme_icon.h" #include "theme_manager.h" +#include #include #include #include @@ -435,39 +436,19 @@ QIcon UserLevelPixmapGenerator::generateIconWithColorOverride(int height, QMap UserLevelPixmapGenerator::iconCache; -QPixmap LockPixmapGenerator::generatePixmap(int height) -{ - - int key = height; - if (pmCache.contains(key)) { - return pmCache.value(key); - } - - const QString resolved = themeManager->assetPath(QStringLiteral("icons/lock")); - QPixmap pixmap = loadSentinelSvgPixmap(resolved, QPalette::WindowText, QSize(height, height)); - if (pixmap.isNull()) { - pixmap = tryLoadImage(QStringLiteral("theme:") + resolved, QSize(height, height), true); - } - pmCache.insert(key, pixmap); - return pixmap; -} - -QMap LockPixmapGenerator::pmCache; - QPixmap DropdownIconPixmapGenerator::generatePixmap(int height, bool expanded) { - QString key = QString::number(expanded) + ":" + QString::number(height); + const QString key = QString::number(expanded) + ":" + QString::number(height); if (pmCache.contains(key)) { return pmCache.value(key); } + // Render through the themed icon engine: it keeps the glyph's aspect ratio, + // follows the palette, and renders at the device pixel ratio instead of + // handing a DPR-1 bitmap to a label that would upscale and blur it on a 2x + // display. const QString prefix = QStringLiteral("icons/") + (expanded ? "dropdown_expanded" : "dropdown_collapsed"); - const QString resolved = themeManager->assetPath(prefix); - QPixmap pixmap = loadSentinelSvgPixmap(resolved, QPalette::WindowText, QSize(height, height)); - if (pixmap.isNull()) { - pixmap = tryLoadImage(QStringLiteral("theme:") + resolved, QSize(height, height), true); - } - + const QPixmap pixmap = loadThemedIcon(prefix).pixmap(QSize(height, height), qApp->devicePixelRatio()); pmCache.insert(key, pixmap); return pixmap; } @@ -539,7 +520,6 @@ void clearPixmapGeneratorCaches() PingPixmapGenerator::clear(); CountryPixmapGenerator::clear(); UserLevelPixmapGenerator::clear(); - LockPixmapGenerator::clear(); DropdownIconPixmapGenerator::clear(); ManaSymbolPixmapGenerator::clear(); } diff --git a/cockatrice/src/interface/pixel_map_generator.h b/cockatrice/src/interface/pixel_map_generator.h index 299f72609..951f4231b 100644 --- a/cockatrice/src/interface/pixel_map_generator.h +++ b/cockatrice/src/interface/pixel_map_generator.h @@ -106,19 +106,6 @@ public: } }; -class LockPixmapGenerator -{ -private: - static QMap pmCache; - -public: - static QPixmap generatePixmap(int height); - static void clear() - { - pmCache.clear(); - } -}; - class DropdownIconPixmapGenerator { private: diff --git a/cockatrice/src/interface/widgets/server/games_model.cpp b/cockatrice/src/interface/widgets/server/games_model.cpp index 315d51d9e..95ea97bd2 100644 --- a/cockatrice/src/interface/widgets/server/games_model.cpp +++ b/cockatrice/src/interface/widgets/server/games_model.cpp @@ -1,6 +1,7 @@ #include "games_model.h" #include "../../../client/settings/cache_settings.h" +#include "../../theme_icon.h" #include "../interface/pixel_map_generator.h" #include "libcockatrice/protocol/pb/serverinfo_user.pb.h" #include "user/user_list_proxy.h" @@ -85,7 +86,8 @@ const QString GamesModel::getGameCreatedString(const int secs) } GamesModel::GamesModel(const QMap &_rooms, const QMap &_gameTypes, QObject *parent) - : QAbstractTableModel(parent), rooms(_rooms), gameTypes(_gameTypes) + : QAbstractTableModel(parent), rooms(_rooms), gameTypes(_gameTypes), + lockIcon(loadThemedIcon(QStringLiteral("icons/lock"))) { } @@ -188,7 +190,9 @@ QVariant GamesModel::data(const QModelIndex &index, int role) const return result.join(", "); } case Qt::DecorationRole: { - return gameentry.with_password() ? QIcon(LockPixmapGenerator::generatePixmap(13)) : QVariant(); + // One engine per model: it is palette- and theme-aware and + // invalidates itself, so a repaint must not build a new one. + return gameentry.with_password() ? lockIcon : QVariant(); case Qt::TextAlignmentRole: return Qt::AlignLeft; default: diff --git a/cockatrice/src/interface/widgets/server/games_model.h b/cockatrice/src/interface/widgets/server/games_model.h index 9bf54b83a..62862417d 100644 --- a/cockatrice/src/interface/widgets/server/games_model.h +++ b/cockatrice/src/interface/widgets/server/games_model.h @@ -5,6 +5,7 @@ #include "game_type_map.h" #include +#include #include #include #include @@ -32,6 +33,7 @@ private: QList gameList; /**< List of games currently displayed. */ QMap rooms; /**< Map of room IDs to room names. */ QMap gameTypes; /**< Map of room IDs to available game types. */ + QIcon lockIcon; /**< Themed lock for password-protected games. */ static const int NUM_COLS = 8; /**< Number of columns in the table. */ From a87c55e7416079a97c88d3749a66b20e1d0a3b5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Tue, 20 Oct 2026 11:49:21 +0200 Subject: [PATCH 104/108] [Icons] Add an icon asset ratchet test The icon rules were enforced by review only, so the next Inkscape export could reintroduce a baked fill or a physical size without anyone noticing. Add a test that rejects non-24 viewBoxes, physical sizes, editor metadata, hardcoded paints, missing tokens, color-coded filenames, and rasters. Keep the legacy baseline empty so the ratchet can only tighten. --- tests/CMakeLists.txt | 1 + tests/icon_assets/CMakeLists.txt | 3 + tests/icon_assets/icon-baseline.txt | 9 + tests/icon_assets/icon_assets_test.cpp | 293 +++++++++++++++++++++++++ 4 files changed, 306 insertions(+) create mode 100644 tests/icon_assets/CMakeLists.txt create mode 100644 tests/icon_assets/icon-baseline.txt create mode 100644 tests/icon_assets/icon_assets_test.cpp diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 5dff3a761..59a7376a3 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -528,6 +528,7 @@ add_subdirectory(card_zone_algorithms) add_subdirectory(carddatabase) add_subdirectory(deck_list_model) add_subdirectory(deck_list_zones) +add_subdirectory(icon_assets) add_subdirectory(loading_from_clipboard) add_subdirectory(model_contract) add_subdirectory(movecard_tests) diff --git a/tests/icon_assets/CMakeLists.txt b/tests/icon_assets/CMakeLists.txt new file mode 100644 index 000000000..da9776f8e --- /dev/null +++ b/tests/icon_assets/CMakeLists.txt @@ -0,0 +1,3 @@ +cockatrice_add_test( + NAME icon_assets_test SOURCES icon_assets_test.cpp DEFINITIONS COCKATRICE_SOURCE_DIR="${CMAKE_SOURCE_DIR}" +) diff --git a/tests/icon_assets/icon-baseline.txt b/tests/icon_assets/icon-baseline.txt new file mode 100644 index 000000000..01a3f8f15 --- /dev/null +++ b/tests/icon_assets/icon-baseline.txt @@ -0,0 +1,9 @@ +# Icon rule baseline (ratchet). +# +# One violation per line: . +# Rules: xml, viewbox, editor-metadata, hardcoded-color, color-filename, no-token, physical-size, raster. +# +# This file may only shrink. When an icon is fixed, delete its line(s) in the +# same change; the test fails while a fixed entry remains. Never add new lines. +# +# The UI glyph set is fully migrated; this list is intentionally empty. diff --git a/tests/icon_assets/icon_assets_test.cpp b/tests/icon_assets/icon_assets_test.cpp new file mode 100644 index 000000000..93681d59c --- /dev/null +++ b/tests/icon_assets/icon_assets_test.cpp @@ -0,0 +1,293 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +const QString ICONS_RELATIVE_PATH = QStringLiteral("cockatrice/resources/icons"); +const QString BASELINE_RELATIVE_PATH = QStringLiteral("tests/icon_assets/icon-baseline.txt"); + +QString iconsDir() +{ + return QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(ICONS_RELATIVE_PATH); +} + +QString baselinePath() +{ + return QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(BASELINE_RELATIVE_PATH); +} + +/** @brief True for content art that is allowed to carry full color. */ +bool isContentArt(const QString &relativePath) +{ + return relativePath.startsWith(QStringLiteral("mana/")); +} + +QString violation(const QString &rule, const QString &relativePath) +{ + return rule + QLatin1Char(' ') + relativePath; +} + +/** + * @brief Reads one SVG and returns true when the root carries a usable viewBox. + * + * Also reports malformed XML and editor-only metadata through the out params. + */ +bool inspectSvg(const QString &absolutePath, + bool &validXml, + bool &hasViewBox, + bool &hasEditorMetadata, + bool &hasPhysicalSize) +{ + validXml = false; + hasViewBox = false; + hasEditorMetadata = false; + hasPhysicalSize = false; + + QFile file(absolutePath); + if (!file.open(QIODevice::ReadOnly)) { + return false; + } + + QXmlStreamReader reader(&file); + bool sawRoot = false; + + while (!reader.atEnd()) { + reader.readNext(); + + if (reader.hasError()) { + return false; + } + + if (!reader.isStartElement()) { + continue; + } + + if (!sawRoot) { + sawRoot = true; + if (reader.name() != QStringLiteral("svg")) { + return false; + } + const QString viewBox = reader.attributes().value(QStringLiteral("viewBox")).toString().simplified(); + hasViewBox = viewBox == QStringLiteral("0 0 24 24"); + hasPhysicalSize = !reader.attributes().value(QStringLiteral("width")).isEmpty() || + !reader.attributes().value(QStringLiteral("height")).isEmpty(); + } + + if (reader.name() == QStringLiteral("metadata") || reader.name() == QStringLiteral("namedview")) { + hasEditorMetadata = true; + } + + for (const QXmlStreamAttribute &attribute : reader.attributes()) { + const QString prefix = attribute.prefix().toString(); + if (prefix == QStringLiteral("inkscape") || prefix == QStringLiteral("sodipodi") || + prefix == QStringLiteral("dc") || prefix == QStringLiteral("cc") || prefix == QStringLiteral("rdf")) { + hasEditorMetadata = true; + } + } + } + + validXml = sawRoot && !reader.hasError(); + return validXml; +} + +bool isAllowedPaintValue(const QString &value) +{ + const QString normalized = value.trimmed().toLower(); + return normalized.isEmpty() || normalized == QStringLiteral("none") || + normalized == QStringLiteral("currentcolor") || normalized == QStringLiteral("inherit") || + normalized == QStringLiteral("context-fill") || normalized == QStringLiteral("context-stroke") || + normalized.startsWith(QStringLiteral("url(")); +} + +QRegularExpression paintValuePattern() +{ + static const QRegularExpression pattern( + QStringLiteral(R"re((?:fill|stroke|stop-color|color)\s*[:=]\s*["']?\s*([^"'\s;>]+))re"), + QRegularExpression::CaseInsensitiveOption); + return pattern; +} + +bool hasHardcodedColor(const QString &absolutePath) +{ + QFile file(absolutePath); + if (!file.open(QIODevice::ReadOnly)) { + return false; + } + + const QString content = QString::fromUtf8(file.readAll()); + auto matches = paintValuePattern().globalMatch(content); + while (matches.hasNext()) { + const QString value = matches.next().captured(1).trimmed(); + if (isAllowedPaintValue(value)) { + continue; + } + const QString normalized = value.toLower(); + if (normalized.startsWith(QLatin1Char('#')) || normalized.contains(QStringLiteral("rgb(")) || + normalized.contains(QStringLiteral("hsl(")) || QColor(value).isValid()) { + return true; + } + } + return false; +} + +bool hasCurrentColorToken(const QString &absolutePath) +{ + QFile file(absolutePath); + if (!file.open(QIODevice::ReadOnly)) { + return false; + } + + const QString content = QString::fromUtf8(file.readAll()); + auto matches = paintValuePattern().globalMatch(content); + while (matches.hasNext()) { + if (matches.next().captured(1).trimmed().compare(QStringLiteral("currentColor"), Qt::CaseInsensitive) == 0) { + return true; + } + } + return false; +} + +bool isSchemeVariant(const QString &fileName) +{ + return fileName.contains(QStringLiteral("-dark."), Qt::CaseInsensitive) || + fileName.contains(QStringLiteral("-light."), Qt::CaseInsensitive); +} + +bool hasColorInFilename(const QString &fileName) +{ + static const QRegularExpression colorSuffix( + QStringLiteral(R"(_(green|blue|red|yellow|black|white|gray|grey)\.svg$)"), + QRegularExpression::CaseInsensitiveOption); + return colorSuffix.match(fileName).hasMatch(); +} + +/** @brief Collects every current violation as "rule relative/path.svg". */ +QSet collectViolations() +{ + QSet violations; + + QDirIterator it(iconsDir(), QDir::Files, QDirIterator::Subdirectories); + while (it.hasNext()) { + it.next(); + const QFileInfo info = it.fileInfo(); + const QString relativePath = QDir(iconsDir()).relativeFilePath(info.absoluteFilePath()); + if (isContentArt(relativePath)) { + continue; + } + if (info.suffix().compare(QStringLiteral("svg"), Qt::CaseInsensitive) != 0) { + violations.insert(violation(QStringLiteral("raster"), relativePath)); + continue; + } + + bool validXml = false; + bool hasViewBox = false; + bool hasEditorMetadata = false; + bool hasPhysicalSize = false; + inspectSvg(info.absoluteFilePath(), validXml, hasViewBox, hasEditorMetadata, hasPhysicalSize); + + if (!validXml) { + violations.insert(violation(QStringLiteral("xml"), relativePath)); + } + if (validXml && !hasViewBox) { + violations.insert(violation(QStringLiteral("viewbox"), relativePath)); + } + if (hasPhysicalSize) { + violations.insert(violation(QStringLiteral("physical-size"), relativePath)); + } + if (hasEditorMetadata) { + violations.insert(violation(QStringLiteral("editor-metadata"), relativePath)); + } + if (hasHardcodedColor(info.absoluteFilePath()) && !isSchemeVariant(info.fileName())) { + violations.insert(violation(QStringLiteral("hardcoded-color"), relativePath)); + } + if (!hasCurrentColorToken(info.absoluteFilePath()) && !isSchemeVariant(info.fileName())) { + violations.insert(violation(QStringLiteral("no-token"), relativePath)); + } + if (hasColorInFilename(info.fileName())) { + violations.insert(violation(QStringLiteral("color-filename"), relativePath)); + } + } + + return violations; +} + +/** @brief Parses the ratchet file; returns false with an error when it cannot be read. */ +bool readBaseline(QSet &entries, QString &error) +{ + QFile file(baselinePath()); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + error = QStringLiteral("Cannot read %1").arg(baselinePath()); + return false; + } + + const QStringList lines = QString::fromUtf8(file.readAll()).split(QLatin1Char('\n')); + for (const QString &line : lines) { + const QString trimmed = line.trimmed(); + if (trimmed.isEmpty() || trimmed.startsWith(QLatin1Char('#'))) { + continue; + } + entries.insert(trimmed); + } + return true; +} + +QString formatSet(const QSet &entries) +{ + QStringList lines(entries.begin(), entries.end()); + lines.sort(); + return lines.join(QLatin1Char('\n')); +} + +} // namespace + +/** + * @brief The icon ratchet: no new violations, and fixed icons must leave the baseline. + * + * Existing UI glyphs are grandfathered in tests/icon_assets/icon-baseline.txt. Fixing an icon + * without deleting its baseline lines fails on purpose, so the list can only shrink. + */ +TEST(IconAssets, BaselineRatchet) +{ + ASSERT_TRUE(QDir::isAbsolutePath(iconsDir())); + ASSERT_TRUE(QDir(iconsDir()).exists()) << "Missing icon directory: " << iconsDir().toStdString(); + + QSet baseline; + QString error; + ASSERT_TRUE(readBaseline(baseline, error)) << error.toStdString(); + + const QSet actual = collectViolations(); + + const QSet newViolations = actual - baseline; + const QSet staleBaseline = baseline - actual; + + EXPECT_TRUE(newViolations.isEmpty()) << "New icon rule violations. Fix the icon; do not add exceptions:\n" + << formatSet(newViolations).toStdString(); + + EXPECT_TRUE(staleBaseline.isEmpty()) << "These baseline entries are fixed or gone; delete them from " + << BASELINE_RELATIVE_PATH.toStdString() << ":\n" + << formatSet(staleBaseline).toStdString(); +} + +/** @brief Guards against duplicate or malformed baseline entries. */ +TEST(IconAssets, BaselineIsWellFormed) +{ + QSet baseline; + QString error; + ASSERT_TRUE(readBaseline(baseline, error)) << error.toStdString(); + EXPECT_TRUE(baseline.isEmpty()) << "The legacy baseline is fully paid down; fix new violations instead"; + + static const QRegularExpression entryPattern(QStringLiteral( + R"(^(xml|viewbox|editor-metadata|hardcoded-color|color-filename|no-token|physical-size|raster) \S+$)")); + for (const QString &entry : baseline) { + EXPECT_TRUE(entryPattern.match(entry).hasMatch()) << "Malformed baseline entry: " << entry.toStdString(); + } +} From 8551e22b2d554e3b73a14fc95503bef993f84083 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Sun, 1 Nov 2026 18:01:59 +0100 Subject: [PATCH 105/108] [Tests] Drop the empty icon baseline The legacy icon set is fully paid down, so the ratchet had nothing left to ratchet: BaselineIsWellFormed required the baseline to be empty, which made the baseline file, the stale-entry diff and the entry-format regex unreachable. The rules are enforced directly now. - AssetsFollowTheRules fails on any violation of collectViolations(). - tests/icon_assets/icon-baseline.txt is gone with its reader. --- tests/icon_assets/icon-baseline.txt | 9 ---- tests/icon_assets/icon_assets_test.cpp | 68 +++----------------------- 2 files changed, 8 insertions(+), 69 deletions(-) delete mode 100644 tests/icon_assets/icon-baseline.txt diff --git a/tests/icon_assets/icon-baseline.txt b/tests/icon_assets/icon-baseline.txt deleted file mode 100644 index 01a3f8f15..000000000 --- a/tests/icon_assets/icon-baseline.txt +++ /dev/null @@ -1,9 +0,0 @@ -# Icon rule baseline (ratchet). -# -# One violation per line: . -# Rules: xml, viewbox, editor-metadata, hardcoded-color, color-filename, no-token, physical-size, raster. -# -# This file may only shrink. When an icon is fixed, delete its line(s) in the -# same change; the test fails while a fixed entry remains. Never add new lines. -# -# The UI glyph set is fully migrated; this list is intentionally empty. diff --git a/tests/icon_assets/icon_assets_test.cpp b/tests/icon_assets/icon_assets_test.cpp index 93681d59c..f0b06d5cc 100644 --- a/tests/icon_assets/icon_assets_test.cpp +++ b/tests/icon_assets/icon_assets_test.cpp @@ -13,18 +13,12 @@ namespace { const QString ICONS_RELATIVE_PATH = QStringLiteral("cockatrice/resources/icons"); -const QString BASELINE_RELATIVE_PATH = QStringLiteral("tests/icon_assets/icon-baseline.txt"); QString iconsDir() { return QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(ICONS_RELATIVE_PATH); } -QString baselinePath() -{ - return QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(BASELINE_RELATIVE_PATH); -} - /** @brief True for content art that is allowed to carry full color. */ bool isContentArt(const QString &relativePath) { @@ -220,26 +214,6 @@ QSet collectViolations() return violations; } -/** @brief Parses the ratchet file; returns false with an error when it cannot be read. */ -bool readBaseline(QSet &entries, QString &error) -{ - QFile file(baselinePath()); - if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { - error = QStringLiteral("Cannot read %1").arg(baselinePath()); - return false; - } - - const QStringList lines = QString::fromUtf8(file.readAll()).split(QLatin1Char('\n')); - for (const QString &line : lines) { - const QString trimmed = line.trimmed(); - if (trimmed.isEmpty() || trimmed.startsWith(QLatin1Char('#'))) { - continue; - } - entries.insert(trimmed); - } - return true; -} - QString formatSet(const QSet &entries) { QStringList lines(entries.begin(), entries.end()); @@ -250,44 +224,18 @@ QString formatSet(const QSet &entries) } // namespace /** - * @brief The icon ratchet: no new violations, and fixed icons must leave the baseline. + * @brief Every UI glyph follows the palette-token rules. * - * Existing UI glyphs are grandfathered in tests/icon_assets/icon-baseline.txt. Fixing an icon - * without deleting its baseline lines fails on purpose, so the list can only shrink. + * The legacy set was paid down icon by icon, so no baseline grandfathers + * violations: a new violation fails right here. Scheme variants + * (`*-dark.svg`/`*-light.svg`) and mana art are exempt inside collectViolations(). */ -TEST(IconAssets, BaselineRatchet) +TEST(IconAssets, AssetsFollowTheRules) { ASSERT_TRUE(QDir::isAbsolutePath(iconsDir())); ASSERT_TRUE(QDir(iconsDir()).exists()) << "Missing icon directory: " << iconsDir().toStdString(); - QSet baseline; - QString error; - ASSERT_TRUE(readBaseline(baseline, error)) << error.toStdString(); - - const QSet actual = collectViolations(); - - const QSet newViolations = actual - baseline; - const QSet staleBaseline = baseline - actual; - - EXPECT_TRUE(newViolations.isEmpty()) << "New icon rule violations. Fix the icon; do not add exceptions:\n" - << formatSet(newViolations).toStdString(); - - EXPECT_TRUE(staleBaseline.isEmpty()) << "These baseline entries are fixed or gone; delete them from " - << BASELINE_RELATIVE_PATH.toStdString() << ":\n" - << formatSet(staleBaseline).toStdString(); -} - -/** @brief Guards against duplicate or malformed baseline entries. */ -TEST(IconAssets, BaselineIsWellFormed) -{ - QSet baseline; - QString error; - ASSERT_TRUE(readBaseline(baseline, error)) << error.toStdString(); - EXPECT_TRUE(baseline.isEmpty()) << "The legacy baseline is fully paid down; fix new violations instead"; - - static const QRegularExpression entryPattern(QStringLiteral( - R"(^(xml|viewbox|editor-metadata|hardcoded-color|color-filename|no-token|physical-size|raster) \S+$)")); - for (const QString &entry : baseline) { - EXPECT_TRUE(entryPattern.match(entry).hasMatch()) << "Malformed baseline entry: " << entry.toStdString(); - } + const QSet violations = collectViolations(); + EXPECT_TRUE(violations.isEmpty()) << "Icon rule violations. Fix the icon; do not add exceptions:\n" + << formatSet(violations).toStdString(); } From 70f9947c0f62175497d2db282cc4f4433bd25c88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Sun, 1 Nov 2026 18:19:37 +0100 Subject: [PATCH 106/108] [Settings] Delete update channels at shutdown SettingsCache owns the stable and beta update channels as raw pointers, and each owns a QNetworkAccessManager, so the process leaves QtNetwork objects alive past QApplication teardown. Delete them from a QCoreApplication::aboutToQuit handler instead of the destructor: SettingsCache is a Q_GLOBAL_STATIC, so ~SettingsCache() runs during static teardown, after QApplication is gone, and destroying the network managers at that point is the post-application window that ASan reports. The hook is installed on the first instance() call that has an application object, since the first access may predate it. --- .../src/client/settings/cache_settings.cpp | 28 ++++++++++++++++++- .../src/client/settings/cache_settings.h | 5 ++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/cockatrice/src/client/settings/cache_settings.cpp b/cockatrice/src/client/settings/cache_settings.cpp index 08eccf1e4..b40e26377 100644 --- a/cockatrice/src/client/settings/cache_settings.cpp +++ b/cockatrice/src/client/settings/cache_settings.cpp @@ -6,6 +6,7 @@ #include "shortcuts_settings.h" #include +#include #include #include #include @@ -47,7 +48,32 @@ Q_GLOBAL_STATIC(SettingsCache, settingsCache) SettingsCache &SettingsCache::instance() { - return *settingsCache; // returns a QT managed singleton reference + SettingsCache &cache = *settingsCache; // returns a QT managed singleton reference + // The release channels own QNetworkAccessManagers, which must be destroyed + // while the application object is alive. ~SettingsCache() runs during + // static teardown, after QApplication is gone, so hook the application's + // shutdown instead. Retried here because the first access may predate the + // application object. + cache.installShutdownHook(); + return cache; +} + +void SettingsCache::installShutdownHook() +{ + if (shutdownHookInstalled) { + return; + } + QCoreApplication *application = QCoreApplication::instance(); + if (application == nullptr) { + return; + } + connect(application, &QCoreApplication::aboutToQuit, this, [this] { + for (ReleaseChannel *releaseChannel : std::as_const(releaseChannels)) { + delete releaseChannel; + } + releaseChannels.clear(); + }); + shutdownHookInstalled = true; } QString SettingsCache::getDataPath() diff --git a/cockatrice/src/client/settings/cache_settings.h b/cockatrice/src/client/settings/cache_settings.h index 9aeb88a18..8ccf6f76b 100644 --- a/cockatrice/src/client/settings/cache_settings.h +++ b/cockatrice/src/client/settings/cache_settings.h @@ -90,8 +90,13 @@ private: QString themeName; QList releaseChannels; + bool shutdownHookInstalled = false; bool isPortableBuild; + // Connects the aboutToQuit handler that deletes the release channels while + // the application object is still alive; a no-op before it exists. + void installShutdownHook(); + [[nodiscard]] QString getSafeConfigPath(QString configEntry, QString defaultPath) const; [[nodiscard]] QString getSafeConfigFilePath(QString configEntry, QString defaultPath) const; void loadPaths(); From 2dcf1f2c69270d9de33d6dc3525b1e035c9a7d65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Sun, 1 Nov 2026 18:19:37 +0100 Subject: [PATCH 107/108] [Game] Follow the palette in judge message tags Judge tags hardcoded a black prefix and a `theme:icons/scales` image inside rich text, where `currentColor` cannot resolve and a palette change leaves the tag unreadable. Render the scales glyph through the themed icon engine, register it as a document image resource, and repaint the log when the palette or theme changes. --- .../game_graphics/log/message_log_widget.cpp | 32 +++++++++++++++++-- .../game_graphics/log/message_log_widget.h | 2 ++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/cockatrice/src/game_graphics/log/message_log_widget.cpp b/cockatrice/src/game_graphics/log/message_log_widget.cpp index 046a1ae90..1a5e58682 100644 --- a/cockatrice/src/game_graphics/log/message_log_widget.cpp +++ b/cockatrice/src/game_graphics/log/message_log_widget.cpp @@ -4,6 +4,8 @@ #include "../../client/settings/card_counter_settings.h" #include "../../client/sound_engine.h" #include "../../interface/card_localization.h" +#include "../../interface/theme_icon.h" +#include "../../interface/theme_manager.h" #include "../../interface/widgets/server/chat_view/chat_view.h" #include "../board/card_item.h" #include "../board/translate_counter_name.h" @@ -12,8 +14,12 @@ #include #include #include +#include +#include #include #include +#include +#include #include #include #include @@ -120,6 +126,7 @@ MessageLogWidget::getFromStr(CardZoneLogic *zone, QString cardName, int position void MessageLogWidget::containerProcessingDone() { currentContext = MessageContext_None; + contextJudgeName.clear(); messageSuffix = messagePrefix = QString(); } @@ -841,8 +848,27 @@ void MessageLogWidget::logUndoDrawFailed(PlayerLogic *player) void MessageLogWidget::setContextJudgeName(QString name) { - messagePrefix = QString(""); - messageSuffix = QString(" [ %1]").arg(sanitizeHtml(name)); + contextJudgeName = std::move(name); + refreshJudgeSuffix(); +} + +void MessageLogWidget::refreshJudgeSuffix() +{ + const QPixmap pixmap = loadThemedIcon(QStringLiteral("icons/scales")).pixmap(QSize(24, 24)); + if (!pixmap.isNull()) { + // Rich text cannot resolve `currentColor`; register a palette-rendered + // image so already-appended judge tags repaint on a theme switch. + document()->addResource(QTextDocument::ImageResource, QUrl(QStringLiteral("cockatrice:scales")), pixmap); + document()->markContentsDirty(0, document()->characterCount()); + } + + if (contextJudgeName.isEmpty()) { + return; + } + + messagePrefix = QStringLiteral("").arg(palette().color(QPalette::WindowText).name()); + messageSuffix = + QStringLiteral(" [ %1]").arg(sanitizeHtml(contextJudgeName)); } void MessageLogWidget::appendHtmlServerMessage(const QString &html, bool optionalIsBold, QString optionalFontColor) @@ -891,4 +917,6 @@ void MessageLogWidget::connectToPlayerEventHandler(PlayerEventHandler *playerEve MessageLogWidget::MessageLogWidget(TabSupervisor *_tabSupervisor, AbstractGame *_game, QWidget *parent) : ChatView(_tabSupervisor, _game, true, parent), currentContext(MessageContext_None) { + connect(themeManager, &ThemeManager::paletteChanged, this, &MessageLogWidget::refreshJudgeSuffix); + connect(themeManager, &ThemeManager::themeChanged, this, &MessageLogWidget::refreshJudgeSuffix); } diff --git a/cockatrice/src/game_graphics/log/message_log_widget.h b/cockatrice/src/game_graphics/log/message_log_widget.h index 5642a76e9..da4fbd4e1 100644 --- a/cockatrice/src/game_graphics/log/message_log_widget.h +++ b/cockatrice/src/game_graphics/log/message_log_widget.h @@ -39,8 +39,10 @@ private: MessageContext currentContext; QString messagePrefix, messageSuffix; + QString contextJudgeName; static QPair getFromStr(CardZoneLogic *zone, QString cardName, int position, bool ownerChange); + void refreshJudgeSuffix(); public: void connectToPlayerEventHandler(PlayerEventHandler *player); From 8fcf04d42de044286207fadbc96e377bb98692a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Tue, 20 Oct 2026 12:59:54 +0200 Subject: [PATCH 108/108] [PrintingSelector] Load the pin badge through the themed icon engine The badge rendered `theme:icons/pin` through the pixmap generator, which resolved `currentColor` once and cached the result, so the glyph stayed at its first palette and was rasterized at DPR 1. - Render the glyph through `loadThemedIcon`, at the label's device pixel ratio, and re-render it on palette and theme changes. - Compose it onto a rounded backdrop filled with `Window` and outlined with `WindowText`. The badge floats on card art rather than on a palette surface, so a bare outline vanishes into art of the same lightness; the backdrop keeps it readable on every printing, and both roles still follow the palette. --- .../printing_selector_card_overlay_widget.cpp | 90 ++++++++++++++----- 1 file changed, 67 insertions(+), 23 deletions(-) diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp index 94acdbdce..a5177a6bc 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp @@ -2,6 +2,8 @@ #include "../../../client/settings/cache_settings.h" #include "../../card_picture_loader/card_picture_loader.h" +#include "../../theme_icon.h" +#include "../../theme_manager.h" #include "../cards/card_info_picture_widget.h" #include "../tabs/abstract_tab_deck_editor.h" #include "all_zones_card_amount_widget.h" @@ -12,16 +14,19 @@ #include #include +#include #include #include #include #include -#include #include #include #include #include #include +#include +#include +#include #include #include #include @@ -36,7 +41,6 @@ #include #include #include -#include #include #include #include @@ -120,6 +124,12 @@ PrintingSelectorCardOverlayWidget::PrintingSelectorCardOverlayWidget(QWidget *pa mainLayout->addWidget(cardInfoPicture); initializePinBadge(); + auto refreshPinBadge = [this] { + initializePinBadge(); + updatePinBadgeVisibility(); + }; + connect(themeManager, &ThemeManager::paletteChanged, this, refreshPinBadge); + connect(themeManager, &ThemeManager::themeChanged, this, refreshPinBadge); // Parent the preview to this overlay so it is destroyed with it (Qt::ToolTip keeps // it a frameless, non-activating top-level window despite the parent). @@ -401,10 +411,55 @@ void PrintingSelectorCardOverlayWidget::customMenu(QPoint point) } /** - * @brief Initializes the pin badge overlay and loads its icon with DPI-aware rasterization. + * @brief Composes the pin badge: the glyph over a rounded palette backdrop. * - * The icon is rasterized once using the label's device pixel ratio so it stays crisp on HiDPI - * displays. The resulting pixmap is cached on the QLabel and simply shown/hidden when needed. + * The badge floats on card art rather than on a palette surface, so a bare glyph + * colored WindowText is not enough: a thin dark stroke disappears on dark art in a + * light palette, and a thin light stroke disappears on light art in a dark palette. + * A Window-filled rounded rect with a WindowText outline gives the glyph a solid + * backing, and the outline guarantees the badge edge contrasts with the art even when + * the fill matches it. Both roles follow the palette, like the glyph. + * + * @param logicalSize The badge's size in logical pixels. + * @param devicePixelRatio The label's device pixel ratio, applied to the pixmap so it + * stays crisp on HiDPI displays. + * @param palette The palette the glyph's WindowText resolves against. + */ +static QPixmap composePinBadgePixmap(const QSize &logicalSize, qreal devicePixelRatio, const QPalette &palette) +{ + // Inset the glyph so the backdrop reads as a badge around it, not as a border. + const int inset = qRound(logicalSize.width() * 0.16); + const QSize glyphSize = logicalSize - QSize(inset * 2, inset * 2); + const QPixmap glyph = loadThemedIcon(QStringLiteral("icons/pin")).pixmap(glyphSize, devicePixelRatio); + if (glyph.isNull()) { + // Let the caller fall back to the textual badge instead of drawing an empty panel. + return QPixmap(); + } + + QPixmap badge(logicalSize * devicePixelRatio); + badge.setDevicePixelRatio(devicePixelRatio); + badge.fill(Qt::transparent); + + QPainter painter(&badge); + painter.setRenderHint(QPainter::Antialiasing); + + // Half-pixel inset keeps the 1.5px outline inside the pixmap instead of clipping it. + const QRectF box(QPointF(0.5, 0.5), QSizeF(logicalSize) - QSizeF(1, 1)); + painter.setPen(QPen(palette.color(QPalette::WindowText), 1.5)); + painter.setBrush(palette.color(QPalette::Window)); + painter.drawRoundedRect(box, 8, 8); + painter.drawPixmap(QPointF(inset, inset), glyph); + + return badge; +} + +/** + * @brief Initializes the pin badge overlay and loads a DPI-aware icon pixmap. + * + * The glyph is drawn onto a rounded palette backdrop so the badge stays readable on + * card art, and it is rasterized at the label's device pixel ratio so it stays crisp on + * HiDPI displays. It is re-rendered when the palette or theme changes so the badge + * follows the surrounding UI instead of freezing at its first colors. * If the SVG cannot be read, a textual fallback badge is created instead. */ void PrintingSelectorCardOverlayWidget::initializePinBadge() @@ -420,24 +475,13 @@ void PrintingSelectorCardOverlayWidget::initializePinBadge() } bool pinLoaded = false; - QImageReader pinReader(QStringLiteral("theme:icons/pin")); - - if (pinReader.canRead()) { - // Rasterize a 64×64 logical icon so it has a consistent size regardless of card scaling. - const QSize targetSize(64, 64); - const qreal dpr = pinBadge->devicePixelRatioF(); - const QSize rasterSize(qMax(1, qCeil(targetSize.width() * dpr)), qMax(1, qCeil(targetSize.height() * dpr))); - pinReader.setScaledSize(rasterSize); - const QImage pinImage = pinReader.read(); - if (!pinImage.isNull()) { - // Tag the pixmap with the same DPR so Qt renders it at the correct physical size. - QPixmap pinPix = QPixmap::fromImage(pinImage); - pinPix.setDevicePixelRatio(dpr); - pinBadge->setPixmap(pinPix); - pinBadge->setFixedSize(targetSize); - pinBadge->setStyleSheet(QStringLiteral("background: transparent;")); - pinLoaded = true; - } + const QSize targetSize(64, 64); + const QPixmap pinPix = composePinBadgePixmap(targetSize, pinBadge->devicePixelRatioF(), pinBadge->palette()); + if (!pinPix.isNull()) { + pinBadge->setPixmap(pinPix); + pinBadge->setFixedSize(targetSize); + pinBadge->setStyleSheet(QStringLiteral("background: transparent;")); + pinLoaded = true; } if (!pinLoaded) {