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
+4 -1
View File
@@ -29,7 +29,10 @@ after `UseRenderer2D()`), `Pathfinding` (grid A*/Dijkstra/BFS and flow fields ov
game-implemented `IPathGrid`; Core-only, owns no world data), `Collisions` (`Collider`
component, spatial hash rebuilt per tick, pairs/queries/raycast; `scene.UseCollisions()`
after movement systems), `UI` (Myra integration: `scene.UseUI()` after `UseRenderer2D()`),
`DevConsole` (in-game console capturing `Core.Log`; `scene.UseDevConsole()` last in OnLoad).
`DevConsole` (in-game console capturing `Core.Log`; `scene.UseDevConsole()` last in OnLoad),
`Mods` (mod discovery + load order from `About/About.json`, JSON `Defs/` with parent
inheritance and later-mod override, `Languages/<code>/` localization, merged content
trees for textures; the game ships its own content as the `Core` mod).
Dependency rule: every module may depend only on `Core`; `Core` depends only on
MonoGame and Friflo.Engine.ECS. `Assets.Generator` is a netstandard2.0 analyzer.
Documented exceptions: Myra renders with its own SpriteBatch internally; `Atlases`
+30
View File
@@ -53,6 +53,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Pathfinding.Tests
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Collisions.Tests", "tests\MrGameEng.Collisions.Tests\MrGameEng.Collisions.Tests.csproj", "{B8C132F5-C4C8-4931-B0CE-885811F44DB0}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Mods", "src\MrGameEng.Mods\MrGameEng.Mods.csproj", "{1EB7D51B-CF32-4EB3-881B-C20AED6ED592}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Mods.Tests", "tests\MrGameEng.Mods.Tests\MrGameEng.Mods.Tests.csproj", "{1C0BBFAC-6A4F-4541-8703-E9C77F637BAF}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -327,6 +331,30 @@ Global
{B8C132F5-C4C8-4931-B0CE-885811F44DB0}.Release|x64.Build.0 = Release|Any CPU
{B8C132F5-C4C8-4931-B0CE-885811F44DB0}.Release|x86.ActiveCfg = Release|Any CPU
{B8C132F5-C4C8-4931-B0CE-885811F44DB0}.Release|x86.Build.0 = Release|Any CPU
{1EB7D51B-CF32-4EB3-881B-C20AED6ED592}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1EB7D51B-CF32-4EB3-881B-C20AED6ED592}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1EB7D51B-CF32-4EB3-881B-C20AED6ED592}.Debug|x64.ActiveCfg = Debug|Any CPU
{1EB7D51B-CF32-4EB3-881B-C20AED6ED592}.Debug|x64.Build.0 = Debug|Any CPU
{1EB7D51B-CF32-4EB3-881B-C20AED6ED592}.Debug|x86.ActiveCfg = Debug|Any CPU
{1EB7D51B-CF32-4EB3-881B-C20AED6ED592}.Debug|x86.Build.0 = Debug|Any CPU
{1EB7D51B-CF32-4EB3-881B-C20AED6ED592}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1EB7D51B-CF32-4EB3-881B-C20AED6ED592}.Release|Any CPU.Build.0 = Release|Any CPU
{1EB7D51B-CF32-4EB3-881B-C20AED6ED592}.Release|x64.ActiveCfg = Release|Any CPU
{1EB7D51B-CF32-4EB3-881B-C20AED6ED592}.Release|x64.Build.0 = Release|Any CPU
{1EB7D51B-CF32-4EB3-881B-C20AED6ED592}.Release|x86.ActiveCfg = Release|Any CPU
{1EB7D51B-CF32-4EB3-881B-C20AED6ED592}.Release|x86.Build.0 = Release|Any CPU
{1C0BBFAC-6A4F-4541-8703-E9C77F637BAF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1C0BBFAC-6A4F-4541-8703-E9C77F637BAF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1C0BBFAC-6A4F-4541-8703-E9C77F637BAF}.Debug|x64.ActiveCfg = Debug|Any CPU
{1C0BBFAC-6A4F-4541-8703-E9C77F637BAF}.Debug|x64.Build.0 = Debug|Any CPU
{1C0BBFAC-6A4F-4541-8703-E9C77F637BAF}.Debug|x86.ActiveCfg = Debug|Any CPU
{1C0BBFAC-6A4F-4541-8703-E9C77F637BAF}.Debug|x86.Build.0 = Debug|Any CPU
{1C0BBFAC-6A4F-4541-8703-E9C77F637BAF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1C0BBFAC-6A4F-4541-8703-E9C77F637BAF}.Release|Any CPU.Build.0 = Release|Any CPU
{1C0BBFAC-6A4F-4541-8703-E9C77F637BAF}.Release|x64.ActiveCfg = Release|Any CPU
{1C0BBFAC-6A4F-4541-8703-E9C77F637BAF}.Release|x64.Build.0 = Release|Any CPU
{1C0BBFAC-6A4F-4541-8703-E9C77F637BAF}.Release|x86.ActiveCfg = Release|Any CPU
{1C0BBFAC-6A4F-4541-8703-E9C77F637BAF}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -354,5 +382,7 @@ Global
{E069CBFD-F4FA-4400-84AE-090F9B81BDFC} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{D9FFA22D-0CFF-4A1E-AD56-BA4BD00526B3} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{B8C132F5-C4C8-4931-B0CE-885811F44DB0} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{1EB7D51B-CF32-4EB3-881B-C20AED6ED592} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{1C0BBFAC-6A4F-4541-8703-E9C77F637BAF} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
EndGlobal
+23 -1
View File
@@ -41,8 +41,30 @@
| `MrGameEng.Collisions` | Определение столкновений: компонент `Collider`, spatial hash, пары/запросы/raycast |
| `MrGameEng.UI` | Игровой UI на [Myra](https://github.com/rds1983/Myra): `Desktop` на сцену, виджеты, скининг |
| `MrGameEng.DevConsole` | Ингейм-консоль разработчика: логи `Log`, команды, история, автодополнение |
| `MrGameEng.Mods` | Система модов: обнаружение и порядок загрузки (`About/About.json`), JSON-дефы с наследованием и переопределением, локализация (`Languages/<код>/`), слияние деревьев контента |
Планируемые модули (по мере развития): `Physics2D`, `Tilemap`, `UI`, `Particles`.
Планируемые модули (по мере развития): `Physics2D`, `Particles`.
### MrGameEng.Mods
Контент игры описывается **модами** — папками вида `Mods/<Id>` с метаданными в
`About/About.json` (id, имя, версия, зависимости). `ModLoader.Load` упорядочивает моды
детерминированно: зависимости раньше зависимых, при равенстве — по алфавиту id. Поздний
мод переопределяет ранние во всех системах контента. Сама игра поставляет свой контент
как мод `Core` — любой другой мод может переопределить её данные.
- **Дефы** (`Defs/**/*.json`): файл-конверт `{ "type": "<ключ>", "defs": [ … ] }`;
CLR-тип на ключ регистрирует игра (`DefDatabase.RegisterType<T>`). Поля: `defName`
(уникален в типе; одноимённый деф позднего мода полностью заменяет ранний), `parent`
(поля родителя как основа, свои — поверх; вложенные объекты заменяются целиком),
`abstract` (только родитель, в базу не попадает; не наследуется), `label`.
- **Локализация** (`Languages/<код>/**/*.json`): плоские словари ключ→строка;
`LanguageManager` переключает язык на лету, недостающие ключи берёт из языка по
умолчанию, в крайнем случае возвращает сам ключ.
- **Деревья контента**: `ModContentTree.Build(mods, "Textures")` сливает одноимённые
папки всех модов (поздний мод побеждает по относительному пути) — результат кормится,
например, в `AtlasBuilder.Build(options, sources)` для инкрементальной сборки атласов
при старте игры.
### Правило зависимостей
+29 -9
View File
@@ -3,11 +3,14 @@ using StbImageWriteSharp;
namespace MrGameEng.Atlases;
/// <summary>Options for one <see cref="AtlasBuilder.Build"/> run.</summary>
/// <summary>Options for one <see cref="AtlasBuilder.Build(AtlasBuildOptions)"/> run.</summary>
public sealed class AtlasBuildOptions
{
/// <summary>Directory scanned recursively for source images (png/jpg/jpeg/bmp).</summary>
public required string SourceDirectory { get; init; }
/// <summary>
/// Directory scanned recursively for source images (png/jpg/jpeg/bmp). Used only by the
/// directory-scanning overload; the explicit-sources overload ignores it.
/// </summary>
public string SourceDirectory { get; init; } = "";
/// <summary>Directory the <c>.atlas</c> metadata and page images are written to.</summary>
public required string OutputDirectory { get; init; }
@@ -38,7 +41,7 @@ public sealed class AtlasBuildOptions
/// <param name="Skipped">True when the atlas was up to date and not rebuilt.</param>
public sealed record AtlasGroupResult(string Name, int RegionCount, int PageCount, bool Skipped);
/// <summary>Result of an <see cref="AtlasBuilder.Build"/> run.</summary>
/// <summary>Result of an <see cref="AtlasBuilder.Build(AtlasBuildOptions)"/> run.</summary>
/// <param name="Groups">Per-atlas outcomes, sorted by name.</param>
/// <param name="DeletedOrphans">Output files of atlases whose source group no longer exists.</param>
public sealed record AtlasBuildResult(IReadOnlyList<AtlasGroupResult> Groups, IReadOnlyList<string> DeletedOrphans);
@@ -57,18 +60,36 @@ public static class AtlasBuilder
/// <summary>Snapshot of one source image taken at scan time (size/mtime feed the staleness check).</summary>
private readonly record struct SourceFile(string FullPath, string Key, long Size, long ModifiedTicks);
/// <summary>Builds (or incrementally refreshes) all atlases for <paramref name="options"/>.</summary>
/// <summary>Builds (or incrementally refreshes) all atlases from <see cref="AtlasBuildOptions.SourceDirectory"/>.</summary>
public static AtlasBuildResult Build(AtlasBuildOptions options)
{
if (string.IsNullOrEmpty(options.SourceDirectory))
{
throw new ArgumentException("SourceDirectory is not set.", nameof(options));
}
var sourceRoot = Path.GetFullPath(options.SourceDirectory);
if (!Directory.Exists(sourceRoot))
{
throw new DirectoryNotFoundException($"Atlas source directory not found: '{sourceRoot}'.");
}
return Build(options, Directory
.EnumerateFiles(sourceRoot, "*", SearchOption.AllDirectories)
.Select(fullPath => (fullPath, Path.GetRelativePath(sourceRoot, fullPath))));
}
/// <summary>
/// Builds (or incrementally refreshes) all atlases from an explicit source list —
/// e.g. a texture tree merged across mods, where files with one relative path may live
/// in different roots. Region keys come from <c>RelativePath</c> without extension.
/// </summary>
public static AtlasBuildResult Build(
AtlasBuildOptions options, IEnumerable<(string FullPath, string RelativePath)> sources)
{
Directory.CreateDirectory(options.OutputDirectory);
var groups = ScanGroups(sourceRoot, options);
var groups = ScanGroups(sources, options);
var results = new List<AtlasGroupResult>();
foreach (var (name, files) in groups)
{
@@ -91,18 +112,17 @@ public static class AtlasBuilder
}
private static SortedDictionary<string, List<SourceFile>> ScanGroups(
string sourceRoot, AtlasBuildOptions options)
IEnumerable<(string FullPath, string RelativePath)> sources, AtlasBuildOptions options)
{
var groups = new SortedDictionary<string, List<SourceFile>>(StringComparer.Ordinal);
var keys = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var fullPath in Directory.EnumerateFiles(sourceRoot, "*", SearchOption.AllDirectories))
foreach (var (fullPath, relative) in sources)
{
if (!SourceExtensions.Contains(Path.GetExtension(fullPath), StringComparer.OrdinalIgnoreCase))
{
continue;
}
var relative = Path.GetRelativePath(sourceRoot, fullPath);
var (atlasName, key) = ClassifyPath(relative, options.GroupDepth, options.RootAtlasName);
if (keys.TryGetValue(key, out var existing))
{
+30
View File
@@ -0,0 +1,30 @@
namespace MrGameEng.Mods;
/// <summary>
/// Base class of all data definitions loaded by <see cref="DefDatabase"/> from mod JSON.
/// Games subclass this per content kind (terrain, things, pawns, …) with plain
/// serializable properties.
/// </summary>
public abstract class Def
{
/// <summary>
/// Unique name within the def type. A later mod redefining the same name fully
/// replaces the earlier def.
/// </summary>
public string DefName { get; init; } = "";
/// <summary>
/// Name of the def (same type) whose fields this def starts from; own fields override
/// the inherited ones. Abstractness is not inherited.
/// </summary>
public string? Parent { get; init; }
/// <summary>Abstract defs only serve as parents and are not emitted into the database.</summary>
public bool Abstract { get; init; }
/// <summary>Display label — plain text or a localization key, as the game decides.</summary>
public string Label { get; init; } = "";
/// <inheritdoc />
public override string ToString() => $"{GetType().Name} {DefName}";
}
+195
View File
@@ -0,0 +1,195 @@
using System.Text.Json;
using System.Text.Json.Nodes;
namespace MrGameEng.Mods;
/// <summary>
/// Database of data definitions loaded from mod JSON. Each file under a mod's
/// <c>Defs/</c> folder is an envelope <c>{ "type": "&lt;key&gt;", "defs": [ … ] }</c>; the
/// game registers the CLR type for every key before <see cref="Load"/>. Defs from later
/// mods replace same-named defs of earlier mods; <c>parent</c> chains are merged
/// field-by-field (own fields win, nested objects are replaced whole); defs marked
/// <c>abstract</c> serve only as parents.
/// </summary>
public sealed class DefDatabase
{
private sealed class TypeEntry
{
public required string Key;
public required Type ClrType;
public readonly Dictionary<string, JsonObject> Raw = new(StringComparer.Ordinal);
public readonly SortedDictionary<string, Def> Resolved = new(StringComparer.Ordinal);
}
private readonly Dictionary<string, TypeEntry> _byKey = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<Type, TypeEntry> _byType = [];
private static readonly JsonDocumentOptions DocumentOptions = new()
{
CommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
/// <summary>Registers the CLR type behind a def-type key (the <c>"type"</c> field of def files).</summary>
public void RegisterType<T>(string typeKey) where T : Def
{
var entry = new TypeEntry { Key = typeKey, ClrType = typeof(T) };
if (!_byKey.TryAdd(typeKey, entry))
{
throw new InvalidOperationException($"Def type '{typeKey}' is already registered.");
}
_byType.Add(typeof(T), entry);
}
/// <summary>
/// Loads every <c>Defs/**/*.json</c> of <paramref name="mods"/> (in load order) and
/// resolves inheritance. Call once after registering all def types.
/// </summary>
public void Load(IReadOnlyList<Mod> mods)
{
foreach (var mod in mods)
{
var defsDir = mod.ContentPath("Defs");
if (!Directory.Exists(defsDir))
{
continue;
}
var files = Directory.EnumerateFiles(defsDir, "*.json", SearchOption.AllDirectories)
.OrderBy(f => f, StringComparer.Ordinal);
foreach (var file in files)
{
LoadFile(mod, file);
}
}
foreach (var entry in _byKey.Values)
{
Resolve(entry);
}
}
/// <summary>Returns the def of type <typeparamref name="T"/> named <paramref name="defName"/>; throws when missing.</summary>
public T Get<T>(string defName) where T : Def =>
TryGet<T>(defName, out var def)
? def
: throw new KeyNotFoundException($"No {typeof(T).Name} def named '{defName}'.");
/// <summary>Returns the def of type <typeparamref name="T"/> named <paramref name="defName"/>, or false.</summary>
public bool TryGet<T>(string defName, out T def) where T : Def
{
if (Entry<T>().Resolved.TryGetValue(defName, out var found))
{
def = (T)found;
return true;
}
def = null!;
return false;
}
/// <summary>All resolved defs of type <typeparamref name="T"/>, sorted by def name (deterministic).</summary>
public IReadOnlyList<T> All<T>() where T : Def => Entry<T>().Resolved.Values.Cast<T>().ToList();
/// <summary>Registered def-type keys, sorted.</summary>
public IReadOnlyList<string> TypeKeys => _byKey.Values.Select(e => e.Key).Order(StringComparer.Ordinal).ToList();
/// <summary>Resolved def names of the given type key, sorted; empty for unknown keys.</summary>
public IReadOnlyList<string> NamesOf(string typeKey) =>
_byKey.TryGetValue(typeKey, out var entry) ? entry.Resolved.Keys.ToList() : [];
private TypeEntry Entry<T>() where T : Def =>
_byType.TryGetValue(typeof(T), out var entry)
? entry
: throw new InvalidOperationException($"Def type {typeof(T).Name} is not registered.");
private void LoadFile(Mod mod, string file)
{
JsonNode root;
try
{
root = JsonNode.Parse(File.ReadAllText(file), documentOptions: DocumentOptions)
?? throw new InvalidDataException("file is empty");
}
catch (JsonException exception)
{
throw new InvalidDataException($"Invalid def file '{file}' (mod '{mod.Id}'): {exception.Message}", exception);
}
var typeKey = root["type"]?.GetValue<string>()
?? throw new InvalidDataException($"Def file '{file}' (mod '{mod.Id}') has no \"type\" field.");
if (!_byKey.TryGetValue(typeKey, out var entry))
{
throw new InvalidDataException(
$"Def file '{file}' (mod '{mod.Id}') uses unknown def type '{typeKey}'; " +
$"registered: {string.Join(", ", TypeKeys)}.");
}
if (root["defs"] is not JsonArray defs)
{
throw new InvalidDataException($"Def file '{file}' (mod '{mod.Id}') has no \"defs\" array.");
}
foreach (var node in defs)
{
if (node is not JsonObject def)
{
throw new InvalidDataException($"Def file '{file}' (mod '{mod.Id}') contains a non-object def entry.");
}
var defName = def["defName"]?.GetValue<string>();
if (string.IsNullOrWhiteSpace(defName))
{
throw new InvalidDataException($"A def in '{file}' (mod '{mod.Id}') has no \"defName\".");
}
entry.Raw[defName] = def; // поздний мод/файл полностью заменяет одноимённый деф
}
}
private static void Resolve(TypeEntry entry)
{
foreach (var defName in entry.Raw.Keys.Order(StringComparer.Ordinal))
{
var merged = MergeChain(entry, defName, []);
if (merged["abstract"]?.GetValue<bool>() == true)
{
continue;
}
var def = (Def?)merged.Deserialize(entry.ClrType, ModInfo.JsonOptions)
?? throw new InvalidDataException($"Def '{defName}' ({entry.Key}) deserialized to null.");
entry.Resolved[defName] = def;
}
}
private static JsonObject MergeChain(TypeEntry entry, string defName, HashSet<string> seen)
{
if (!seen.Add(defName))
{
throw new InvalidDataException($"Cyclic def inheritance involving '{defName}' ({entry.Key}).");
}
if (!entry.Raw.TryGetValue(defName, out var node))
{
throw new InvalidDataException($"Unknown parent def '{defName}' ({entry.Key}).");
}
var parentName = node["parent"]?.GetValue<string>();
if (parentName is null)
{
return (JsonObject)node.DeepClone();
}
var merged = MergeChain(entry, parentName, seen);
merged.Remove("abstract"); // абстрактность не наследуется
merged.Remove("defName");
foreach (var (key, value) in node)
{
merged[key] = value?.DeepClone();
}
return merged;
}
}
+141
View File
@@ -0,0 +1,141 @@
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace MrGameEng.Mods;
/// <summary>
/// Keyed localization strings loaded from mods: <c>Languages/&lt;code&gt;/**/*.json</c>,
/// each file a flat string-to-string map. Later mods override earlier ones key by key.
/// Lookup falls back from the current language to the default one; a missing key returns
/// the key itself, so untranslated strings are visible instead of crashing.
/// </summary>
public sealed class LanguageManager
{
private readonly Dictionary<string, Dictionary<string, string>> _languages =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>Creates a manager whose fallback language is <paramref name="defaultLanguage"/>.</summary>
public LanguageManager(string defaultLanguage = "en")
{
DefaultLanguage = defaultLanguage;
CurrentLanguage = defaultLanguage;
}
/// <summary>Fallback language code.</summary>
public string DefaultLanguage { get; }
/// <summary>Active language code. Change with <see cref="SetLanguage"/>.</summary>
public string CurrentLanguage { get; private set; }
/// <summary>Increments whenever loaded strings or the active language change — UI rebuilds on it.</summary>
public int Revision { get; private set; }
/// <summary>Language codes that have at least one loaded string, sorted.</summary>
public IReadOnlyList<string> AvailableLanguages =>
_languages.Keys.Order(StringComparer.OrdinalIgnoreCase).ToList();
/// <summary>Loads (merges in) language files of <paramref name="mods"/> in load order.</summary>
public void Load(IReadOnlyList<Mod> mods)
{
foreach (var mod in mods)
{
var languagesDir = mod.ContentPath("Languages");
if (!Directory.Exists(languagesDir))
{
continue;
}
foreach (var languageDir in Directory.EnumerateDirectories(languagesDir).OrderBy(d => d, StringComparer.Ordinal))
{
var code = Path.GetFileName(languageDir);
if (!_languages.TryGetValue(code, out var strings))
{
strings = new Dictionary<string, string>(StringComparer.Ordinal);
_languages.Add(code, strings);
}
var files = Directory.EnumerateFiles(languageDir, "*.json", SearchOption.AllDirectories)
.OrderBy(f => f, StringComparer.Ordinal);
foreach (var file in files)
{
LoadFile(mod, file, strings);
}
}
}
Revision++;
}
/// <summary>
/// Switches the active language. Returns false (and keeps the current one) when no
/// strings are loaded for <paramref name="code"/>.
/// </summary>
public bool SetLanguage(string code)
{
if (!_languages.ContainsKey(code))
{
return false;
}
CurrentLanguage = code;
Revision++;
return true;
}
/// <summary>Returns the string for <paramref name="key"/>: current language → default language → the key itself.</summary>
public string Get(string key)
{
if (_languages.TryGetValue(CurrentLanguage, out var current) && current.TryGetValue(key, out var value))
{
return value;
}
if (_languages.TryGetValue(DefaultLanguage, out var fallback) && fallback.TryGetValue(key, out value))
{
return value;
}
return key;
}
/// <summary>Formats the string for <paramref name="key"/> with <paramref name="args"/> (invariant culture).</summary>
public string Format(string key, params object[] args) =>
string.Format(CultureInfo.InvariantCulture, Get(key), args);
private static void LoadFile(Mod mod, string file, Dictionary<string, string> strings)
{
JsonNode root;
try
{
root = JsonNode.Parse(
File.ReadAllText(file),
documentOptions: new JsonDocumentOptions
{
CommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
}) ?? throw new InvalidDataException("file is empty");
}
catch (JsonException exception)
{
throw new InvalidDataException(
$"Invalid language file '{file}' (mod '{mod.Id}'): {exception.Message}", exception);
}
if (root is not JsonObject map)
{
throw new InvalidDataException($"Language file '{file}' (mod '{mod.Id}') must be a flat JSON object.");
}
foreach (var (key, value) in map)
{
if (value is not JsonValue jsonValue || jsonValue.GetValueKind() != JsonValueKind.String)
{
throw new InvalidDataException(
$"Language file '{file}' (mod '{mod.Id}'): key '{key}' must map to a string.");
}
strings[key] = jsonValue.GetValue<string>(); // поздний мод переопределяет ключ
}
}
}
+29
View File
@@ -0,0 +1,29 @@
namespace MrGameEng.Mods;
/// <summary>One discovered mod: its metadata and content directories on disk.</summary>
public sealed class Mod
{
internal Mod(ModInfo info, string rootPath)
{
Info = info;
RootPath = rootPath;
}
/// <summary>Metadata from <c>About/About.json</c>.</summary>
public ModInfo Info { get; }
/// <summary>Absolute path of the mod's root directory.</summary>
public string RootPath { get; }
/// <summary>Unique mod id (shortcut for <c>Info.Id</c>).</summary>
public string Id => Info.Id;
/// <summary>
/// Absolute path of a content folder inside the mod (e.g. <c>Defs</c>, <c>Textures</c>,
/// <c>Languages</c>). The folder is not required to exist.
/// </summary>
public string ContentPath(string folder) => Path.Combine(RootPath, folder);
/// <inheritdoc />
public override string ToString() => $"{Id} {Info.Version}".TrimEnd();
}
+65
View File
@@ -0,0 +1,65 @@
namespace MrGameEng.Mods;
/// <summary>A file contributed by a mod, addressed by its content-relative path.</summary>
/// <param name="RelativePath">Path relative to the content folder, forward slashes.</param>
/// <param name="FullPath">Absolute path of the winning file on disk.</param>
/// <param name="Mod">The mod that contributed the file.</param>
public readonly record struct ModFile(string RelativePath, string FullPath, Mod Mod);
/// <summary>
/// Merged view of one content folder (e.g. <c>Textures</c>) across the active mods in load
/// order: a later mod shipping a file under the same relative path overrides the earlier
/// one. Paths use forward slashes and match case-insensitively (Windows-friendly content).
/// </summary>
public sealed class ModContentTree
{
private readonly Dictionary<string, ModFile> _files;
private ModContentTree(Dictionary<string, ModFile> files, IReadOnlyList<ModFile> ordered)
{
_files = files;
Files = ordered;
}
/// <summary>Winning files, sorted by relative path — deterministic for identical mod sets.</summary>
public IReadOnlyList<ModFile> Files { get; }
/// <summary>Returns the winning file for <paramref name="relativePath"/>.</summary>
public bool TryGet(string relativePath, out ModFile file) =>
_files.TryGetValue(Normalize(relativePath), out file);
/// <summary>
/// Builds the merged tree of <paramref name="contentFolder"/> over <paramref name="mods"/>
/// (in load order). With <paramref name="extensions"/> only matching files are included
/// (e.g. <c>".png"</c>); without them, every file.
/// </summary>
public static ModContentTree Build(IReadOnlyList<Mod> mods, string contentFolder, params string[] extensions)
{
var files = new Dictionary<string, ModFile>(StringComparer.OrdinalIgnoreCase);
foreach (var mod in mods)
{
var root = mod.ContentPath(contentFolder);
if (!Directory.Exists(root))
{
continue;
}
foreach (var fullPath in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories))
{
if (extensions.Length > 0 &&
!extensions.Contains(Path.GetExtension(fullPath), StringComparer.OrdinalIgnoreCase))
{
continue;
}
var relative = Normalize(Path.GetRelativePath(root, fullPath));
files[relative] = new ModFile(relative, fullPath, mod); // поздний мод побеждает
}
}
var ordered = files.Values.OrderBy(f => f.RelativePath, StringComparer.Ordinal).ToList();
return new ModContentTree(files, ordered);
}
private static string Normalize(string path) => path.Replace('\\', '/');
}
+36
View File
@@ -0,0 +1,36 @@
using System.Text.Json;
namespace MrGameEng.Mods;
/// <summary>
/// Mod metadata loaded from <c>About/About.json</c> in the mod's root directory.
/// </summary>
public sealed class ModInfo
{
/// <summary>JSON options shared by all mod content readers (camelCase, comments and trailing commas allowed).</summary>
internal static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
WriteIndented = true,
};
/// <summary>Unique mod id, referenced by <see cref="Dependencies"/> of other mods.</summary>
public string Id { get; init; } = "";
/// <summary>Human-readable mod name.</summary>
public string Name { get; init; } = "";
/// <summary>Mod author.</summary>
public string Author { get; init; } = "";
/// <summary>Mod version string (informational).</summary>
public string Version { get; init; } = "";
/// <summary>Short description shown in mod lists.</summary>
public string Description { get; init; } = "";
/// <summary>Ids of mods that must be active and load before this one.</summary>
public List<string> Dependencies { get; init; } = [];
}
+152
View File
@@ -0,0 +1,152 @@
using System.Text.Json;
using MrGameEng.Core;
namespace MrGameEng.Mods;
/// <summary>
/// Discovers mods — subdirectories of a mods root containing <c>About/About.json</c> —
/// and resolves a deterministic load order: dependencies first, ties broken
/// alphabetically by id. Later mods override earlier ones in every content system
/// (defs, textures, languages).
/// </summary>
public static class ModLoader
{
/// <summary>
/// Walks up from <paramref name="startDirectory"/> looking for a <c>Mods</c> folder.
/// Lets dev builds run from <c>bin/…</c> while shipped builds keep <c>Mods</c> next to
/// the executable. Returns null when no such folder exists on the path to the root.
/// </summary>
public static string? FindModsRoot(string startDirectory)
{
for (var dir = new DirectoryInfo(Path.GetFullPath(startDirectory)); dir is not null; dir = dir.Parent)
{
var candidate = Path.Combine(dir.FullName, "Mods");
if (Directory.Exists(candidate))
{
return candidate;
}
}
return null;
}
/// <summary>
/// Loads mods under <paramref name="modsRoot"/> in dependency order. With
/// <paramref name="activeIds"/> only that subset is loaded (its dependencies must be
/// included); without it every discovered mod is active.
/// </summary>
public static IReadOnlyList<Mod> Load(string modsRoot, IEnumerable<string>? activeIds = null)
{
if (!Directory.Exists(modsRoot))
{
throw new DirectoryNotFoundException($"Mods root not found: '{modsRoot}'.");
}
var discovered = Discover(modsRoot);
List<Mod> active;
if (activeIds is null)
{
active = discovered.Values.ToList();
}
else
{
active = [];
foreach (var id in activeIds)
{
if (!discovered.TryGetValue(id, out var mod))
{
throw new InvalidDataException($"Active mod '{id}' is not installed under '{modsRoot}'.");
}
active.Add(mod);
}
}
var ordered = SortByDependencies(active);
Log.Info($"Mods loaded: {string.Join(", ", ordered)}");
return ordered;
}
private static SortedDictionary<string, Mod> Discover(string modsRoot)
{
var discovered = new SortedDictionary<string, Mod>(StringComparer.Ordinal);
foreach (var dir in Directory.EnumerateDirectories(modsRoot))
{
var aboutPath = Path.Combine(dir, "About", "About.json");
if (!File.Exists(aboutPath))
{
continue; // не мод — служебная папка
}
ModInfo info;
try
{
info = JsonSerializer.Deserialize<ModInfo>(File.ReadAllText(aboutPath), ModInfo.JsonOptions)
?? throw new InvalidDataException("About.json deserialized to null.");
}
catch (JsonException exception)
{
throw new InvalidDataException($"Invalid mod metadata '{aboutPath}': {exception.Message}", exception);
}
if (string.IsNullOrWhiteSpace(info.Id))
{
throw new InvalidDataException($"Mod at '{dir}' has an empty id in About.json.");
}
if (discovered.TryGetValue(info.Id, out var existing))
{
throw new InvalidDataException(
$"Duplicate mod id '{info.Id}': '{existing.RootPath}' and '{dir}'.");
}
discovered.Add(info.Id, new Mod(info, dir));
}
return discovered;
}
private static List<Mod> SortByDependencies(List<Mod> active)
{
var byId = active.ToDictionary(m => m.Id, StringComparer.Ordinal);
var ordered = new List<Mod>(active.Count);
var state = new Dictionary<string, bool>(StringComparer.Ordinal); // false = в обработке, true = готов
void Visit(Mod mod)
{
if (state.TryGetValue(mod.Id, out var done))
{
if (!done)
{
throw new InvalidDataException($"Cyclic mod dependency involving '{mod.Id}'.");
}
return;
}
state[mod.Id] = false;
foreach (var dependency in mod.Info.Dependencies)
{
if (!byId.TryGetValue(dependency, out var parent))
{
throw new InvalidDataException(
$"Mod '{mod.Id}' requires '{dependency}', which is not installed or not active.");
}
Visit(parent);
}
state[mod.Id] = true;
ordered.Add(mod);
}
// Обход в алфавитном порядке id — итоговый порядок детерминирован.
foreach (var mod in active.OrderBy(m => m.Id, StringComparer.Ordinal))
{
Visit(mod);
}
return ordered;
}
}
+15
View File
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="MrGameEng.Mods.Tests" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
</ItemGroup>
</Project>
@@ -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>