[Client] Show localized card names, texts and pictures

Localization wiring now runs end to end: the oracle importer collects
foreignData for the configured language and the client renders it.

- [Oracle] Import localized names and rules texts for the selected cardLang
  - single-face cards store their foreignData name and full text
  - multi-face (split/adventure/aftermath/prepare) cards collect the joined
    name once and join each face's translated text with the same separator
    as the English merge; an incomplete translation falls back to English;
    the joined text follows the same highest-priority-set policy as the
    single-face path and is only collected when localization is enabled
  - the wizard switching languages re-imports the card database

- [Client] Display localized card info throughout the client
  - card info text/picture widgets and the game board re-render on language
    change
  - pictures resolve cardLang art through Scryfall's named endpoint using the
    localized name, falling back to id-based art when no match exists
  - deck editor keeps canonical English names as card identity (EditRole)
    while showing localized names (DisplayRole), so decks and wire names
    stay stable

- [Card] Add CardLocalization-backed name/text lookup and cards.xml v4
  localization elements with a bounded-size translation cache

- [Tests] Cover oracle foreignData import (incl. multi-face joins, priority
  and fallback paths), XML v4 localization parsing, deck model localized
  display and the language-aware settings default

Existing installations need to re-run Oracle to see translations: localized
data only lands in cards.xml when the Oracle app is started with the
preferred language selected — launch the separate "Oracle" program that
ships with Cockatrice, pick the language in the wizard and let it re-import
the card database.

The client's database cache (cards.xml.cache) is invalidated by the cache
format bump and the source-hash checks, but a cache written before the
re-import can still hold English-only entries (the hash uses file size and
mtime, so a same-size/same-timestamp rewrite may be served as-is); delete
cards.xml.cache and relaunch if no localized names/texts show up after
re-importing.
This commit is contained in:
Lukas Brübach 2026-09-06 03:35:38 +02:00
parent fd82b140a8
commit 8bb2337117
35 changed files with 1138 additions and 25 deletions

View file

