Honor finite deck copy limits across printings and deck zones [skip ci]

This commit is contained in:
Bryce! 2026-11-01 17:37:05 -05:00
parent e08402ac94
commit 142341bc3c
9 changed files with 438 additions and 48 deletions

View file

@ -295,6 +295,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;
}
@ -756,8 +757,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()
@ -863,6 +894,8 @@ void OracleImporter::clear()
{
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.
*/
@ -299,7 +303,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);