diff --git a/libcockatrice_card/libcockatrice/card/format/format_legality_rules.cpp b/libcockatrice_card/libcockatrice/card/format/format_legality_rules.cpp index 64785759e..c6c481db2 100644 --- a/libcockatrice_card/libcockatrice/card/format/format_legality_rules.cpp +++ b/libcockatrice_card/libcockatrice/card/format/format_legality_rules.cpp @@ -45,12 +45,23 @@ bool exceptionAppliesToCard(const CardInfo &card, const ExceptionRule &rule) return true; } -bool cardHasAnyException(const CardInfo &card, const FormatRules &format) +std::optional cardCopyLimitException(const CardInfo &card, const FormatRules &format) { + std::optional limit; for (const ExceptionRule &rule : format.exceptions) { if (exceptionAppliesToCard(card, rule)) { - return true; + if (rule.maxCopies == -1) { + return -1; + } + if (rule.maxCopies >= 0 && (!limit || rule.maxCopies > *limit)) { + limit = rule.maxCopies; + } } } - return false; -} \ No newline at end of file + return limit; +} + +bool cardHasAnyException(const CardInfo &card, const FormatRules &format) +{ + return cardCopyLimitException(card, format).has_value(); +} diff --git a/libcockatrice_card/libcockatrice/card/format/format_legality_rules.h b/libcockatrice_card/libcockatrice/card/format/format_legality_rules.h index 15ba46728..ccbcd7779 100644 --- a/libcockatrice_card/libcockatrice/card/format/format_legality_rules.h +++ b/libcockatrice_card/libcockatrice/card/format/format_legality_rules.h @@ -4,6 +4,7 @@ #include #include #include +#include class CardInfo; @@ -74,6 +75,9 @@ bool cardMatchesCondition(const CardInfo &card, const CardCondition &cond); bool exceptionAppliesToCard(const CardInfo &card, const ExceptionRule &rule); +/** @brief Most permissive matching copy-count exception, or no value when none applies. */ +std::optional cardCopyLimitException(const CardInfo &card, const FormatRules &format); + bool cardHasAnyException(const CardInfo &card, const FormatRules &format); #endif // COCKATRICE_FORMAT_LEGALITY_RULES_H 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 f3c4545d7..0916d3016 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp @@ -11,6 +11,7 @@ #include "libcockatrice/deck_list/tree/abstract_deck_list_node.h" #include "libcockatrice/deck_list/tree/deck_list_card_node.h" +#include #include #include #include @@ -327,7 +328,7 @@ void DeckListModel::emitBackgroundUpdates(const QModelIndex &parent) QModelIndex topLeft = index(0, 0, parent); QModelIndex bottomRight = index(rows - 1, columnCount() - 1, parent); - emit dataChanged(topLeft, bottomRight, {Qt::BackgroundRole}); + emit dataChanged(topLeft, bottomRight, {Qt::BackgroundRole, DeckRoles::IsLegalRole}); for (int r = 0; r < rows; ++r) { QModelIndex child = index(r, 0, parent); @@ -355,7 +356,6 @@ bool DeckListModel::setData(const QModelIndex &index, const QVariant &value, con switch (index.column()) { case DeckListModelColumns::CARD_AMOUNT: node->setNumber(value.toInt()); - refreshCardFormatLegalities(); break; case DeckListModelColumns::CARD_NAME: node->setName(value.toString()); @@ -373,6 +373,7 @@ bool DeckListModel::setData(const QModelIndex &index, const QVariant &value, con return false; } + refreshCardFormatLegalities(); emitRecursiveUpdates(index); deckList->refreshDeckHash(); emit deckHashChanged(); @@ -415,6 +416,7 @@ bool DeckListModel::removeRows(int row, int count, const QModelIndex &parent) emitRecursiveUpdates(parent); } + refreshCardFormatLegalities(); deckList->refreshDeckHash(); emit deckHashChanged(); @@ -818,7 +820,6 @@ void DeckListModel::setActiveFormat(const QString &_format) { deckList->setGameFormat(_format); refreshCardFormatLegalities(); - emitBackgroundUpdates(QModelIndex()); // start from root } void DeckListModel::cleanList() @@ -894,17 +895,17 @@ QStringList DeckListModel::getCustomZoneNames(const QString &boardZoneName) cons return zoneNames; } -static int maxAllowedForLegality(const FormatRules &format, const QString &legality) +static std::optional maxAllowedForLegality(const FormatRules &format, const QString &legality) { for (const AllowedCount &c : format.allowedCounts) { if (c.label == legality) { return c.max; } } - return -1; // unknown legality → treat as illegal + return std::nullopt; // Unknown legality is distinct from an unlimited count. } -static bool isCardQuantityLegalForFormat(const QString &format, const CardInfo &cardInfo, int quantity) +static bool isCardQuantityLegalForFormat(const QString &format, const CardInfo &cardInfo, qint64 quantity) { if (format.isEmpty()) { return true; @@ -917,9 +918,9 @@ static bool isCardQuantityLegalForFormat(const QString &format, const CardInfo & return cardInfo.isLegalInFormat(format); } - // Exceptions always win - if (cardHasAnyException(cardInfo, *formatRules)) { - return true; + // Explicit format exceptions retain precedence, including finite limits. + if (const auto limit = cardCopyLimitException(cardInfo, *formatRules)) { + return *limit == -1 || quantity <= *limit; } // check legality prop @@ -928,42 +929,37 @@ static bool isCardQuantityLegalForFormat(const QString &format, const CardInfo & return false; } - int maxAllowed = maxAllowedForLegality(*formatRules, legality); - - if (maxAllowed == -1) { - return false; - } - - if (maxAllowed < 0) { // unlimited - return true; - } - - return quantity <= maxAllowed; -} - -static bool isCardNodeLegalForFormat(const QString &format, const InnerDecklistNode *zone, const DecklistCardNode *card) -{ - // Don't check legality for tokens - if (zone->getName() == DECK_ZONE_TOKENS) { - return true; - } - - // unknown cards are not legal - ExactCard exactCard = CardDatabaseManager::query()->getCard(card->toCardRef()); - if (!exactCard) { - return false; - } - - // actual check - return isCardQuantityLegalForFormat(format, exactCard.getInfo(), card->getNumber()); + const auto maxAllowed = maxAllowedForLegality(*formatRules, legality); + return maxAllowed && (*maxAllowed == -1 || (*maxAllowed >= 0 && quantity <= *maxAllowed)); } void DeckListModel::refreshCardFormatLegalities() { - QString format = deckList->getGameFormat(); - - deckList->forEachCard([&format](const InnerDecklistNode *zone, DecklistCardNode *card) { - bool legal = isCardNodeLegalForFormat(format, zone, card); + const QString format = deckList->getGameFormat(); + QHash quantities; + QHash cardInfos; + deckList->forEachCard([&](const InnerDecklistNode *zone, DecklistCardNode *card) { + if (zone->getName() == DECK_ZONE_TOKENS) { + return; + } + const ExactCard exactCard = CardDatabaseManager::query()->getCard(card->toCardRef()); + if (exactCard) { + const auto info = exactCard.getCardPtr(); + cardInfos.insert(card, info); + // The maybeboard is not part of the submitted deck. Count the other + // non-token zones together, including printings and nested custom zones. + if (zone->getName() != DECK_ZONE_MAYBEBOARD) { + quantities[info->getName()] += std::max(0, card->getNumber()); + } + } + }); + deckList->forEachCard([&](const InnerDecklistNode *zone, DecklistCardNode *card) { + const auto info = cardInfos.value(card); + const qint64 quantity = + info && zone->getName() != DECK_ZONE_MAYBEBOARD ? quantities.value(info->getName()) : card->getNumber(); + const bool legal = + zone->getName() == DECK_ZONE_TOKENS || (info && isCardQuantityLegalForFormat(format, *info, quantity)); card->setFormatLegality(legal); }); + emitBackgroundUpdates(QModelIndex()); } diff --git a/oracle/src/oracleimporter.cpp b/oracle/src/oracleimporter.cpp index c83c962e3..c4c635494 100644 --- a/oracle/src/oracleimporter.cpp +++ b/oracle/src/oracleimporter.cpp @@ -299,6 +299,7 @@ CardInfoPtr OracleImporter::addCard(QString name, qDebug() << "warning: an empty card was added to set" << printingInfo.getSet()->getShortName(); } cards.insert(name, newCard); + magicFormatsDirty = true; return newCard; } @@ -760,8 +761,38 @@ static FormatRulesNameMap buildDefaultMagicFormats() const FormatRulesNameMap &OracleImporter::createDefaultMagicFormats() { - static const FormatRulesNameMap cached = buildDefaultMagicFormats(); - return cached; + static const FormatRulesNameMap defaults = buildDefaultMagicFormats(); + if (!magicFormatsDirty) { + return magicFormats; + } + + // Sort by name so saved rules are independent of QHash iteration order. + QMap finiteLimits; + for (const auto &card : cards) { + if (const auto limit = parseDeckCopyLimit(card->getName(), card->getText())) { + finiteLimits.insert(card->getName(), *limit); + } + } + magicFormats = defaults; + if (!finiteLimits.isEmpty()) { + for (auto it = magicFormats.begin(); it != magicFormats.end(); ++it) { + // Copy the value before appending: defaults and other importer + // instances retain their existing shared FormatRules objects. + FormatRulesPtr format(new FormatRules(*it.value())); + for (auto limit = finiteLimits.cbegin(); limit != finiteLimits.cend(); ++limit) { + ExceptionRule exception; + exception.conditions.append({"name", "equals", limit.key()}); + // A card's copy allowance does not make it legal in a format + // where it is banned, restricted or outside the card pool. + exception.conditions.append({"format-" + it.key(), "equals", "legal"}); + exception.maxCopies = limit.value(); + format->exceptions.append(exception); + } + it.value() = format; + } + } + magicFormatsDirty = false; + return magicFormats; } int OracleImporter::startImport() @@ -772,6 +803,8 @@ int OracleImporter::startImport() importError.clear(); sets.clear(); cards.clear(); + magicFormats.clear(); + magicFormatsDirty = true; localizedEntries.clear(); splitLocalizedTexts.clear(); if (allSets.isEmpty()) { @@ -859,6 +892,8 @@ int OracleImporter::failImport(const QString &message) importError = message; sets.clear(); cards.clear(); + magicFormats.clear(); + magicFormatsDirty = true; localizedEntries.clear(); splitLocalizedTexts.clear(); qWarning() << message; @@ -886,6 +921,8 @@ void OracleImporter::clear() prepareImport(); sets.clear(); cards.clear(); + magicFormats.clear(); + magicFormatsDirty = true; allSets.clear(); rawSetsData.clear(); localizedEntries.clear(); diff --git a/oracle/src/oracleimporter.h b/oracle/src/oracleimporter.h index 5ebe9cf36..3275befd6 100644 --- a/oracle/src/oracleimporter.h +++ b/oracle/src/oracleimporter.h @@ -170,6 +170,10 @@ private: */ CardNameMap cards; + // Shared defaults are immutable; card-specific copy limits belong to this import. + FormatRulesNameMap magicFormats; + bool magicFormatsDirty = true; + /** * The sets, indexed by short name. */ @@ -321,7 +325,8 @@ public: bool saveToFile(const QString &fileName, const QString &sourceUrl, const QString &sourceVersion); int importCardsFromSet(const CardSetPtr ¤tSet, const QJsonArray &cardsList); /** - * @brief Returns the default format rules. The result is memoized on first use and must be treated as immutable. + * @brief Returns format rules including finite copy allowances from imported cards. + * The result is cached until cards are added or cleared and must be treated as immutable. */ const FormatRulesNameMap &createDefaultMagicFormats(); const CardNameMap &getCardList() const diff --git a/oracle/src/parsehelpers.cpp b/oracle/src/parsehelpers.cpp index 2c897031f..078617782 100644 --- a/oracle/src/parsehelpers.cpp +++ b/oracle/src/parsehelpers.cpp @@ -71,3 +71,52 @@ bool parseCipt(const QString &name, const QString &text) return ciptPattern.match(text).hasMatch(); } + +std::optional parseDeckCopyLimit(const QString &name, const QString &text) +{ + // Require a complete, standalone allowance. Other effects that mention a + // number of cards or a different card name must not change deck construction. + static const QRegularExpression rule( + R"(^A deck can have up to ([a-z]+(?:[ -][a-z]+)*|[0-9]+) cards? named ([^\r\n]+)\.\r?$)", + QRegularExpression::MultilineOption); + static const QStringList smallNumbers = { + "zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", + "ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen", "sixteen", "seventeen", "eighteen", "nineteen"}; + static const QStringList tens = {"twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety"}; + auto normalizedName = [](QString cardName) { + return cardName.replace("Æ", "AE").replace("’", "'").normalized(QString::NormalizationForm_C); + }; + const QString expectedName = normalizedName(name); + if (expectedName.isEmpty()) { + return std::nullopt; + } + std::optional limit; + auto matches = rule.globalMatch(text); + while (matches.hasNext()) { + const auto match = matches.next(); + if (normalizedName(match.captured(2)) != expectedName) { + continue; + } + const QString count = match.captured(1); + bool numeric = false; + int value = count.toInt(&numeric); + if (!numeric) { + // English words through ninety-nine; larger counts remain supported + // as decimal numerals. Unknown wording is deliberately not guessed. + const QStringList words = QString(count).replace('-', ' ').split(' '); + value = words.size() == 1 ? smallNumbers.indexOf(words.first()) : -1; + const int ten = tens.indexOf(words.first()); + if (ten >= 0 && words.size() <= 2) { + const int unit = words.size() == 1 ? 0 : smallNumbers.indexOf(words.last()); + if (unit >= 0 && unit < 10 && (words.size() == 1 || unit > 0)) { + value = (ten + 2) * 10 + unit; + } + } + } + if (value <= 0 || (limit && *limit != value)) { + return std::nullopt; + } + limit = value; + } + return limit; +} diff --git a/oracle/src/parsehelpers.h b/oracle/src/parsehelpers.h index 40a36c753..0a48e9dfd 100644 --- a/oracle/src/parsehelpers.h +++ b/oracle/src/parsehelpers.h @@ -2,6 +2,10 @@ #define PARSEHELPERS_H #include +#include + +/** @brief Read a finite deck-copy allowance naming this card in canonical English rules text. */ +std::optional parseDeckCopyLimit(const QString &name, const QString &text); bool parseCipt(const QString &name, const QString &text); diff --git a/tests/deck_list_model/deck_list_model_zone_integration_test.cpp b/tests/deck_list_model/deck_list_model_zone_integration_test.cpp index 63c904910..9a381af7b 100644 --- a/tests/deck_list_model/deck_list_model_zone_integration_test.cpp +++ b/tests/deck_list_model/deck_list_model_zone_integration_test.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -289,6 +290,131 @@ TEST(DeckListModelZoneIntegration, AddCardToCustomZoneWithNestedSubZoneAppends) ASSERT_TRUE(added.parent().data(DeckRoles::IsCustomZoneRole).toBool()); } +class DeckListModelCopyLimits : public testing::Test +{ +protected: + CardDatabase *database = CardDatabaseManager::getInstance(); + FormatRulesPtr format; + + void SetUp() override + { + database->clear(); + format.reset(new FormatRules); + format->formatName = "copy-limit-test"; + format->allowedCounts = {{4, "legal"}, {1, "restricted"}, {0, "banned"}}; + database->addFormat(format); + } + + void TearDown() override + { + database->clear(); + } + + CardInfoPtr addCard(const QString &name, const QString &legality = "legal") + { + auto card = CardInfo::newInstance(name); + card->setProperty("format-copy-limit-test", legality); + database->addCard(card); + return card; + } + + QSharedPointer newDeck() + { + auto deck = QSharedPointer::create(); + deck->setGameFormat(format->formatName); + return deck; + } +}; + +TEST_F(DeckListModelCopyLimits, FiniteExceptionHonorsItsLimitAndRetainsExplicitPrecedence) +{ + const QString name = "Seven Dwarves"; + addCard(name, "banned"); // Explicit format exceptions retain the precedence established in #6535. + format->exceptions.append({{{"name", "equals", name}}, 7}); + auto deck = newDeck(); + auto *row = deck->getTree()->addCard(name, 7, DECK_ZONE_MAIN, -1); + DeckListModel model(nullptr, deck); + EXPECT_TRUE(row->getFormatLegality()); + row->setNumber(8); + model.rebuildTree(); + EXPECT_FALSE(row->getFormatLegality()); +} + +TEST_F(DeckListModelCopyLimits, CopiesAcrossPrintingsSideboardAndNestedZonesShareOneLimit) +{ + addCard("Same card"); + auto deck = newDeck(); + auto *tree = deck->getTree(); + auto *first = tree->addCard("Same card", 2, DECK_ZONE_MAIN, -1, "ONE", "1", "printing-one"); + auto *second = tree->addCard("Same card", 2, DECK_ZONE_SIDE, -1, "TWO", "2", "printing-two"); + auto *custom = tree->addCustomZone(DECK_ZONE_MAIN, "Removal"); + auto *nested = new InnerDecklistNode("Nested", custom); + auto *third = new DecklistCardNode("Same card", 1, nested, -1); + auto *token = tree->addCard("Same card", 100, DECK_ZONE_TOKENS, -1); + auto *maybe = tree->addCard("Same card", 4, DECK_ZONE_MAYBEBOARD, -1); + DeckListModel model(nullptr, deck); + EXPECT_FALSE(first->getFormatLegality()); + EXPECT_FALSE(second->getFormatLegality()); + EXPECT_FALSE(third->getFormatLegality()); + EXPECT_TRUE(token->getFormatLegality()); + EXPECT_TRUE(maybe->getFormatLegality()); + third->setNumber(0); + model.rebuildTree(); + EXPECT_TRUE(first->getFormatLegality()); + EXPECT_TRUE(second->getFormatLegality()); +} + +TEST_F(DeckListModelCopyLimits, EditingOrRemovingOneRowRefreshesTheOtherRows) +{ + addCard("Same card"); + auto deck = newDeck(); + auto *first = deck->getTree()->addCard("Same card", 2, DECK_ZONE_MAIN, -1); + auto *second = deck->getTree()->addCard("Same card", 2, DECK_ZONE_SIDE, -1); + DeckListModel model(nullptr, deck); + const auto index = model.findCard("Same card", DECK_ZONE_SIDE); + ASSERT_TRUE(index.isValid()); + ASSERT_TRUE(model.setData(index.siblingAtColumn(DeckListModelColumns::CARD_AMOUNT), 3, Qt::EditRole)); + EXPECT_FALSE(first->getFormatLegality()); + EXPECT_FALSE(second->getFormatLegality()); + ASSERT_TRUE(model.removeRows(index.row(), 1, index.parent())); + EXPECT_TRUE(first->getFormatLegality()); +} + +TEST_F(DeckListModelCopyLimits, NegativeRowsCannotCancelCopiesElsewhere) +{ + addCard("Same card"); + auto deck = newDeck(); + auto *first = deck->getTree()->addCard("Same card", 5, DECK_ZONE_MAIN, -1); + auto *negative = deck->getTree()->addCard("Same card", -1, DECK_ZONE_SIDE, -1); + DeckListModel model(nullptr, deck); + EXPECT_FALSE(first->getFormatLegality()); + EXPECT_FALSE(negative->getFormatLegality()); +} + +TEST_F(DeckListModelCopyLimits, UnlimitedCountIsDistinctFromUnknownLegality) +{ + addCard("Unlimited"); + addCard("Unknown", "not-a-status"); + format->allowedCounts = {{-1, "legal"}}; + auto deck = newDeck(); + auto *unlimited = deck->getTree()->addCard("Unlimited", 100, DECK_ZONE_MAIN, -1); + auto *unknown = deck->getTree()->addCard("Unknown", 1, DECK_ZONE_MAIN, -1); + DeckListModel model(nullptr, deck); + EXPECT_TRUE(unlimited->getFormatLegality()); + EXPECT_FALSE(unknown->getFormatLegality()); +} + +TEST_F(DeckListModelCopyLimits, MostPermissiveExceptionWinsIncludingUnlimited) +{ + const auto card = addCard("Exception"); + const QList conditions{{"name", "equals", "Exception"}}; + format->exceptions = {{conditions, 7}, {conditions, 9}}; + EXPECT_EQ(cardCopyLimitException(*card, *format), 9); + format->exceptions.append({conditions, -1}); + EXPECT_EQ(cardCopyLimitException(*card, *format), -1); + EXPECT_FALSE(cardCopyLimitException(*addCard("No exception"), *format)); +} + int main(int argc, char **argv) { ::testing::InitGoogleTest(&argc, argv); diff --git a/tests/oracle/oracle_importer_test.cpp b/tests/oracle/oracle_importer_test.cpp index 259bae087..0e5f86e48 100644 --- a/tests/oracle/oracle_importer_test.cpp +++ b/tests/oracle/oracle_importer_test.cpp @@ -1,5 +1,6 @@ #include "../../oracle/src/database_writer.h" #include "../../oracle/src/oracleimporter.h" +#include "../../oracle/src/parsehelpers.h" #include "../../oracle/src/raw_json_scanner.h" #include "libcockatrice/card/card_info.h" @@ -8,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -27,6 +29,8 @@ #include #include #include +#include +#include #include #include #include @@ -327,6 +331,162 @@ TEST_F(OracleImporterTest, CreateDefaultMagicFormatsCaching) ASSERT_EQ(first.value("standard").data(), second.value("standard").data()); } +TEST(DeckCopyLimitParser, ReadsNamedAllowancesWithoutACardNameList) +{ + EXPECT_EQ(parseDeckCopyLimit("Seven Dwarves", "A deck can have up to seven cards named Seven Dwarves."), 7); + EXPECT_EQ(parseDeckCopyLimit("Nazgûl", + "Deathtouch\nA deck can have up to nine cards named Nazgûl.\nThe Ring tempts you."), + 9); + EXPECT_EQ(parseDeckCopyLimit("Future Card", "A deck can have up to 17 cards named Future Card."), 17); + EXPECT_EQ(parseDeckCopyLimit("Future Card", "A deck can have up to twenty-three cards named Future Card."), 23); + EXPECT_EQ(parseDeckCopyLimit("Future Card", "A deck can have up to ninety nine cards named Future Card."), 99); + EXPECT_EQ(parseDeckCopyLimit("AEther's Ally", "A deck can have up to six cards named Æther’s Ally."), 6); +} + +TEST(DeckCopyLimitParser, RejectsUnknownMalformedAndOtherCardRules) +{ + const QString name = "Example"; + for (const QString &text : + {QString(), QString("A deck can have any number of cards named Example."), + QString("A deck can have up to seven cards named Other Card."), + QString("A deck can have up to seven cards named Example"), + QString("A deck can have up to many cards named Example."), + QString("A deck can have up to zero cards named Example."), + QString("A deck can have up to 0 cards named Example."), + QString("A deck can have up to -7 cards named Example."), + QString("A deck can have up to 99999999999999999999 cards named Example."), + QString("A deck can have up to twenty zero cards named Example."), + QString("If you control a Dragon, A deck can have up to seven cards named Example."), + QString( + "A deck can have up to seven cards named Example.\nA deck can have up to nine cards named Example.")}) { + EXPECT_FALSE(parseDeckCopyLimit(name, text).has_value()) << text.toStdString(); + } +} + +TEST_F(OracleImporterTest, CreateDefaultMagicFormatsDerivesFiniteLimitsFromCanonicalText) +{ + QVariantMap legalities; + const auto defaults = importer->createDefaultMagicFormats(); + for (auto it = defaults.cbegin(); it != defaults.cend(); ++it) { + legalities.insert(it.key(), "Legal"); + } + auto dwarves = makeCard("Seven Dwarves", "", "", legalities); + dwarves["text"] = "Seven Dwarves gets +1/+1 for each other creature named Seven Dwarves you control.\n" + "A deck can have up to seven cards named Seven Dwarves."; + auto nazgul = makeCard("Nazgûl", "", "", legalities); + nazgul["text"] = "Deathtouch\nA deck can have up to nine cards named Nazgûl."; + auto unknown = makeCard("Unknown Card"); + unknown["text"] = "A deck can have up to many cards named Unknown Card."; + auto otherCard = makeCard("Other Card"); + otherCard["text"] = "A deck can have up to seven cards named Seven Dwarves."; + importer->importCardsFromSet(set, QJsonArray{dwarves, nazgul, unknown, otherCard, makeCard("Ordinary Card")}); + + const auto &formats = importer->createDefaultMagicFormats(); + for (const auto &format : formats) { + ASSERT_EQ(format->exceptions.size(), 4); + EXPECT_EQ(format->exceptions[0].maxCopies, -1); + EXPECT_EQ(format->exceptions[1].maxCopies, -1); + for (const auto &expected : {qMakePair(QString("Seven Dwarves"), 7), qMakePair(QString("Nazgûl"), 9)}) { + bool found = false; + for (const auto &rule : format->exceptions) { + if (rule.maxCopies == expected.second) { + ASSERT_EQ(rule.conditions.size(), 2); + EXPECT_EQ(rule.conditions.last().field, "format-" + format->formatName.toLower()); + EXPECT_EQ(rule.conditions.last().matchType, "equals"); + EXPECT_EQ(rule.conditions.last().value, "legal"); + EXPECT_EQ(rule.conditions.first().field, "name"); + EXPECT_EQ(rule.conditions.first().matchType, "equals"); + EXPECT_EQ(rule.conditions.first().value, expected.first); + EXPECT_TRUE(exceptionAppliesToCard(*importer->getCardList().value(expected.first), rule)); + EXPECT_FALSE(exceptionAppliesToCard(*importer->getCardList().value("Other Card"), rule)); + found = true; + } + } + EXPECT_TRUE(found); + } + } +} + +TEST_F(OracleImporterTest, FiniteCopyAllowancesDoNotOverrideFormatEligibility) +{ + const QVariantMap legalities{ + {"standard", "Legal"}, {"modern", "Banned"}, {"vintage", "Restricted"}, {"commander", "Not_Legal"}}; + auto dwarves = makeCard("Seven Dwarves", "", "", legalities); + dwarves["text"] = "A deck can have up to seven cards named Seven Dwarves."; + importer->importCardsFromSet(set, QJsonArray{dwarves}); + const auto &formats = importer->createDefaultMagicFormats(); + const auto card = importer->getCardList().value("Seven Dwarves"); + ASSERT_TRUE(card); + EXPECT_EQ(cardCopyLimitException(*card, *formats.value("standard")), 7); + for (const auto &format : {"modern", "vintage", "commander", "legacy"}) { + EXPECT_FALSE(cardCopyLimitException(*card, *formats.value(format)).has_value()) << format; + } +} + +TEST_F(OracleImporterTest, DerivedFormatCacheRefreshesWithoutChangingSharedDefaults) +{ + OracleImporter otherImporter; + const auto defaults = otherImporter.createDefaultMagicFormats(); + EXPECT_EQ(importer->createDefaultMagicFormats().value("standard"), defaults.value("standard")); + + auto dwarves = makeCard("Seven Dwarves"); + dwarves["text"] = "A deck can have up to seven cards named Seven Dwarves."; + importer->importCardsFromSet(set, QJsonArray{dwarves}); + const auto first = importer->createDefaultMagicFormats(); + EXPECT_NE(first.value("standard"), defaults.value("standard")); + EXPECT_EQ(first.value("standard"), importer->createDefaultMagicFormats().value("standard")); + ASSERT_EQ(first.value("standard")->exceptions.size(), 3); + + auto nazgul = makeCard("Nazgûl"); + nazgul["text"] = "A deck can have up to nine cards named Nazgûl."; + importer->importCardsFromSet(set, QJsonArray{nazgul}); + const auto second = importer->createDefaultMagicFormats(); + EXPECT_EQ(second.value("standard")->exceptions.size(), 4); + EXPECT_EQ(first.value("standard")->exceptions.size(), 3); + EXPECT_EQ(defaults.value("standard")->exceptions.size(), 2); + EXPECT_EQ(otherImporter.createDefaultMagicFormats().value("standard"), defaults.value("standard")); + + importer->clear(); + EXPECT_EQ(importer->createDefaultMagicFormats().value("standard"), defaults.value("standard")); +} + +TEST_F(OracleImporterTest, FiniteCopyRulesSurviveImportSaveAndXmlReload) +{ + const QVariantMap legalities{{"commander", "Legal"}, {"modern", "Not_Legal"}}; + auto dwarves = makeCard("Seven Dwarves", "", "", legalities); + dwarves["text"] = "A deck can have up to seven cards named Seven Dwarves."; + auto nazgul = makeCard("Nazgûl", "", "", legalities); + nazgul["text"] = "A deck can have up to nine cards named Nazgûl."; + const QJsonObject setData{{"code", "TST"}, + {"name", "Test Set"}, + {"type", "expansion"}, + {"releaseDate", "2024-01-01"}, + {"cards", QJsonArray{dwarves, nazgul}}}; + const QJsonObject source{{"data", QJsonObject{{"TST", setData}}}}; + ASSERT_TRUE(importer->readSetsFromByteArray(QJsonDocument(source).toJson())); + ASSERT_EQ(importer->startImport(), 1); + + QTemporaryDir directory; + ASSERT_TRUE(directory.isValid()); + const QString filename = directory.filePath("cards.xml"); + ASSERT_TRUE(importer->saveToFile(filename, "fixture", "1")); + QFile file(filename); + ASSERT_TRUE(file.open(QIODevice::ReadOnly)); + CardDatabaseData restored; + CockatriceXml4Parser parser(controller); + parser.parseFileInto(file, restored); + const auto commander = restored.formats.value("commander"); + const auto modern = restored.formats.value("modern"); + ASSERT_TRUE(commander); + ASSERT_TRUE(modern); + for (const auto &expected : {qMakePair(QString("Seven Dwarves"), 7), qMakePair(QString("Nazgûl"), 9)}) { + const auto card = restored.cards.value(expected.first); + ASSERT_TRUE(card); + EXPECT_EQ(cardCopyLimitException(*card, *commander), expected.second); + EXPECT_FALSE(cardCopyLimitException(*card, *modern).has_value()); + } +} + // ============================================================================ // readSetsFromByteArray tests // ============================================================================