From dc4aa19d23fa24dbbd61a7522d9baaee4ce695dd Mon Sep 17 00:00:00 2001 From: Bryce! Date: Tue, 3 Nov 2026 04:40:28 -0500 Subject: [PATCH] Import Moxfield signature spells into the deck [skip ci] (#7751) Co-authored-by: Codex --- .../parsers/interface_json_deck_parser.h | 26 ++++++ .../libcockatrice/deck_list/deck_list.cpp | 19 ++++- .../libcockatrice/deck_list/deck_list.h | 8 ++ tests/CMakeLists.txt | 13 +++ tests/moxfield_signature_spells_test.cpp | 84 +++++++++++++++++++ 5 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 tests/moxfield_signature_spells_test.cpp diff --git a/cockatrice/src/client/network/parsers/interface_json_deck_parser.h b/cockatrice/src/client/network/parsers/interface_json_deck_parser.h index 8f1defb7a..7e15d472f 100644 --- a/cockatrice/src/client/network/parsers/interface_json_deck_parser.h +++ b/cockatrice/src/client/network/parsers/interface_json_deck_parser.h @@ -54,7 +54,11 @@ public: outStream << quantity << ' ' << cardName << " (" << setName << ") " << collectorNumber << '\n'; } + outStream.flush(); + outStream.seek(0); deckList.loadFromStream_Plain(outStream, false, CardNameNormalizer()); + deckList.setName(deckName); + deckList.setComments(deckDescription); deckList.forEachCard(CardNodeFunction::ResolveProviderId()); deckList.setName(deckName); deckList.setComments(deckDescription); @@ -100,7 +104,11 @@ public: outStream << quantity << ' ' << cardName << " (" << setName << ") " << collectorNumber << '\n'; } + outStream.flush(); + outStream.seek(0); deckList.loadFromStream_Plain(outStream, false, CardNameNormalizer()); + deckList.setName(deckName); + deckList.setComments(deckDescription); deckList.forEachCard(CardNodeFunction::ResolveProviderId()); deckList.setName(deckName); deckList.setComments(deckDescription); @@ -119,6 +127,24 @@ public: } } + // Moxfield stores Oathbreaker signature spells in their own board, + // separate from both mainboard and commanders. They are playable deck + // cards, but must not replace the Oathbreaker as the banner card. + const QJsonObject signatureSpells = obj.value("signatureSpells").toObject(); + for (auto it = signatureSpells.begin(); it != signatureSpells.end(); ++it) { + const QJsonObject entry = it.value().toObject(); + const QJsonObject card = entry.value("card").toObject(); + const QString name = card.value("name").toString(); + const int quantity = entry.value("quantity").toInt(); + if (name.isEmpty() || quantity <= 0) { + continue; + } + const QString set = card.value("set").toString().toUpper(); + const QString collectorNumber = card.value("cn").toString(); + const QString providerId = card.value("scryfall_id").toString(); + deckList.addCards(name, quantity, DECK_ZONE_MAIN, -1, set, collectorNumber, providerId); + } + return deckList; } }; diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp index 821b53dff..2cd5ef914 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp @@ -423,8 +423,23 @@ DecklistCardNode *DeckList::addCard(const QString &cardName, const QString &cardProviderId, bool formatLegal) { - auto node = - tree.addCard(cardName, 1, zoneName, position, cardSetName, cardSetCollectorNumber, cardProviderId, formatLegal); + return addCards(cardName, 1, zoneName, position, cardSetName, cardSetCollectorNumber, cardProviderId, formatLegal); +} + +DecklistCardNode *DeckList::addCards(const QString &cardName, + int amount, + const QString &zoneName, + int position, + const QString &cardSetName, + const QString &cardSetCollectorNumber, + const QString &cardProviderId, + bool formatLegal) +{ + if (amount <= 0) { + return nullptr; + } + auto node = tree.addCard(cardName, amount, zoneName, position, cardSetName, cardSetCollectorNumber, cardProviderId, + formatLegal); refreshDeckHash(); return node; } diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h index 6796e9c33..9d0f20586 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h @@ -284,6 +284,14 @@ public: const QString &cardSetCollectorNumber = QString(), const QString &cardProviderId = QString(), const bool formatLegal = true); + DecklistCardNode *addCards(const QString &cardName, + int amount, + const QString &zoneName, + int position = -1, + const QString &cardSetName = QString(), + const QString &cardSetCollectorNumber = QString(), + const QString &cardProviderId = QString(), + const bool formatLegal = true); ///@} /** @name Deck identity */ diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index cccff48c3..6acd225c2 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -463,6 +463,19 @@ endif() # Guard must match the condition for src/single_instance_manager.cpp in cockatrice/CMakeLists.txt. if(WITH_CLIENT) + cockatrice_add_test( + NAME + moxfield_signature_spells_test + SOURCES + ${VERSION_STRING_CPP} + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/deck_loader/card_node_function.cpp + moxfield_signature_spells_test.cpp + LIBS + libcockatrice_card + libcockatrice_deck_list + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/cockatrice/src + ) cockatrice_add_test( NAME single_instance_manager_test diff --git a/tests/moxfield_signature_spells_test.cpp b/tests/moxfield_signature_spells_test.cpp new file mode 100644 index 000000000..54346a934 --- /dev/null +++ b/tests/moxfield_signature_spells_test.cpp @@ -0,0 +1,84 @@ +#include "client/network/parsers/interface_json_deck_parser.h" + +#include +#include +#include +#include + +TEST(MoxfieldImport, SignatureSpellIsAddedToMainWithoutReplacingCommanderBanner) +{ + // Moxfield v2 board objects map IDs to entries with quantity and card. + const auto input = QJsonDocument::fromJson(R"json({ + "name": "Oathbreaker example", "description": "Notes retained", + "mainboard": {"land": {"quantity": 1, "card": {"name": "Island", "set": "abc", "cn": "1"}}}, + "sideboard": {"side": {"quantity": 1, "card": {"name": "Mountain", "set": "abc", "cn": "2"}}}, + "commanders": {"walker": {"quantity": 1, "card": {"name": "Jace", "set": "abc", "cn": "3", "scryfall_id": "walker-id"}}}, + "signatureSpells": {"spell": {"quantity": 1, "card": {"name": "Lightning Bolt", "set": "xyz", "cn": "4", "scryfall_id": "spell-id"}}} + })json") + .object(); + MoxfieldJsonParser parser; + DeckList deck = parser.parse(input); + int mainSpellCount = 0; + int sideSpellCount = 0; + int mainLands = 0; + int sideLands = 0; + deck.forEachCard([&](InnerDecklistNode *zone, DecklistCardNode *card) { + if (card->getName() == "Island" && zone->getName() == DECK_ZONE_MAIN) { + mainLands += card->getNumber(); + } + if (card->getName() == "Mountain" && zone->getName() == DECK_ZONE_SIDE) { + sideLands += card->getNumber(); + } + if (card->getName() != "Lightning Bolt") { + return; + } + if (zone->getName() == DECK_ZONE_MAIN) { + mainSpellCount += card->getNumber(); + EXPECT_EQ(card->getCardSetShortName(), "XYZ"); + EXPECT_EQ(card->getCardCollectorNumber(), "4"); + EXPECT_EQ(card->getCardProviderId(), "spell-id"); + } else if (zone->getName() == DECK_ZONE_SIDE) { + sideSpellCount += card->getNumber(); + } + }); + EXPECT_EQ(mainSpellCount, 1); + EXPECT_EQ(sideSpellCount, 0); + EXPECT_EQ(mainLands, 1); + EXPECT_EQ(sideLands, 1); + EXPECT_EQ(deck.getName(), "Oathbreaker example"); + EXPECT_EQ(deck.getComments(), "Notes retained"); + EXPECT_EQ(deck.getBannerCard().name, "Jace"); + EXPECT_EQ(deck.getBannerCard().providerId, "walker-id"); +} + +TEST(MoxfieldImport, QuantityAwareDeckInsertionRetainsCountAndPrinting) +{ + DeckList deck; + auto *card = deck.addCards("Island", 2, DECK_ZONE_MAIN, -1, "ABC", "4", "id"); + ASSERT_NE(card, nullptr); + EXPECT_EQ(card->getNumber(), 2); + EXPECT_EQ(card->getCardProviderId(), "id"); + EXPECT_EQ(deck.addCards("Island", 0, DECK_ZONE_MAIN), nullptr); +} + +TEST(OnlineImport, ArchidektPreservesNameDescriptionAndCards) +{ + const auto input = QJsonDocument::fromJson(R"json({ + "name": "Online deck", "description": "Deck notes", + "cards": [{"quantity": 2, "card": { + "oracleCard": {"name": "Island"}, "edition": {"editioncode": "abc"}, "collectorNumber": "1" + }}] + })json") + .object(); + ArchidektJsonParser parser; + const DeckList deck = parser.parse(input); + int count = 0; + deck.forEachCard([&](InnerDecklistNode *zone, DecklistCardNode *card) { + if (zone->getName() == DECK_ZONE_MAIN && card->getName() == "Island") { + count += card->getNumber(); + } + }); + EXPECT_EQ(count, 2); + EXPECT_EQ(deck.getName(), "Online deck"); + EXPECT_EQ(deck.getComments(), "Deck notes"); +}