@ -72,6 +72,18 @@ protected:
return card;
}
// Helper: build a single MTGJSON foreignData entry
QJsonObject makeForeignEntry(const QString &language, const QString &name, const QString &text)
{
QJsonObject entry;
entry["language"] = language;
entry["name"] = name;
if (!text.isEmpty()) {
entry["text"] = text;
}
return entry;
}
NoopCardSetPriorityController *controller;
OracleImporter *importer;
CardSetPtr set;
@ -872,6 +884,243 @@ TEST_F(OracleImporterTest, DisablingProgressReportingSuppressesScanEmissions)
ASSERT_GT(emissions, 0);
}
// Localized card text tests
// ============================================================================
TEST_F(OracleImporterTest, ImportsLocalizedTextForRequestedLanguage)
{
QJsonObject card = makeCard("Lightning Bolt");
card["foreignData"] =
QJsonArray{makeForeignEntry("German", "Blitzschlag", "Blitzschlag fügt 3 Schadenspunkte zu.")};
QJsonArray cards{card};
importer->setCardLang("de");
importer->importCardsFromSet(set, cards);
importer->applyLocalizedData();
auto result = importer->getCardList().value("Lightning Bolt");
ASSERT_FALSE(result.isNull());
ASSERT_EQ(result->getLocalizedName("de"), "Blitzschlag");
ASSERT_EQ(result->getLocalizedText("de"), "Blitzschlag fügt 3 Schadenspunkte zu.");
// English identity untouched
ASSERT_EQ(result->getName(), "Lightning Bolt");
ASSERT_EQ(result->getText(), "Rules text.");
}
TEST_F(OracleImporterTest, ImportsLocalizedNameAndTextForMultiFaceCards)
{
// MTGJSON reports multi-face cards (adventure/split/aftermath/prepare) as one
// card object per face; every face carries the joined name but only its own
// face's rules text in foreignData. The importer joins the per-face texts with
// the same separator as the English merge.
QJsonObject front = makeCard("Disruptive Stormbrood // Petty Revenge");
front["layout"] = "adventure";
front["faceName"] = "Disruptive Stormbrood";
front["side"] = "a";
front["foreignData"] = QJsonArray{
makeForeignEntry("German", "Disruptive Stormbrood // Kleinliche Rache",
"Fliegend\nWenn diese Kreatur ins Spiel kommt, zerstöre bis zu ein Artefakt oder eine "
"Verzauberung deiner Wahl.")};
QJsonObject back = makeCard("Disruptive Stormbrood // Petty Revenge");
back["layout"] = "adventure";
back["faceName"] = "Petty Revenge";
back["side"] = "b";
back["text"] = "Destroy target creature.";
back["foreignData"] = QJsonArray{makeForeignEntry("German", "Disruptive Stormbrood // Kleinliche Rache",
"Zerstöre eine Kreatur deiner Wahl mit Stärke 3 oder weniger.")};
QJsonArray cards{front, back};
importer->setCardLang("de");
importer->importCardsFromSet(set, cards);
importer->applyLocalizedData();
auto result = importer->getCardList().value("Disruptive Stormbrood // Petty Revenge");
ASSERT_FALSE(result.isNull());
ASSERT_EQ(result->getLocalizedName("de"), "Disruptive Stormbrood // Kleinliche Rache");
ASSERT_EQ(result->getLocalizedText("de"),
"Fliegend\nWenn diese Kreatur ins Spiel kommt, zerstöre bis zu ein Artefakt oder eine Verzauberung "
"deiner Wahl.\n\n---\n\nZerstöre eine Kreatur deiner Wahl mit Stärke 3 oder weniger.");
// English identity untouched
ASSERT_EQ(result->getName(), "Disruptive Stormbrood // Petty Revenge");
ASSERT_EQ(result->getText(), "Rules text.\n\n---\n\nDestroy target creature.");
}
TEST_F(OracleImporterTest, MultiFaceCardsWithoutCompleteForeignTextKeepEnglishText)
{
// Both faces must carry a foreignData text for the joined text; otherwise the
// rules text stays English while the localized name (from a later complete
// printing) is still applied.
QJsonObject front = makeCard("Wear // Tear");
front["layout"] = "split";
front["faceName"] = "Wear";
front["side"] = "a";
front["foreignData"] = QJsonArray{makeForeignEntry("German", "Verschleiß // Zerrreißung", "Verschleiß-Text.")};
QJsonObject back = makeCard("Wear // Tear");
back["layout"] = "split";
back["faceName"] = "Tear";
back["side"] = "b";
back["text"] = "Tear rules text.";
back["foreignData"] = QJsonArray{makeForeignEntry("German", "Verschleiß // Zerrreißung", "")};
QJsonArray cards{front, back};
importer->setCardLang("de");
importer->importCardsFromSet(set, cards);
importer->applyLocalizedData();
auto result = importer->getCardList().value("Wear // Tear");
ASSERT_FALSE(result.isNull());
// The joined name is still applied.
ASSERT_EQ(result->getLocalizedName("de"), "Verschleiß // Zerrreißung");
// The incomplete text must not become the card's localized text.
ASSERT_TRUE(result->getLocalizedTexts().isEmpty());
ASSERT_EQ(result->getText(), "Rules text.\n\n---\n\nTear rules text.");
}
TEST_F(OracleImporterTest, DefaultLanguageSkipsForeignData)
{
QJsonObject card = makeCard("Lightning Bolt");
card["foreignData"] =
QJsonArray{makeForeignEntry("German", "Blitzschlag", "Blitzschlag fügt 3 Schadenspunkte zu.")};
QJsonArray cards{card};
// cardLang defaults to "en" — foreignData must never be imported
importer->importCardsFromSet(set, cards);
importer->applyLocalizedData();
auto result = importer->getCardList().value("Lightning Bolt");
ASSERT_FALSE(result.isNull());
ASSERT_TRUE(result->getLocalizedNames().isEmpty());
ASSERT_TRUE(result->getLocalizedTexts().isEmpty());
}
TEST_F(OracleImporterTest, UnsupportedLanguageSkipsForeignData)
{
QJsonObject card = makeCard("Lightning Bolt");
card["foreignData"] = QJsonArray{makeForeignEntry("xx", "Kochanie", "Grzmot uderza.")};
QJsonArray cards{card};
importer->setCardLang("xx");
importer->importCardsFromSet(set, cards);
importer->applyLocalizedData();
auto result = importer->getCardList().value("Lightning Bolt");
ASSERT_FALSE(result.isNull());
ASSERT_TRUE(result->getLocalizedNames().isEmpty());
}
TEST_F(OracleImporterTest, NonMatchingLanguageNotCollected)
{
QJsonObject card = makeCard("Lightning Bolt");
card["foreignData"] = QJsonArray{makeForeignEntry("French", "Éclair", "L'Éclair inflige 3 blessures.")};
QJsonArray cards{card};
importer->setCardLang("de");
importer->importCardsFromSet(set, cards);
importer->applyLocalizedData();
auto result = importer->getCardList().value("Lightning Bolt");
ASSERT_FALSE(result.isNull());
ASSERT_TRUE(result->getLocalizedNames().isEmpty());
}
TEST_F(OracleImporterTest, HigherPrioritySetWinsForReprint)
{
// First printing in a reprint set, then another in a (more authoritative)
// core set: the core set's German text must win even though it was seen later.
QJsonObject reprintCard = makeCard("Lightning Bolt");
reprintCard["foreignData"] = QJsonArray{makeForeignEntry("German", "Blitzschlag", "Älterer deutscher Text.")};
CardSetPtr reprintSet =
CardSet::newInstance(controller, "TS2", "Second Set", QString(), QDate(), CardSet::PriorityReprint);
importer->setCardLang("de");
importer->importCardsFromSet(reprintSet, QJsonArray{reprintCard});
QJsonObject primaryCard = makeCard("Lightning Bolt");
primaryCard["foreignData"] =
QJsonArray{makeForeignEntry("German", "Blitzschlag", "Blitzschlag fügt 3 Schadenspunkte zu.")};
CardSetPtr primarySet =
CardSet::newInstance(controller, "TS3", "Third Set", QString(), QDate(), CardSet::PriorityPrimary);
importer->importCardsFromSet(primarySet, QJsonArray{primaryCard});
importer->applyLocalizedData();
auto result = importer->getCardList().value("Lightning Bolt");
ASSERT_FALSE(result.isNull());
ASSERT_EQ(result->getLocalizedName("de"), "Blitzschlag");
ASSERT_EQ(result->getLocalizedText("de"), "Blitzschlag fügt 3 Schadenspunkte zu.");
ASSERT_EQ(importer->getCardList().size(), 1);
}
TEST_F(OracleImporterTest, HigherPrioritySplitSetWinsForReprint)
{
// Split cards print each face as its own card object; the joined text is
// collected per set with the same priority policy as single-face cards, so a
// reprint set's German text must yield to the core set's even when reprints
// are imported first.
QJsonObject reprintFront = makeCard("Wear // Tear");
reprintFront["layout"] = "split";
reprintFront["faceName"] = "Wear";
reprintFront["side"] = "a";
reprintFront["foreignData"] =
QJsonArray{makeForeignEntry("German", "Verschleiß // Zerrreißung", "Wear alter Text.")};
QJsonObject reprintBack = makeCard("Wear // Tear");
reprintBack["layout"] = "split";
reprintBack["faceName"] = "Tear";
reprintBack["side"] = "b";
reprintBack["foreignData"] =
QJsonArray{makeForeignEntry("German", "Verschleiß // Zerrreißung", "Tear alter Text.")};
CardSetPtr reprintSet =
CardSet::newInstance(controller, "TS2", "Second Set", QString(), QDate(), CardSet::PriorityReprint);
importer->setCardLang("de");
importer->importCardsFromSet(reprintSet, QJsonArray{reprintFront, reprintBack});
QJsonObject primaryFront = makeCard("Wear // Tear");
primaryFront["layout"] = "split";
primaryFront["faceName"] = "Wear";
primaryFront["side"] = "a";
primaryFront["foreignData"] =
QJsonArray{makeForeignEntry("German", "Verschleiß // Zerrreißung", "Wear neuer Text.")};
QJsonObject primaryBack = makeCard("Wear // Tear");
primaryBack["layout"] = "split";
primaryBack["faceName"] = "Tear";
primaryBack["side"] = "b";
primaryBack["foreignData"] =
QJsonArray{makeForeignEntry("German", "Verschleiß // Zerrreißung", "Tear neuer Text.")};
CardSetPtr primarySet =
CardSet::newInstance(controller, "TS3", "Third Set", QString(), QDate(), CardSet::PriorityPrimary);
importer->importCardsFromSet(primarySet, QJsonArray{primaryFront, primaryBack});
importer->applyLocalizedData();
auto result = importer->getCardList().value("Wear // Tear");
ASSERT_FALSE(result.isNull());
ASSERT_EQ(result->getLocalizedName("de"), "Verschleiß // Zerrreißung");
ASSERT_EQ(result->getLocalizedText("de"), "Wear neuer Text.\n\n---\n\nTear neuer Text.");
ASSERT_EQ(importer->getCardList().size(), 1);
}
TEST_F(OracleImporterTest, StartImportAppliesLocalizedData)
{
QJsonObject card = makeCard("Lightning Bolt");
card["foreignData"] =
QJsonArray{makeForeignEntry("Portuguese (Brazil)", "Raio", "Raio causa 3 de dano a qualquer alvo.")};
QJsonObject dataSet;
dataSet["code"] = "tst";
dataSet["name"] = "Test Set";
dataSet["type"] = "expansion";
dataSet["releaseDate"] = "2024-01-01";
dataSet["cards"] = QJsonArray{card};
QJsonObject root;
root["data"] = QJsonObject{{"TST", dataSet}};
importer->setCardLang("pt");
ASSERT_TRUE(importer->readSetsFromByteArray(QJsonDocument(root).toJson(QJsonDocument::Compact)));
ASSERT_EQ(importer->startImport(), 1);
auto result = importer->getCardList().value("Lightning Bolt");
ASSERT_FALSE(result.isNull());
ASSERT_EQ(result->getLocalizedName("pt"), "Raio");
ASSERT_EQ(result->getLocalizedText("pt"), "Raio causa 3 de dano a qualquer alvo.");
}
int main(int argc, char **argv)
{
::testing::InitGoogleTest(&argc, argv);