Add MrGameEng.Mods: mod loading, JSON defs and localization
CI / build-test (push) Failing after 1m8s
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:
co-authored by
Claude Fable 5
parent
501d81e19f
commit
5d3c18de40
@@ -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>
|
||||
Reference in New Issue
Block a user