This commit is contained in:
Bryce! 2026-11-04 16:02:56 -05:00 • committed by GitHub
commit f0ca0183b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 440 additions and 48 deletions

View file

@ -45,12 +45,23 @@ bool exceptionAppliesToCard(const CardInfo &card, const ExceptionRule &rule)
return true;
}
bool cardHasAnyException(const CardInfo &card, const FormatRules &format)
std::optional<int> cardCopyLimitException(const CardInfo &card, const FormatRules &format)
{
std::optional<int> 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;
}
return limit;
}
bool cardHasAnyException(const CardInfo &card, const FormatRules &format)
{
return cardCopyLimitException(card, format).has_value();
}

View file

@ -4,6 +4,7 @@
#include <QList>
#include <QSharedPointer>
#include <QString>
#include <optional>
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<int> cardCopyLimitException(const CardInfo &card, const FormatRules &format);
bool cardHasAnyException(const CardInfo &card, const FormatRules &format);
#endif // COCKATRICE_FORMAT_LEGALITY_RULES_H

View file

@ -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 <QHash>
#include <QModelIndexList>
#include <QSet>
#include <QVariant>
@ -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<int> 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<QString, qint64> quantities;
QHash<const DecklistCardNode *, CardInfoPtr> 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());
}

View file

@ -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<QString, int> 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();

View file

@ -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 &currentSet, 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

View file

@ -71,3 +71,52 @@ bool parseCipt(const QString &name, const QString &text)
return ciptPattern.match(text).hasMatch();
}
std::optional<int> 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<int> 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;
}

View file

@ -2,6 +2,10 @@
#define PARSEHELPERS_H
#include <QString>
#include <optional>
/** @brief Read a finite deck-copy allowance naming this card in canonical English rules text. */
std::optional<int> parseDeckCopyLimit(const QString &name, const QString &text);
bool parseCipt(const QString &name, const QString &text);

View file

@ -10,6 +10,7 @@
#include <gtest/gtest.h>
#include <libcockatrice/card/card_info.h>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/card/format/format_legality_rules.h>
#include <libcockatrice/card/game_specific_terms.h>
#include <libcockatrice/card/printing/exact_card.h>
#include <libcockatrice/deck_list/deck_list.h>
@ -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<DeckList> newDeck()
{
auto deck = QSharedPointer<DeckList>::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<CardCondition> 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);

View file

@ -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 <QChar>
#include <QDate>
#include <QFile>
#include <QIODevice>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
@ -27,6 +29,8 @@
#include <QVariant>
#include <QVariantMap>
#include <QtGlobal>
#include <libcockatrice/card/database/card_database_data.h>
#include <libcockatrice/card/database/parser/cockatrice_xml_4.h>
#include <libcockatrice/card/format/format_legality_rules.h>
#include <libcockatrice/card/set/card_set.h>
#include <libcockatrice/interfaces/noop_card_set_priority_controller.h>
@ -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
// ============================================================================