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