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,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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user