Add MrGameEng.Mods: mod loading, JSON defs and localization
CI / build-test (push) Failing after 1m8s

Mods are folders with About/About.json metadata; ModLoader resolves a
deterministic load order (dependencies first, ties alphabetical) and
later mods override earlier ones everywhere.

DefDatabase loads JSON def files ({ "type", "defs": [...] }) into
game-registered Def subclasses, with parent inheritance (own fields on
top of the parent's, nested values replaced whole), abstract parents
and full replacement of same-named defs by later mods.

LanguageManager loads Languages/<code>/*.json flat key-string maps,
switches language at runtime and falls back current -> default -> key.

ModContentTree merges one content folder across mods by relative path;
AtlasBuilder gains an explicit-sources Build overload so a merged
texture tree can be packed incrementally at game start.

The LittleSim game now ships its entire content as the Core mod,
demonstrating the module end to end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-06-11 21:51:17 +03:00
co-authored by Claude Fable 5
parent 501d81e19f
commit 5d3c18de40
16 changed files with 1114 additions and 11 deletions
@@ -0,0 +1,139 @@
using Xunit;
namespace MrGameEng.Mods.Tests;
public sealed class DefDatabaseTests : IDisposable
{
private sealed class AnimalDef : Def
{
public float Speed { get; init; }
public int Legs { get; init; } = 4;
public List<string> Tags { get; init; } = [];
}
private readonly string _root = Directory.CreateTempSubdirectory("mrge-defs-tests-").FullName;
private int _modCounter;
public void Dispose() => Directory.Delete(_root, recursive: true);
/// <summary>Создаёт мод с одним файлом дефов и возвращает его (порядок загрузки = порядок создания).</summary>
private Mod WriteDefsMod(string defsJson)
{
var id = $"mod{_modCounter++:D2}";
var modDir = Path.Combine(_root, id);
Directory.CreateDirectory(Path.Combine(modDir, "Defs"));
File.WriteAllText(Path.Combine(modDir, "Defs", "animals.json"), defsJson);
return new Mod(new ModInfo { Id = id }, modDir);
}
private DefDatabase LoadAnimals(params Mod[] mods)
{
var database = new DefDatabase();
database.RegisterType<AnimalDef>("Animal");
database.Load(mods);
return database;
}
[Fact]
public void Load_ParsesDefs_AndAllIsSortedByName()
{
var mod = WriteDefsMod(
"""
{ "type": "Animal", "defs": [
{ "defName": "Wolf", "label": "волк", "speed": 9 },
{ "defName": "Bear", "speed": 6 }
]}
""");
var database = LoadAnimals(mod);
Assert.Equal(["Bear", "Wolf"], database.All<AnimalDef>().Select(d => d.DefName));
Assert.Equal(9f, database.Get<AnimalDef>("Wolf").Speed);
Assert.Equal("волк", database.Get<AnimalDef>("Wolf").Label);
Assert.Equal(4, database.Get<AnimalDef>("Bear").Legs); // значение по умолчанию
}
[Fact]
public void Load_ParentChain_MergesFields_ChildWins()
{
var mod = WriteDefsMod(
"""
{ "type": "Animal", "defs": [
{ "defName": "BaseAnimal", "abstract": true, "speed": 5, "tags": ["wild"] },
{ "defName": "Hare", "parent": "BaseAnimal", "speed": 12 },
{ "defName": "Snail", "parent": "BaseAnimal", "legs": 0, "tags": ["slow", "slimy"] }
]}
""");
var database = LoadAnimals(mod);
var hare = database.Get<AnimalDef>("Hare");
Assert.Equal(12f, hare.Speed); // своё поле победило
Assert.Equal(["wild"], hare.Tags); // унаследовано
var snail = database.Get<AnimalDef>("Snail");
Assert.Equal(5f, snail.Speed); // унаследовано
Assert.Equal(0, snail.Legs);
Assert.Equal(["slow", "slimy"], snail.Tags); // массив заменён целиком
Assert.False(database.TryGet<AnimalDef>("BaseAnimal", out _)); // абстрактный не эмитится
}
[Fact]
public void Load_LaterMod_ReplacesSameDefName()
{
var core = WriteDefsMod(
"""{ "type": "Animal", "defs": [ { "defName": "Wolf", "speed": 9, "tags": ["wild"] } ] }""");
var patch = WriteDefsMod(
"""{ "type": "Animal", "defs": [ { "defName": "Wolf", "speed": 20 } ] }""");
var database = LoadAnimals(core, patch);
var wolf = database.Get<AnimalDef>("Wolf");
Assert.Equal(20f, wolf.Speed);
Assert.Empty(wolf.Tags); // полная замена, не слияние с дефом раннего мода
}
[Fact]
public void Load_CyclicParents_Throws()
{
var mod = WriteDefsMod(
"""
{ "type": "Animal", "defs": [
{ "defName": "A", "parent": "B" },
{ "defName": "B", "parent": "A" }
]}
""");
var exception = Assert.Throws<InvalidDataException>(() => LoadAnimals(mod));
Assert.Contains("Cyclic", exception.Message);
}
[Fact]
public void Load_UnknownParent_Throws()
{
var mod = WriteDefsMod("""{ "type": "Animal", "defs": [ { "defName": "X", "parent": "Ghost" } ] }""");
var exception = Assert.Throws<InvalidDataException>(() => LoadAnimals(mod));
Assert.Contains("Ghost", exception.Message);
}
[Fact]
public void Load_UnknownDefType_ThrowsWithFileAndMod()
{
var mod = WriteDefsMod("""{ "type": "Vehicle", "defs": [] }""");
var exception = Assert.Throws<InvalidDataException>(() => LoadAnimals(mod));
Assert.Contains("Vehicle", exception.Message);
Assert.Contains(mod.Id, exception.Message);
}
[Fact]
public void Get_UnknownDef_Throws_TryGetReturnsFalse()
{
var database = LoadAnimals(WriteDefsMod("""{ "type": "Animal", "defs": [] }"""));
Assert.Throws<KeyNotFoundException>(() => database.Get<AnimalDef>("Dodo"));
Assert.False(database.TryGet<AnimalDef>("Dodo", out _));
}
}
@@ -0,0 +1,93 @@
using Xunit;
namespace MrGameEng.Mods.Tests;
public sealed class LanguageManagerTests : IDisposable
{
private readonly string _root = Directory.CreateTempSubdirectory("mrge-lang-tests-").FullName;
private int _modCounter;
public void Dispose() => Directory.Delete(_root, recursive: true);
private Mod WriteLanguageMod(string code, string fileName, string json)
{
var id = $"mod{_modCounter++:D2}";
var modDir = Path.Combine(_root, id);
var languageDir = Path.Combine(modDir, "Languages", code);
Directory.CreateDirectory(languageDir);
File.WriteAllText(Path.Combine(languageDir, fileName), json);
return new Mod(new ModInfo { Id = id }, modDir);
}
[Fact]
public void Get_UsesCurrentLanguage_FallsBackToDefault_ThenKey()
{
var mod = WriteLanguageMod("en", "ui.json", """{ "hud.title": "Title", "hud.only-en": "English only" }""");
var ruDir = Path.Combine(mod.RootPath, "Languages", "ru");
Directory.CreateDirectory(ruDir);
File.WriteAllText(Path.Combine(ruDir, "ui.json"), """{ "hud.title": "Заголовок" }""");
var languages = new LanguageManager(defaultLanguage: "en");
languages.Load([mod]);
Assert.True(languages.SetLanguage("ru"));
Assert.Equal("Заголовок", languages.Get("hud.title"));
Assert.Equal("English only", languages.Get("hud.only-en")); // fallback на en
Assert.Equal("hud.missing", languages.Get("hud.missing")); // ключ виден, не краш
}
[Fact]
public void Load_LaterMod_OverridesKey()
{
var core = WriteLanguageMod("en", "ui.json", """{ "hud.title": "Core", "hud.other": "Other" }""");
var patch = WriteLanguageMod("en", "ui.json", """{ "hud.title": "Patched" }""");
var languages = new LanguageManager();
languages.Load([core, patch]);
Assert.Equal("Patched", languages.Get("hud.title"));
Assert.Equal("Other", languages.Get("hud.other"));
}
[Fact]
public void SetLanguage_UnknownCode_ReturnsFalse_AndKeepsCurrent()
{
var languages = new LanguageManager();
languages.Load([WriteLanguageMod("en", "ui.json", """{ "k": "v" }""")]);
Assert.False(languages.SetLanguage("fr"));
Assert.Equal("en", languages.CurrentLanguage);
Assert.Equal(["en"], languages.AvailableLanguages);
}
[Fact]
public void Format_SubstitutesArguments()
{
var languages = new LanguageManager();
languages.Load([WriteLanguageMod("en", "ui.json", """{ "hud.pop": "population {0}" }""")]);
Assert.Equal("population 80", languages.Format("hud.pop", 80));
}
[Fact]
public void Load_NonStringValue_Throws()
{
var mod = WriteLanguageMod("en", "ui.json", """{ "bad": { "nested": true } }""");
var languages = new LanguageManager();
Assert.Throws<InvalidDataException>(() => languages.Load([mod]));
}
[Fact]
public void Revision_Bumps_OnLoadAndLanguageSwitch()
{
var languages = new LanguageManager();
var before = languages.Revision;
languages.Load([WriteLanguageMod("ru", "ui.json", """{ "k": "v" }""")]);
Assert.NotEqual(before, languages.Revision);
before = languages.Revision;
languages.SetLanguage("ru");
Assert.NotEqual(before, languages.Revision);
}
}
@@ -0,0 +1,114 @@
using Xunit;
namespace MrGameEng.Mods.Tests;
public sealed class ModLoaderTests : IDisposable
{
private readonly string _root = Directory.CreateTempSubdirectory("mrge-mods-tests-").FullName;
private string ModsRoot => Path.Combine(_root, "Mods");
public void Dispose() => Directory.Delete(_root, recursive: true);
/// <summary>Создаёт мод с About.json; зависимости — id модов, которые должны грузиться раньше.</summary>
private void WriteMod(string id, params string[] dependencies)
{
var aboutDir = Path.Combine(ModsRoot, id, "About");
Directory.CreateDirectory(aboutDir);
var deps = string.Join(", ", dependencies.Select(d => $"\"{d}\""));
File.WriteAllText(
Path.Combine(aboutDir, "About.json"),
$$"""{ "id": "{{id}}", "name": "{{id}} mod", "version": "1.0", "dependencies": [{{deps}}] }""");
}
[Fact]
public void Load_OrdersDependenciesFirst_ThenAlphabetically()
{
WriteMod("zebra", "Core");
WriteMod("apple", "Core");
WriteMod("Core");
var mods = ModLoader.Load(ModsRoot);
Assert.Equal(["Core", "apple", "zebra"], mods.Select(m => m.Id));
}
[Fact]
public void Load_MissingDependency_Throws()
{
WriteMod("orphan", "NoSuchMod");
var exception = Assert.Throws<InvalidDataException>(() => ModLoader.Load(ModsRoot));
Assert.Contains("NoSuchMod", exception.Message);
}
[Fact]
public void Load_CyclicDependency_Throws()
{
WriteMod("a", "b");
WriteMod("b", "a");
var exception = Assert.Throws<InvalidDataException>(() => ModLoader.Load(ModsRoot));
Assert.Contains("Cyclic", exception.Message);
}
[Fact]
public void Load_ActiveSubset_LoadsOnlyRequested()
{
WriteMod("Core");
WriteMod("extra", "Core");
WriteMod("unused");
var mods = ModLoader.Load(ModsRoot, ["extra", "Core"]);
Assert.Equal(["Core", "extra"], mods.Select(m => m.Id));
}
[Fact]
public void Load_IgnoresDirectoriesWithoutAbout()
{
WriteMod("Core");
Directory.CreateDirectory(Path.Combine(ModsRoot, "not-a-mod"));
var mod = Assert.Single(ModLoader.Load(ModsRoot));
Assert.Equal("Core", mod.Id);
Assert.Equal("Core mod", mod.Info.Name);
}
[Fact]
public void FindModsRoot_WalksUpFromNestedDirectory()
{
WriteMod("Core");
var nested = Path.Combine(_root, "bin", "Debug", "net8.0");
Directory.CreateDirectory(nested);
Assert.Equal(ModsRoot, ModLoader.FindModsRoot(nested));
}
[Fact]
public void ContentTree_LaterMod_OverridesSameRelativePath()
{
WriteMod("Core");
WriteMod("patch", "Core");
File.WriteAllText(CreateContentFile("Core", "Textures", "things/rock.png"), "core");
File.WriteAllText(CreateContentFile("Core", "Textures", "things/tree.png"), "core");
File.WriteAllText(CreateContentFile("patch", "Textures", "things/rock.png"), "patched");
var mods = ModLoader.Load(ModsRoot);
var tree = ModContentTree.Build(mods, "Textures", ".png");
Assert.Equal(2, tree.Files.Count);
Assert.True(tree.TryGet("things/rock.png", out var rock));
Assert.Equal("patch", rock.Mod.Id);
Assert.Equal("patched", File.ReadAllText(rock.FullPath));
Assert.True(tree.TryGet("things/tree.png", out var oak));
Assert.Equal("Core", oak.Mod.Id);
}
private string CreateContentFile(string modId, string folder, string relativePath)
{
var fullPath = Path.Combine(ModsRoot, modId, folder, relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
return fullPath;
}
}
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\MrGameEng.Mods\MrGameEng.Mods.csproj" />
</ItemGroup>
</Project>