Skip to content

Commit

Permalink
feat!: split CardDefs by language
Browse files Browse the repository at this point in the history
BREAKING CHANGE: data for languages other than enUS must now be loaded
separately at runtime. See README.
  • Loading branch information
azeier committed Jan 26, 2025
1 parent 01105a2 commit 95480ae
Show file tree
Hide file tree
Showing 10 changed files with 264 additions and 59 deletions.
40 changes: 40 additions & 0 deletions HearthDb.Tests/CardDefsLoadTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using System.IO;
using HearthDb.Enums;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace HearthDb.Tests
{
[TestClass]
public class CardDefsLoadTest
{
[TestInitialize]
public void Init()
{
Cards.ClearData();
}

[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));
}
}
}
23 changes: 23 additions & 0 deletions HearthDb.Tests/Data/TestCardDefs.base.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<CardDefs build="212197">
<Entity CardID="AT_001" ID="2539" version="2">
<Tag enumID="185" name="CARDNAME" type="LocString">
<enUS>Flame Lance</enUS>
</Tag>
<Tag enumID="184" name="CARDTEXT" type="LocString">
<enUS>Deal $25 damage
to a minion.</enUS>
</Tag>
<Tag enumID="351" name="FLAVORTEXT" type="LocString">
<enUS>It's on the rack next to ice lance, acid lance, and English muffin lance.</enUS>
</Tag>
<Tag enumID="342" name="ARTISTNAME" type="String">Nutthapon Petchthai</Tag>
<Tag enumID="48" name="COST" type="Int" value="5"/>
<Tag enumID="183" name="CARD_SET" type="Int" value="15"/>
<Tag enumID="199" name="CLASS" type="Int" value="4"/>
<Tag enumID="202" name="CARDTYPE" type="Int" value="5"/>
<Tag enumID="203" name="RARITY" type="Int" value="1"/>
<Tag enumID="321" name="COLLECTIBLE" type="Int" value="1"/>
<Tag enumID="1635" name="SPELL_SCHOOL" type="Int" value="2"/>
</Entity>
</CardDefs>
14 changes: 14 additions & 0 deletions HearthDb.Tests/Data/TestCardDefs.deDE.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<CardDefs build="212197">
<Entity CardID="AT_001" ID="2539" version="2">
<Tag enumID="185" name="CARDNAME" type="LocString">
<deDE>Flammenlanze</deDE>
</Tag>
<Tag enumID="184" name="CARDTEXT" type="LocString">
<deDE>Fügt einem Diener $25 Schaden zu.</deDE>
</Tag>
<Tag enumID="351" name="FLAVORTEXT" type="LocString">
<deDE>Die Sommerversion der Eislanze. Gut gegen chronisch kalte Hände.</deDE>
</Tag>
</Entity>
</CardDefs>
4 changes: 4 additions & 0 deletions HearthDb.Tests/HearthDb.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,8 @@
<ProjectReference Include="..\HearthDb\HearthDb.csproj" />
</ItemGroup>

<ItemGroup>
<None Update="Data/*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>

</Project>
5 changes: 3 additions & 2 deletions HearthDb.Tests/UnitTest1.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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"));
Expand Down
4 changes: 4 additions & 0 deletions HearthDb/CardDefs/CardDefs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,9 @@ public class CardDefs
{
[XmlElement("Entity")]
public List<Entity> Entites { get; set; }


[XmlAttribute("build")]
public string Build { get; set; }
}
}
188 changes: 143 additions & 45 deletions HearthDb/Cards.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,66 +37,164 @@ public static class Cards
CardIds.NonCollectible.Neutral.SeabreakerGoliathGILNEAS
};

static Cards()
private static readonly XmlSerializer CardDefsSerializer = new XmlSerializer(typeof(CardDefs.CardDefs));

public static string Build { get; private set; }

internal static void ClearData()
{
All.Clear();
AllByDbfId.Clear();
Collectible.Clear();
CollectibleByDbfId.Clear();
BaconPoolMinions.Clear();
BaconPoolMinionsByDbfId.Clear();
NormalToTripleCardIds.Clear();
TripleToNormalCardIds.Clear();
NormalToTripleDbfIds.Clear();
TripleToNormalCardIds.Clear();
}

