mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-06 01:53:25 -08:00
Honor finite deck copy limits across printings and deck zones [skip ci]
This commit is contained in:
parent
e08402ac94
commit
142341bc3c
9 changed files with 438 additions and 48 deletions
|
|
@ -45,12 +45,23 @@ bool exceptionAppliesToCard(const CardInfo &card, const ExceptionRule &rule)
|
||||||
return true;
|
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) {
|
for (const ExceptionRule &rule : format.exceptions) {
|
||||||
if (exceptionAppliesToCard(card, rule)) {
|
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();
|
||||||
}
|
}
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
#include <QList>
|
#include <QList>
|
||||||
#include <QSharedPointer>
|
#include <QSharedPointer>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
|
#include <optional>
|
||||||
|
|
||||||
class CardInfo;
|
class CardInfo;
|
||||||
|
|
||||||
|
|
@ -74,6 +75,9 @@ bool cardMatchesCondition(const CardInfo &card, const CardCondition &cond);
|
||||||
|
|
||||||
bool exceptionAppliesToCard(const CardInfo &card, const ExceptionRule &rule);
|
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);
|
bool cardHasAnyException(const CardInfo &card, const FormatRules &format);
|
||||||
|
|
||||||
#endif // COCKATRICE_FORMAT_LEGALITY_RULES_H
|
#endif // COCKATRICE_FORMAT_LEGALITY_RULES_H
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
#include "libcockatrice/deck_list/tree/abstract_deck_list_node.h"
|
#include "libcockatrice/deck_list/tree/abstract_deck_list_node.h"
|
||||||
#include "libcockatrice/deck_list/tree/deck_list_card_node.h"
|
#include "libcockatrice/deck_list/tree/deck_list_card_node.h"
|
||||||
|
|
||||||
|
#include <QHash>
|
||||||
#include <QModelIndexList>
|
#include <QModelIndexList>
|
||||||
#include <QSet>
|
#include <QSet>
|
||||||
#include <QVariant>
|
#include <QVariant>
|
||||||
|
|
@ -320,7 +321,7 @@ void DeckListModel::emitBackgroundUpdates(const QModelIndex &parent)
|
||||||
|
|
||||||
QModelIndex topLeft = index(0, 0, parent);
|
QModelIndex topLeft = index(0, 0, parent);
|
||||||
QModelIndex bottomRight = index(rows - 1, columnCount() - 1, 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) {
|
for (int r = 0; r < rows; ++r) {
|
||||||
QModelIndex child = index(r, 0, parent);
|
QModelIndex child = index(r, 0, parent);
|
||||||
|
|
@ -348,7 +349,6 @@ bool DeckListModel::setData(const QModelIndex &index, const QVariant &value, con
|
||||||
switch (index.column()) {
|
switch (index.column()) {
|
||||||
case DeckListModelColumns::CARD_AMOUNT:
|
case DeckListModelColumns::CARD_AMOUNT:
|
||||||
node->setNumber(value.toInt());
|
node->setNumber(value.toInt());
|
||||||
refreshCardFormatLegalities();
|
|
||||||
break;
|
break;
|
||||||
case DeckListModelColumns::CARD_NAME:
|
case DeckListModelColumns::CARD_NAME:
|
||||||
node->setName(value.toString());
|
node->setName(value.toString());
|
||||||
|
|
@ -366,6 +366,7 @@ bool DeckListModel::setData(const QModelIndex &index, const QVariant &value, con
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
refreshCardFormatLegalities();
|
||||||
emitRecursiveUpdates(index);
|
emitRecursiveUpdates(index);
|
||||||
deckList->refreshDeckHash();
|
deckList->refreshDeckHash();
|
||||||
emit deckHashChanged();
|
emit deckHashChanged();
|
||||||
|
|
@ -408,6 +409,7 @@ bool DeckListModel::removeRows(int row, int count, const QModelIndex &parent)
|
||||||
emitRecursiveUpdates(parent);
|
emitRecursiveUpdates(parent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
refreshCardFormatLegalities();
|
||||||
deckList->refreshDeckHash();
|
deckList->refreshDeckHash();
|
||||||
emit deckHashChanged();
|
emit deckHashChanged();
|
||||||
|
|
||||||
|
|
@ -809,7 +811,6 @@ void DeckListModel::setActiveFormat(const QString &_format)
|
||||||
{
|
{
|
||||||
deckList->setGameFormat(_format);
|
deckList->setGameFormat(_format);
|
||||||
refreshCardFormatLegalities();
|
refreshCardFormatLegalities();
|
||||||
emitBackgroundUpdates(QModelIndex()); // start from root
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void DeckListModel::cleanList()
|
void DeckListModel::cleanList()
|
||||||
|
|
@ -885,17 +886,17 @@ QStringList DeckListModel::getCustomZoneNames(const QString &boardZoneName) cons
|
||||||
return zoneNames;
|
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) {
|
for (const AllowedCount &c : format.allowedCounts) {
|
||||||
if (c.label == legality) {
|
if (c.label == legality) {
|
||||||
return c.max;
|
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()) {
|
if (format.isEmpty()) {
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -908,9 +909,9 @@ static bool isCardQuantityLegalForFormat(const QString &format, const CardInfo &
|
||||||
return cardInfo.isLegalInFormat(format);
|
return cardInfo.isLegalInFormat(format);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exceptions always win
|
// Explicit format exceptions retain precedence, including finite limits.
|
||||||
if (cardHasAnyException(cardInfo, *formatRules)) {
|
if (const auto limit = cardCopyLimitException(cardInfo, *formatRules)) {
|
||||||
return true;
|
return *limit == -1 || quantity <= *limit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// check legality prop
|
// check legality prop
|
||||||
|
|
@ -919,42 +920,37 @@ static bool isCardQuantityLegalForFormat(const QString &format, const CardInfo &
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
int maxAllowed = maxAllowedForLegality(*formatRules, legality);
|
const auto maxAllowed = maxAllowedForLegality(*formatRules, legality);
|
||||||
|
return maxAllowed && (*maxAllowed == -1 || (*maxAllowed >= 0 && quantity <= *maxAllowed));
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void DeckListModel::refreshCardFormatLegalities()
|
void DeckListModel::refreshCardFormatLegalities()
|
||||||
{
|
{
|
||||||
QString format = deckList->getGameFormat();
|
const QString format = deckList->getGameFormat();
|
||||||
|
QHash<QString, qint64> quantities;
|
||||||
deckList->forEachCard([&format](const InnerDecklistNode *zone, DecklistCardNode *card) {
|
QHash<const DecklistCardNode *, CardInfoPtr> cardInfos;
|
||||||
bool legal = isCardNodeLegalForFormat(format, zone, card);
|
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);
|
card->setFormatLegality(legal);
|
||||||
});
|
});
|
||||||
|
emitBackgroundUpdates(QModelIndex());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -295,6 +295,7 @@ CardInfoPtr OracleImporter::addCard(QString name,
|
||||||
qDebug() << "warning: an empty card was added to set" << printingInfo.getSet()->getShortName();
|
qDebug() << "warning: an empty card was added to set" << printingInfo.getSet()->getShortName();
|
||||||
}
|
}
|
||||||
cards.insert(name, newCard);
|
cards.insert(name, newCard);
|
||||||
|
magicFormatsDirty = true;
|
||||||
|
|
||||||
return newCard;
|
return newCard;
|
||||||
}
|
}
|
||||||
|
|
@ -756,8 +757,38 @@ static FormatRulesNameMap buildDefaultMagicFormats()
|
||||||
|
|
||||||
const FormatRulesNameMap &OracleImporter::createDefaultMagicFormats()
|
const FormatRulesNameMap &OracleImporter::createDefaultMagicFormats()
|
||||||
{
|
{
|
||||||
static const FormatRulesNameMap cached = buildDefaultMagicFormats();
|
static const FormatRulesNameMap defaults = buildDefaultMagicFormats();
|
||||||
return cached;
|
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()
|
int OracleImporter::startImport()
|
||||||
|
|
@ -863,6 +894,8 @@ void OracleImporter::clear()
|
||||||
{
|
{
|
||||||
sets.clear();
|
sets.clear();
|
||||||
cards.clear();
|
cards.clear();
|
||||||
|
magicFormats.clear();
|
||||||
|
magicFormatsDirty = true;
|
||||||
allSets.clear();
|
allSets.clear();
|
||||||
rawSetsData.clear();
|
rawSetsData.clear();
|
||||||
localizedEntries.clear();
|
localizedEntries.clear();
|
||||||
|
|
|
||||||
|
|
@ -170,6 +170,10 @@ private:
|
||||||
*/
|
*/
|
||||||
CardNameMap cards;
|
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.
|
* The sets, indexed by short name.
|
||||||
*/
|
*/
|
||||||
|
|
@ -299,7 +303,8 @@ public:
|
||||||
bool saveToFile(const QString &fileName, const QString &sourceUrl, const QString &sourceVersion);
|
bool saveToFile(const QString &fileName, const QString &sourceUrl, const QString &sourceVersion);
|
||||||
int importCardsFromSet(const CardSetPtr ¤tSet, const QJsonArray &cardsList);
|
int importCardsFromSet(const CardSetPtr ¤tSet, const QJsonArray &cardsList);
|
||||||
/**
|
/**
|
||||||
* @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 FormatRulesNameMap &createDefaultMagicFormats();
|
||||||
const CardNameMap &getCardList() const
|
const CardNameMap &getCardList() const
|
||||||
|
|
|
||||||
|
|
@ -71,3 +71,52 @@ bool parseCipt(const QString &name, const QString &text)
|
||||||
|
|
||||||
return ciptPattern.match(text).hasMatch();
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,10 @@
|
||||||
#define PARSEHELPERS_H
|
#define PARSEHELPERS_H
|
||||||
|
|
||||||
#include <QString>
|
#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);
|
bool parseCipt(const QString &name, const QString &text);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
#include <gtest/gtest.h>
|
#include <gtest/gtest.h>
|
||||||
#include <libcockatrice/card/card_info.h>
|
#include <libcockatrice/card/card_info.h>
|
||||||
#include <libcockatrice/card/database/card_database_manager.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/game_specific_terms.h>
|
||||||
#include <libcockatrice/card/printing/exact_card.h>
|
#include <libcockatrice/card/printing/exact_card.h>
|
||||||
#include <libcockatrice/deck_list/deck_list.h>
|
#include <libcockatrice/deck_list/deck_list.h>
|
||||||
|
|
@ -289,6 +290,131 @@ TEST(DeckListModelZoneIntegration, AddCardToCustomZoneWithNestedSubZoneAppends)
|
||||||
ASSERT_TRUE(added.parent().data(DeckRoles::IsCustomZoneRole).toBool());
|
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)
|
int main(int argc, char **argv)
|
||||||
{
|
{
|
||||||
::testing::InitGoogleTest(&argc, argv);
|
::testing::InitGoogleTest(&argc, argv);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
#include "../../oracle/src/oracleimporter.h"
|
#include "../../oracle/src/oracleimporter.h"
|
||||||
|
#include "../../oracle/src/parsehelpers.h"
|
||||||
#include "../../oracle/src/raw_json_scanner.h"
|
#include "../../oracle/src/raw_json_scanner.h"
|
||||||
#include "libcockatrice/card/card_info.h"
|
#include "libcockatrice/card/card_info.h"
|
||||||
|
|
||||||
|
|
@ -6,6 +7,8 @@
|
||||||
#include <QByteArray>
|
#include <QByteArray>
|
||||||
#include <QChar>
|
#include <QChar>
|
||||||
#include <QDate>
|
#include <QDate>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QIODevice>
|
||||||
#include <QJsonArray>
|
#include <QJsonArray>
|
||||||
#include <QJsonDocument>
|
#include <QJsonDocument>
|
||||||
#include <QJsonObject>
|
#include <QJsonObject>
|
||||||
|
|
@ -20,10 +23,13 @@
|
||||||
#include <QRegularExpressionMatch>
|
#include <QRegularExpressionMatch>
|
||||||
#include <QSharedPointer>
|
#include <QSharedPointer>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
|
#include <QTemporaryDir>
|
||||||
#include <QUuid>
|
#include <QUuid>
|
||||||
#include <QVariant>
|
#include <QVariant>
|
||||||
#include <QVariantMap>
|
#include <QVariantMap>
|
||||||
#include <QtGlobal>
|
#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/format/format_legality_rules.h>
|
||||||
#include <libcockatrice/card/set/card_set.h>
|
#include <libcockatrice/card/set/card_set.h>
|
||||||
#include <libcockatrice/interfaces/noop_card_set_priority_controller.h>
|
#include <libcockatrice/interfaces/noop_card_set_priority_controller.h>
|
||||||
|
|
@ -318,6 +324,162 @@ TEST_F(OracleImporterTest, CreateDefaultMagicFormatsCaching)
|
||||||
ASSERT_EQ(first.value("standard").data(), second.value("standard").data());
|
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
|
// readSetsFromByteArray tests
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue