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
@@ -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`
|
||||
|
||||
@@ -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
@@ -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)` для инкрементальной сборки атласов
|
||||
при старте игры.
|
||||
|
||||
### Правило зависимостей
|
||||
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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}";
|
||||
}
|
||||
@@ -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": "<key>", "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;
|
||||
}
|
||||
}
|
||||
@@ -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/<code>/**/*.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>(); // поздний мод переопределяет ключ
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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('\\', '/');
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user