public static CardDefs.CardDefs ParseCardDefs(Stream cardDefsData)
{
var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream("HearthDb.CardDefs.xml");
if(stream == null)
return;
using(TextReader tr = new StreamReader(stream))
return (CardDefs.CardDefs)CardDefsSerializer.Deserialize(cardDefsData);
}

public static void LoadBaseData()
{
var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream("HearthDb.CardDefs.base.xml");
if(stream != null)
LoadBaseData(stream);
}

public static void LoadBaseData(Stream cardDefsData) => LoadBaseData(ParseCardDefs(cardDefsData));

public static void LoadBaseData(CardDefs.CardDefs cardDefs)
{
ClearData();
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));
}
}
}

// 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;
}
}

public static void LoadLocaleData(Stream cardDefsData, Locale locale) => LoadLocaleData(ParseCardDefs(cardDefsData), locale);

if (!IgnoreTripleIds.Contains(entity.CardId))
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;
}
}
}

static Cards()
{
if(Config.AutoLoadCardDefs)
LoadBaseData();
}

/// <summary>
/// Will try to return the card ID of the triple. Returns the normal card id if it cannot be found.
/// </summary>
Expand Down
7 changes: 7 additions & 0 deletions HearthDb/Config.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace HearthDb
{
public static class Config
{
public static bool AutoLoadCardDefs { get; set; } = true;
}
}
29 changes: 17 additions & 12 deletions HearthDb/HearthDb.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,27 @@
<EmbeddedResource Remove="hsdata/**" />
<None Remove="hsdata/**" />

<EmbeddedResource Include="hsdata/CardDefs.xml">
<LogicalName>$(RootNamespace).CardDefs.xml</LogicalName>
<EmbeddedResource Include="hsdata/CardDefs.base.xml">
<LogicalName>$(RootNamespace).CardDefs.base.xml</LogicalName>
</EmbeddedResource>
</ItemGroup>

<!-- Clone hsdata into a subdirectory of the project if it isn't there already -->
<Target Name="CloneHSData" Condition="!Exists('$(MSBuildProjectDirectory)/hsdata')" BeforeTargets="PreBuildEvent">
<Message Importance="normal" Text="Couldn't find hsdata repo." />
<Exec WorkingDirectory="$(MSBuildProjectDirectory)" Command="git clone --depth=1 https://github.com/HearthSim/hsdata.git hsdata" />
</Target>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>HearthDb.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>

<PropertyGroup>
<!-- CardDefs.base.xml contains non localized tags and enUS tag -->
<!-- Other languages can be found under e.g. CardDefs.deDE.xml -->
<BaseDataUrl>https://api.hearthstonejson.com/v1/latest/CardDefs.base.xml</BaseDataUrl>
</PropertyGroup>

<!-- If the hsdata repo is already there, fetch the latest. -->
<Target Name="UpdateHSData" Condition="Exists('$(MSBuildProjectDirectory)/hsdata')" BeforeTargets="PreBuildEvent">
<Message Importance="normal" Text="Fetching latest card data from hsdata repo." />
<Exec WorkingDirectory="$(MSBuildProjectDirectory)/hsdata" Command="git fetch" />
<Exec WorkingDirectory="$(MSBuildProjectDirectory)/hsdata" Command="git reset --hard origin/master" />
<Target Name="DownloadBaseData" BeforeTargets="PreBuildEvent">
<DownloadFile SourceUrl="$(BaseDataUrl)" DestinationFolder="$(MSBuildProjectDirectory)/hsdata" SkipUnchangedFiles="true">
<Output TaskParameter="DownloadedFile" ItemName="Content" />
</DownloadFile>
</Target>

</Project>
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ 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
`<BaseDataUrl>` in `HearthDb.csproj` with `https://github.
com/HearthSim/hsdata/blob/master/CardDefs.xml`.

## CardIDs
`HearthDb.CardIds` contains properly named constant for all cardIds existing in Hearthstone.

Expand Down

0 comments on commit 95480ae

Please sign in to comment.