diff --git a/HearthDb.CardDefsDownloader/HearthDb.CardDefsDownloader.csproj b/HearthDb.CardDefsDownloader/HearthDb.CardDefsDownloader.csproj new file mode 100644 index 00000000..918adb65 --- /dev/null +++ b/HearthDb.CardDefsDownloader/HearthDb.CardDefsDownloader.csproj @@ -0,0 +1,9 @@ + + + + netcoreapp3.1 + 8 + Exe + + + diff --git a/HearthDb.CardDefsDownloader/Program.cs b/HearthDb.CardDefsDownloader/Program.cs new file mode 100644 index 00000000..00ebf4c3 --- /dev/null +++ b/HearthDb.CardDefsDownloader/Program.cs @@ -0,0 +1,31 @@ +using System.IO; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; + +namespace HearthDb.CardDefsDownloader +{ + class Program + { + static void Main(string[] args) + { + var outdir = args[0]; + Directory.CreateDirectory(outdir); + var httpClient = new HttpClient(new HttpClientHandler { AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate }); + + // CardDefs.base.xml contains non localized tags and enUS tag + // Other languages can be found under e.g. CardDefs.deDE.xml + using var request = new HttpRequestMessage(HttpMethod.Get, "https://api.hearthstonejson.com/v1/latest/CardDefs.base.xml"); + request.Headers.AcceptEncoding.Add(new StringWithQualityHeaderValue("gzip")); + request.Headers.AcceptEncoding.Add(new StringWithQualityHeaderValue("deflate")); + + var response = httpClient.SendAsync(request).Result; + var data = response.Content.ReadAsStringAsync().Result; + File.WriteAllText(Path.Combine(outdir, "CardDefs.base.xml"), data); + + var etag = response.Headers.ETag.Tag; + var lastModified = response.Content.Headers.LastModified; + File.WriteAllText(Path.Combine(outdir, "CardDefs.base.etag"), $"{etag}\n{lastModified.ToString()}"); + } + } +} \ No newline at end of file diff --git a/HearthDb.Tests/CardDefsLoadTest.cs b/HearthDb.Tests/CardDefsLoadTest.cs new file mode 100644 index 00000000..2805ce3d --- /dev/null +++ b/HearthDb.Tests/CardDefsLoadTest.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using System.IO; +using HearthDb.CardDefs; +using HearthDb.Enums; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace HearthDb.Tests +{ + [TestClass] + public class CardDefsLoadTest + { + [TestInitialize] + public void Init() + { + Cards.LoadBaseData(new CardDefs.CardDefs { Entites = new List() }); + } + + [TestCleanup] + public void Cleanup() + { + Cards.LoadBaseData(); + } + + [TestMethod] + public void LoadCards_CorrectlyAssemblesData() + { + using var fsBase = File.OpenRead("Data/TestCardDefs.base.xml"); + Cards.LoadBaseData(fsBase); + + Assert.AreEqual("Nutthapon Petchthai", Cards.All["AT_001"].ArtistName); + Assert.AreEqual("Flame Lance", Cards.All["AT_001"].GetLocName(Locale.enUS)); + Assert.AreEqual(null, Cards.All["AT_001"].GetLocName(Locale.deDE)); + + using var fsLocDe = File.OpenRead("Data/TestCardDefs.deDE.xml"); + Cards.LoadLocaleData(fsLocDe, Locale.deDE); + + Assert.AreEqual("Nutthapon Petchthai", Cards.All["AT_001"].ArtistName); + Assert.AreEqual("Flame Lance", Cards.All["AT_001"].GetLocName(Locale.enUS)); + Assert.AreEqual("Flammenlanze", Cards.All["AT_001"].GetLocName(Locale.deDE)); + } + + [TestMethod] + public void HasValidETagData() + { + var bundled = Cards.GetBundledCardDefsETag(); + Assert.IsNotNull(bundled.ETag); + Assert.IsTrue(DateTime.TryParse(bundled.LastModified, out _)); + } + } +} \ No newline at end of file diff --git a/HearthDb.Tests/Data/TestCardDefs.base.xml b/HearthDb.Tests/Data/TestCardDefs.base.xml new file mode 100644 index 00000000..8ad641a1 --- /dev/null +++ b/HearthDb.Tests/Data/TestCardDefs.base.xml @@ -0,0 +1,23 @@ + + + + + Flame Lance + + + Deal $25 damage + to a minion. + + + It's on the rack next to ice lance, acid lance, and English muffin lance. + + Nutthapon Petchthai + + + + + + + + + \ No newline at end of file diff --git a/HearthDb.Tests/Data/TestCardDefs.deDE.xml b/HearthDb.Tests/Data/TestCardDefs.deDE.xml new file mode 100644 index 00000000..d52cf312 --- /dev/null +++ b/HearthDb.Tests/Data/TestCardDefs.deDE.xml @@ -0,0 +1,14 @@ + + + + + Flammenlanze + + + Fügt einem Diener $25 Schaden zu. + + + Die Sommerversion der Eislanze. Gut gegen chronisch kalte Hände. + + + \ No newline at end of file diff --git a/HearthDb.Tests/HearthDb.Tests.csproj b/HearthDb.Tests/HearthDb.Tests.csproj index 9ea1105b..e5f7c962 100644 --- a/HearthDb.Tests/HearthDb.Tests.csproj +++ b/HearthDb.Tests/HearthDb.Tests.csproj @@ -17,4 +17,8 @@ + + + + diff --git a/HearthDb.Tests/UnitTest1.cs b/HearthDb.Tests/UnitTest1.cs index 9d4c1506..c9497e9d 100644 --- a/HearthDb.Tests/UnitTest1.cs +++ b/HearthDb.Tests/UnitTest1.cs @@ -11,7 +11,8 @@ public class UnitTest1 public void BasicTest() { Assert.AreEqual("Flame Lance", Cards.All["AT_001"].Name); - Assert.AreEqual("Flammenlanze", Cards.All["AT_001"].GetLocName(Locale.deDE)); + // non-enUS no longer included by default. CardDefsLoadTest verifies this works. + // Assert.AreEqual("Flammenlanze", Cards.All["AT_001"].GetLocName(Locale.deDE)); Assert.AreEqual("Nutthapon Petchthai", Cards.All["AT_001"].ArtistName); Assert.AreEqual(CardSet.TGT, Cards.All["AT_001"].Set); Assert.AreEqual(true, Cards.All["AT_001"].Collectible); @@ -80,7 +81,7 @@ public void TestCardText() Assert.IsTrue(cramSession.Text.Contains("improved by")); var flameLance = Cards.All[CardIds.Collectible.Mage.FlameLanceTGT]; - Assert.IsTrue(flameLance.GetLocText(Locale.frFR).Contains("$25")); + Assert.IsTrue(flameLance.GetLocText(Locale.enUS).Contains("$25")); var elvenArcher = Cards.All[CardIds.Collectible.Neutral.ElvenArcherVanilla]; Assert.IsTrue(elvenArcher.Text.Contains("Deal 1 damage")); diff --git a/HearthDb.sln b/HearthDb.sln index 5773565f..286e4c35 100644 --- a/HearthDb.sln +++ b/HearthDb.sln @@ -11,6 +11,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HearthDb.EnumsGenerator", " EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HearthDb.Tests", "HearthDb.Tests\HearthDb.Tests.csproj", "{875316D1-C4C3-48BD-BDAE-3727269B2A67}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HearthDb.CardDefsDownloader", "HearthDb.CardDefsDownloader\HearthDb.CardDefsDownloader.csproj", "{92DBA109-DF71-4874-827A-20B00EABB57D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -51,6 +53,14 @@ Global {875316D1-C4C3-48BD-BDAE-3727269B2A67}.Release|Any CPU.Build.0 = Release|Any CPU {875316D1-C4C3-48BD-BDAE-3727269B2A67}.Release|x86.ActiveCfg = Release|Any CPU {875316D1-C4C3-48BD-BDAE-3727269B2A67}.Release|x86.Build.0 = Release|Any CPU + {92DBA109-DF71-4874-827A-20B00EABB57D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {92DBA109-DF71-4874-827A-20B00EABB57D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {92DBA109-DF71-4874-827A-20B00EABB57D}.Debug|x86.ActiveCfg = Debug|Any CPU + {92DBA109-DF71-4874-827A-20B00EABB57D}.Debug|x86.Build.0 = Debug|Any CPU + {92DBA109-DF71-4874-827A-20B00EABB57D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {92DBA109-DF71-4874-827A-20B00EABB57D}.Release|Any CPU.Build.0 = Release|Any CPU + {92DBA109-DF71-4874-827A-20B00EABB57D}.Release|x86.ActiveCfg = Release|Any CPU + {92DBA109-DF71-4874-827A-20B00EABB57D}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/HearthDb/CardDefs/CardDefs.cs b/HearthDb/CardDefs/CardDefs.cs index a0c1fc70..17637ab3 100644 --- a/HearthDb/CardDefs/CardDefs.cs +++ b/HearthDb/CardDefs/CardDefs.cs @@ -12,5 +12,9 @@ public class CardDefs { [XmlElement("Entity")] public List Entites { get; set; } + + + [XmlAttribute("build")] + public string Build { get; set; } } } \ No newline at end of file diff --git a/HearthDb/Cards.cs b/HearthDb/Cards.cs index 6406f2bb..b7115cd0 100644 --- a/HearthDb/Cards.cs +++ b/HearthDb/Cards.cs @@ -15,20 +15,20 @@ namespace HearthDb { public static class Cards { - public static readonly Dictionary All = new Dictionary(); - public static readonly Dictionary AllByDbfId = new Dictionary(); + public static Dictionary All { get; private set; } = new Dictionary(); + public static Dictionary AllByDbfId { get; private set; } = new Dictionary(); - public static readonly Dictionary Collectible = new Dictionary(); - public static readonly Dictionary CollectibleByDbfId = new Dictionary(); + public static Dictionary Collectible { get; private set; } = new Dictionary(); + public static Dictionary CollectibleByDbfId { get; private set; } = new Dictionary(); - public static readonly Dictionary BaconPoolMinions = new Dictionary(); - public static readonly Dictionary BaconPoolMinionsByDbfId = new Dictionary(); + public static Dictionary BaconPoolMinions { get; private set; } = new Dictionary(); + public static Dictionary BaconPoolMinionsByDbfId { get; private set; } = new Dictionary(); - public static readonly Dictionary NormalToTripleCardIds = new Dictionary(); - public static readonly Dictionary TripleToNormalCardIds = new Dictionary(); + public static Dictionary NormalToTripleCardIds { get; private set; } = new Dictionary(); + public static Dictionary TripleToNormalCardIds { get; private set; } = new Dictionary(); - public static readonly Dictionary NormalToTripleDbfIds = new Dictionary(); - public static readonly Dictionary TripleToNormalDbfIds = new Dictionary(); + public static Dictionary NormalToTripleDbfIds { get; private set; } = new Dictionary(); + public static Dictionary TripleToNormalDbfIds { get; private set; } = new Dictionary(); private static readonly HashSet IgnoreTripleIds = new HashSet { @@ -37,63 +37,216 @@ public static class Cards CardIds.NonCollectible.Neutral.SeabreakerGoliathGILNEAS }; + private static readonly XmlSerializer CardDefsSerializer = new XmlSerializer(typeof(CardDefs.CardDefs)); + + public static string Build { get; private set; } + + private static (string ETag, string LastModified)? _bundledCardDefsETag; + public static (string ETag, string LastModified) GetBundledCardDefsETag() + { + if(_bundledCardDefsETag == null) + { + using var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream("HearthDb.CardDefs.base.etag")!; + using var reader = new StreamReader(stream); + var text = reader.ReadToEnd().Split('\n'); + _bundledCardDefsETag = (text[0], text[1]); + } + return _bundledCardDefsETag.Value; + } + + public static CardDefs.CardDefs GetBundledBaseData() + { + var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream("HearthDb.CardDefs.base.xml")!; + return ParseCardDefs(stream); + } + static Cards() { - var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream("HearthDb.CardDefs.xml"); - if(stream == null) - return; - using(TextReader tr = new StreamReader(stream)) + if(Config.AutoLoadCardDefs) + LoadBaseData(); + } + + public static CardDefs.CardDefs ParseCardDefs(Stream cardDefsData) + { + return (CardDefs.CardDefs)CardDefsSerializer.Deserialize(cardDefsData); + } + + /// + /// Load base card data (non-localized card metadata, as well as + /// localized strings in enUS and zhCN) bundled with HearthDb. + /// + public static void LoadBaseData() => LoadBaseData(GetBundledBaseData()); + + /// + /// Load base card data, this should usually be a + /// CardDefs.base.xml file, that at least includes all + /// non-localized metadata. + /// + /// This will clear any previously loaded data. + /// + public static void LoadBaseData(Stream cardDefsData) => LoadBaseData(ParseCardDefs(cardDefsData)); + + /// + /// Load base card data, this should usually be a + /// CardDefs.base.xml file, that at least includes all + /// non-localized metadata. + /// + /// This will clear any previously loaded data. + /// + public static void LoadBaseData(CardDefs.CardDefs cardDefs) + { + // Instantiate new dictionaries here and re-assign once complete to avoid modifying collections. + // This may be called in a thread. + + var all = new Dictionary(); + var allByDbfId = new Dictionary(); + var collectible = new Dictionary(); + var collectibleByDbfId = new Dictionary(); + var baconPoolMinions = new Dictionary(); + var baconPoolMinionsByDbfId = new Dictionary(); + + Build = cardDefs.Build; + + var baconTriples = new List<(Card, int)>(); + var nonBaconTriples = new List<(Card, int)>(); + foreach (var entity in cardDefs.Entites) { - var xml = new XmlSerializer(typeof(CardDefs.CardDefs)); - var cardDefs = (CardDefs.CardDefs)xml.Deserialize(tr); - var baconTriples = new List<(Card, int)>(); - var nonBaconTriples = new List<(Card, int)>(); - foreach(var entity in cardDefs.Entites) + // For some reason Deflect-o-bot is missing divine shield + if (IsDeflectOBot(entity) && !entity.Tags.Any(x => x.EnumId == (int)GameTag.DIVINE_SHIELD)) + entity.Tags.Add(new Tag { EnumId = (int)GameTag.DIVINE_SHIELD, Value = 1 }); + + var card = new Card(entity); + all[entity.CardId] = card; + allByDbfId[entity.DbfId] = card; + if (card.Collectible && (card.Type != CardType.HERO || card.Set != CardSet.HERO_SKINS)) { - // For some reason Deflect-o-bot is missing divine shield - if (IsDeflectOBot(entity) && !entity.Tags.Any(x => x.EnumId == (int)GameTag.DIVINE_SHIELD)) - entity.Tags.Add(new Tag { EnumId = (int)GameTag.DIVINE_SHIELD, Value = 1 }); - - var card = new Card(entity); - All.Add(entity.CardId, card); - AllByDbfId.Add(entity.DbfId, card); - if (card.Collectible && (card.Type != CardType.HERO || card.Set != CardSet.HERO_SKINS)) - { - Collectible.Add(entity.CardId, card); - CollectibleByDbfId.Add(entity.DbfId, card); - } + collectible[entity.CardId] = card; + collectibleByDbfId[entity.DbfId] = card; + } - if (card.IsBaconPoolMinion) + if (card.IsBaconPoolMinion) + { + baconPoolMinions[entity.CardId] = card; + baconPoolMinionsByDbfId[entity.DbfId] = card; + } + + if (!IgnoreTripleIds.Contains(entity.CardId)) + { + var tripleDbfId = card.Entity.Tags.FirstOrDefault(x => x.EnumId == 1429); + if (tripleDbfId != null) { - BaconPoolMinions.Add(entity.CardId, card); - BaconPoolMinionsByDbfId.Add(entity.DbfId, card); + if (card.IsBaconPoolMinion) + baconTriples.Add((card, tripleDbfId.Value)); + else + nonBaconTriples.Add((card, tripleDbfId.Value)); } + } + } + + All = all; + AllByDbfId = allByDbfId; + Collectible = collectible; + CollectibleByDbfId = collectibleByDbfId; + BaconPoolMinions = baconPoolMinions; + BaconPoolMinionsByDbfId = baconPoolMinionsByDbfId; + + var normalToTripleCardIds = new Dictionary(); + var tripleToNormalCardIds = new Dictionary(); + var normalToTripleDbfIds = new Dictionary(); + var tripleToNormalDbfIds = new Dictionary(); + + // Triples have to be resolved after the first loop since we need to look up the triple card from the id + // Loop over non-bacon first in case both contain a mapping to the same card. + // We want to use the bacon one in that case. + foreach (var (card, tripleDbfId) in nonBaconTriples.Concat(baconTriples)) + { + if (!AllByDbfId.TryGetValue(tripleDbfId, out var triple)) + continue; + normalToTripleCardIds[card.Id] = triple.Id; + normalToTripleDbfIds[card.DbfId] = triple.DbfId; + tripleToNormalCardIds[triple.Id] = card.Id; + tripleToNormalDbfIds[triple.DbfId] = card.DbfId; + } - if (!IgnoreTripleIds.Contains(entity.CardId)) + NormalToTripleCardIds = normalToTripleCardIds; + TripleToNormalCardIds = tripleToNormalCardIds; + NormalToTripleDbfIds = normalToTripleDbfIds; + TripleToNormalDbfIds = tripleToNormalDbfIds; + + } + + /// + /// Load additional locale specific card data (names, text, ...). + /// LoadBaseData() must have been previously called if + /// Config.AutoLoadCardDefs (enabled by default) was disabled. + /// + public static void LoadLocaleData(Stream cardDefsData, Locale locale) => LoadLocaleData(ParseCardDefs(cardDefsData), locale); + + /// + /// Load additional locale specific card data (names, text, ...). + /// LoadBaseData() must have been previously called if + /// Config.AutoLoadCardDefs (enabled by default) was disabled. + /// + public static void LoadLocaleData(CardDefs.CardDefs cardDefs, Locale locale) + { + foreach (var entity in cardDefs.Entites) + { + if (!All.TryGetValue(entity.CardId, out var curr)) + continue; + foreach (var tag in entity.Tags) + { + var currTag = curr.Entity.Tags.FirstOrDefault(x => x.EnumId == tag.EnumId); + if (currTag == null) + curr.Entity.Tags.Add(tag); + else { - var tripleDbfId = card.Entity.Tags.FirstOrDefault(x => x.EnumId == 1429); - if (tripleDbfId != null) + switch (locale) { - if(card.IsBaconPoolMinion) - baconTriples.Add((card, tripleDbfId.Value)); - else - nonBaconTriples.Add((card, tripleDbfId.Value)); + case Locale.deDE: + currTag.LocStringDeDe = tag.LocStringDeDe; + break; + case Locale.enUS: + currTag.LocStringEnUs = tag.LocStringEnUs; + break; + case Locale.esES: + currTag.LocStringEsEs = tag.LocStringEsEs; + break; + case Locale.esMX: + currTag.LocStringEsMx = tag.LocStringEsMx; + break; + case Locale.frFR: + currTag.LocStringFrFr = tag.LocStringFrFr; + break; + case Locale.itIT: + currTag.LocStringItIt = tag.LocStringItIt; + break; + case Locale.jaJP: + currTag.LocStringJaJp = tag.LocStringJaJp; + break; + case Locale.koKR: + currTag.LocStringKoKr = tag.LocStringKoKr; + break; + case Locale.plPL: + currTag.LocStringPlPl = tag.LocStringPlPl; + break; + case Locale.ptBR: + currTag.LocStringPtBr = tag.LocStringPtBr; + break; + case Locale.ruRU: + currTag.LocStringRuRu = tag.LocStringRuRu; + break; + case Locale.zhCN: + currTag.LocStringZhCn = tag.LocStringZhCn; + break; + case Locale.zhTW: + currTag.LocStringZhTw = tag.LocStringZhTw; + break; + case Locale.thTH: + currTag.LocStringThTh = tag.LocStringThTh; + break; } } } - - // Triples have to be resolved after the first loop since we need to look up the triple card from the id - // Loop over non-bacon first in case both contain a mapping to the same card. - // We want to use the bacon one in that case. - foreach (var (card, tripleDbfId) in nonBaconTriples.Concat(baconTriples)) - { - if (!AllByDbfId.TryGetValue(tripleDbfId, out var triple)) - continue; - NormalToTripleCardIds[card.Id] = triple.Id; - NormalToTripleDbfIds[card.DbfId] = triple.DbfId; - TripleToNormalCardIds[triple.Id] = card.Id; - TripleToNormalDbfIds[triple.DbfId] = card.DbfId; - } } } diff --git a/HearthDb/Config.cs b/HearthDb/Config.cs new file mode 100644 index 00000000..d7b92edc --- /dev/null +++ b/HearthDb/Config.cs @@ -0,0 +1,12 @@ +namespace HearthDb +{ + public static class Config + { + /// + /// Automatically and synchronously load card data included with HearthDB. + /// By default, this will be all non-localized card metadata, as well as localized strings in enUS and zhCN. + /// If disabled, card data needs to be explicitly loaded via HearthDb.Cards.LoadBaseData + /// + public static bool AutoLoadCardDefs { get; set; } = true; + } +} \ No newline at end of file diff --git a/HearthDb/HearthDb.csproj b/HearthDb/HearthDb.csproj index 5bfb2d56..adf72afe 100644 --- a/HearthDb/HearthDb.csproj +++ b/HearthDb/HearthDb.csproj @@ -1,4 +1,4 @@ - + netstandard2.0 @@ -17,22 +17,28 @@ - - $(RootNamespace).CardDefs.xml + + $(RootNamespace).CardDefs.base.xml + + $(RootNamespace).CardDefs.base.etag + + + + + + <_Parameter1>HearthDb.Tests + - - - - - + + $(SolutionDir)HearthDb.CardDefsDownloader/bin/Release/netcoreapp3.1/ + - - - - - - + + + + + diff --git a/README.md b/README.md index cf867fd2..d594ba9d 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,13 @@ Full deck strings documentation can be found here: https://hearthsim.info/docs/d See [here](https://github.com/HearthSim/HearthDb/blob/master/HearthDb.Tests/UnitTest1.cs#L14-L25) for example usage. +### Localized strings +By default, HearthDb only loads locale data for enUS and zhCN. Additional language +data can be downloaded from `api.hearthstonejson.com`, e.g. `https://api.hearthstonejson.com/v1/latest/CardDefs.deDE.xml` and loaded at runtime via +`HearthDb.Cards.LoadLocaleData(...)`. + +If desired, all language data can be included by default by replacing the url in `HearthDb.CardDefsDownloader` with `https://github.com/HearthSim/hsdata/blob/master/CardDefs.xml`. + ## CardIDs `HearthDb.CardIds` contains properly named constant for all cardIds existing in Hearthstone.