From 5d3c18de40a3b0c1f5817dfb5ca59c410c83ee78 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 11 Jun 2026 21:51:17 +0300 Subject: [PATCH] Add MrGameEng.Mods: mod loading, JSON defs and localization 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//*.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 --- CLAUDE.md | 5 +- MrGameEng.sln | 30 +++ docs/architecture.md | 24 ++- src/MrGameEng.Atlases/AtlasBuilder.cs | 38 +++- src/MrGameEng.Mods/Def.cs | 30 +++ src/MrGameEng.Mods/DefDatabase.cs | 195 ++++++++++++++++++ src/MrGameEng.Mods/LanguageManager.cs | 141 +++++++++++++ src/MrGameEng.Mods/Mod.cs | 29 +++ src/MrGameEng.Mods/ModContentTree.cs | 65 ++++++ src/MrGameEng.Mods/ModInfo.cs | 36 ++++ src/MrGameEng.Mods/ModLoader.cs | 152 ++++++++++++++ src/MrGameEng.Mods/MrGameEng.Mods.csproj | 15 ++ .../MrGameEng.Mods.Tests/DefDatabaseTests.cs | 139 +++++++++++++ .../LanguageManagerTests.cs | 93 +++++++++ tests/MrGameEng.Mods.Tests/ModLoaderTests.cs | 114 ++++++++++ .../MrGameEng.Mods.Tests.csproj | 19 ++ 16 files changed, 1114 insertions(+), 11 deletions(-) create mode 100644 src/MrGameEng.Mods/Def.cs create mode 100644 src/MrGameEng.Mods/DefDatabase.cs create mode 100644 src/MrGameEng.Mods/LanguageManager.cs create mode 100644 src/MrGameEng.Mods/Mod.cs create mode 100644 src/MrGameEng.Mods/ModContentTree.cs create mode 100644 src/MrGameEng.Mods/ModInfo.cs create mode 100644 src/MrGameEng.Mods/ModLoader.cs create mode 100644 src/MrGameEng.Mods/MrGameEng.Mods.csproj create mode 100644 tests/MrGameEng.Mods.Tests/DefDatabaseTests.cs create mode 100644 tests/MrGameEng.Mods.Tests/LanguageManagerTests.cs create mode 100644 tests/MrGameEng.Mods.Tests/ModLoaderTests.cs create mode 100644 tests/MrGameEng.Mods.Tests/MrGameEng.Mods.Tests.csproj diff --git a/CLAUDE.md b/CLAUDE.md index 6a86892..ec7d3cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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//` 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` diff --git a/MrGameEng.sln b/MrGameEng.sln index 1e184bc..cad90f4 100644 --- a/MrGameEng.sln +++ b/MrGameEng.sln @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md index bb92500..37aa90d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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/` с метаданными в +`About/About.json` (id, имя, версия, зависимости). `ModLoader.Load` упорядочивает моды +детерминированно: зависимости раньше зависимых, при равенстве — по алфавиту id. Поздний +мод переопределяет ранние во всех системах контента. Сама игра поставляет свой контент +как мод `Core` — любой другой мод может переопределить её данные. + +- **Дефы** (`Defs/**/*.json`): файл-конверт `{ "type": "<ключ>", "defs": [ … ] }`; + CLR-тип на ключ регистрирует игра (`DefDatabase.RegisterType`). Поля: `defName` + (уникален в типе; одноимённый деф позднего мода полностью заменяет ранний), `parent` + (поля родителя как основа, свои — поверх; вложенные объекты заменяются целиком), + `abstract` (только родитель, в базу не попадает; не наследуется), `label`. +- **Локализация** (`Languages/<код>/**/*.json`): плоские словари ключ→строка; + `LanguageManager` переключает язык на лету, недостающие ключи берёт из языка по + умолчанию, в крайнем случае возвращает сам ключ. +- **Деревья контента**: `ModContentTree.Build(mods, "Textures")` сливает одноимённые + папки всех модов (поздний мод побеждает по относительному пути) — результат кормится, + например, в `AtlasBuilder.Build(options, sources)` для инкрементальной сборки атласов + при старте игры. ### Правило зависимостей diff --git a/src/MrGameEng.Atlases/AtlasBuilder.cs b/src/MrGameEng.Atlases/AtlasBuilder.cs index 656e36d..c54bc51 100644 --- a/src/MrGameEng.Atlases/AtlasBuilder.cs +++ b/src/MrGameEng.Atlases/AtlasBuilder.cs @@ -3,11 +3,14 @@ using StbImageWriteSharp; namespace MrGameEng.Atlases; -/// Options for one run. +/// Options for one run. public sealed class AtlasBuildOptions { - /// Directory scanned recursively for source images (png/jpg/jpeg/bmp). - public required string SourceDirectory { get; init; } + /// + /// Directory scanned recursively for source images (png/jpg/jpeg/bmp). Used only by the + /// directory-scanning overload; the explicit-sources overload ignores it. + /// + public string SourceDirectory { get; init; } = ""; /// Directory the .atlas metadata and page images are written to. public required string OutputDirectory { get; init; } @@ -38,7 +41,7 @@ public sealed class AtlasBuildOptions /// True when the atlas was up to date and not rebuilt. public sealed record AtlasGroupResult(string Name, int RegionCount, int PageCount, bool Skipped); -/// Result of an run. +/// Result of an run. /// Per-atlas outcomes, sorted by name. /// Output files of atlases whose source group no longer exists. public sealed record AtlasBuildResult(IReadOnlyList Groups, IReadOnlyList DeletedOrphans); @@ -57,18 +60,36 @@ public static class AtlasBuilder /// Snapshot of one source image taken at scan time (size/mtime feed the staleness check). private readonly record struct SourceFile(string FullPath, string Key, long Size, long ModifiedTicks); - /// Builds (or incrementally refreshes) all atlases for . + /// Builds (or incrementally refreshes) all atlases from . 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)))); + } + + /// + /// 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 RelativePath without extension. + /// + 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(); foreach (var (name, files) in groups) { @@ -91,18 +112,17 @@ public static class AtlasBuilder } private static SortedDictionary> ScanGroups( - string sourceRoot, AtlasBuildOptions options) + IEnumerable<(string FullPath, string RelativePath)> sources, AtlasBuildOptions options) { var groups = new SortedDictionary>(StringComparer.Ordinal); var keys = new Dictionary(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)) { diff --git a/src/MrGameEng.Mods/Def.cs b/src/MrGameEng.Mods/Def.cs new file mode 100644 index 0000000..0c1a581 --- /dev/null +++ b/src/MrGameEng.Mods/Def.cs @@ -0,0 +1,30 @@ +namespace MrGameEng.Mods; + +/// +/// Base class of all data definitions loaded by from mod JSON. +/// Games subclass this per content kind (terrain, things, pawns, …) with plain +/// serializable properties. +/// +public abstract class Def +{ + /// + /// Unique name within the def type. A later mod redefining the same name fully + /// replaces the earlier def. + /// + public string DefName { get; init; } = ""; + + /// + /// Name of the def (same type) whose fields this def starts from; own fields override + /// the inherited ones. Abstractness is not inherited. + /// + public string? Parent { get; init; } + + /// Abstract defs only serve as parents and are not emitted into the database. + public bool Abstract { get; init; } + + /// Display label — plain text or a localization key, as the game decides. + public string Label { get; init; } = ""; + + /// + public override string ToString() => $"{GetType().Name} {DefName}"; +} diff --git a/src/MrGameEng.Mods/DefDatabase.cs b/src/MrGameEng.Mods/DefDatabase.cs new file mode 100644 index 0000000..b3c51e4 --- /dev/null +++ b/src/MrGameEng.Mods/DefDatabase.cs @@ -0,0 +1,195 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace MrGameEng.Mods; + +/// +/// Database of data definitions loaded from mod JSON. Each file under a mod's +/// Defs/ folder is an envelope { "type": "<key>", "defs": [ … ] }; the +/// game registers the CLR type for every key before . Defs from later +/// mods replace same-named defs of earlier mods; parent chains are merged +/// field-by-field (own fields win, nested objects are replaced whole); defs marked +/// abstract serve only as parents. +/// +public sealed class DefDatabase +{ + private sealed class TypeEntry + { + public required string Key; + public required Type ClrType; + public readonly Dictionary Raw = new(StringComparer.Ordinal); + public readonly SortedDictionary Resolved = new(StringComparer.Ordinal); + } + + private readonly Dictionary _byKey = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _byType = []; + + private static readonly JsonDocumentOptions DocumentOptions = new() + { + CommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }; + + /// Registers the CLR type behind a def-type key (the "type" field of def files). + public void RegisterType(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); + } + + /// + /// Loads every Defs/**/*.json of (in load order) and + /// resolves inheritance. Call once after registering all def types. + /// + public void Load(IReadOnlyList 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); + } + } + + /// Returns the def of type named ; throws when missing. + public T Get(string defName) where T : Def => + TryGet(defName, out var def) + ? def + : throw new KeyNotFoundException($"No {typeof(T).Name} def named '{defName}'."); + + /// Returns the def of type named , or false. + public bool TryGet(string defName, out T def) where T : Def + { + if (Entry().Resolved.TryGetValue(defName, out var found)) + { + def = (T)found; + return true; + } + + def = null!; + return false; + } + + /// All resolved defs of type , sorted by def name (deterministic). + public IReadOnlyList All() where T : Def => Entry().Resolved.Values.Cast().ToList(); + + /// Registered def-type keys, sorted. + public IReadOnlyList TypeKeys => _byKey.Values.Select(e => e.Key).Order(StringComparer.Ordinal).ToList(); + + /// Resolved def names of the given type key, sorted; empty for unknown keys. + public IReadOnlyList NamesOf(string typeKey) => + _byKey.TryGetValue(typeKey, out var entry) ? entry.Resolved.Keys.ToList() : []; + + private TypeEntry Entry() 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() + ?? 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(); + 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() == 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 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(); + 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; + } +} diff --git a/src/MrGameEng.Mods/LanguageManager.cs b/src/MrGameEng.Mods/LanguageManager.cs new file mode 100644 index 0000000..dedf568 --- /dev/null +++ b/src/MrGameEng.Mods/LanguageManager.cs @@ -0,0 +1,141 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace MrGameEng.Mods; + +/// +/// Keyed localization strings loaded from mods: Languages/<code>/**/*.json, +/// 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. +/// +public sealed class LanguageManager +{ + private readonly Dictionary> _languages = + new(StringComparer.OrdinalIgnoreCase); + + /// Creates a manager whose fallback language is . + public LanguageManager(string defaultLanguage = "en") + { + DefaultLanguage = defaultLanguage; + CurrentLanguage = defaultLanguage; + } + + /// Fallback language code. + public string DefaultLanguage { get; } + + /// Active language code. Change with . + public string CurrentLanguage { get; private set; } + + /// Increments whenever loaded strings or the active language change — UI rebuilds on it. + public int Revision { get; private set; } + + /// Language codes that have at least one loaded string, sorted. + public IReadOnlyList AvailableLanguages => + _languages.Keys.Order(StringComparer.OrdinalIgnoreCase).ToList(); + + /// Loads (merges in) language files of in load order. + public void Load(IReadOnlyList 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(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++; + } + + /// + /// Switches the active language. Returns false (and keeps the current one) when no + /// strings are loaded for . + /// + public bool SetLanguage(string code) + { + if (!_languages.ContainsKey(code)) + { + return false; + } + + CurrentLanguage = code; + Revision++; + return true; + } + + /// Returns the string for : current language → default language → the key itself. + 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; + } + + /// Formats the string for with (invariant culture). + public string Format(string key, params object[] args) => + string.Format(CultureInfo.InvariantCulture, Get(key), args); + + private static void LoadFile(Mod mod, string file, Dictionary 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(); // поздний мод переопределяет ключ + } + } +} diff --git a/src/MrGameEng.Mods/Mod.cs b/src/MrGameEng.Mods/Mod.cs new file mode 100644 index 0000000..250361c --- /dev/null +++ b/src/MrGameEng.Mods/Mod.cs @@ -0,0 +1,29 @@ +namespace MrGameEng.Mods; + +/// One discovered mod: its metadata and content directories on disk. +public sealed class Mod +{ + internal Mod(ModInfo info, string rootPath) + { + Info = info; + RootPath = rootPath; + } + + /// Metadata from About/About.json. + public ModInfo Info { get; } + + /// Absolute path of the mod's root directory. + public string RootPath { get; } + + /// Unique mod id (shortcut for Info.Id). + public string Id => Info.Id; + + /// + /// Absolute path of a content folder inside the mod (e.g. Defs, Textures, + /// Languages). The folder is not required to exist. + /// + public string ContentPath(string folder) => Path.Combine(RootPath, folder); + + /// + public override string ToString() => $"{Id} {Info.Version}".TrimEnd(); +} diff --git a/src/MrGameEng.Mods/ModContentTree.cs b/src/MrGameEng.Mods/ModContentTree.cs new file mode 100644 index 0000000..461db86 --- /dev/null +++ b/src/MrGameEng.Mods/ModContentTree.cs @@ -0,0 +1,65 @@ +namespace MrGameEng.Mods; + +/// A file contributed by a mod, addressed by its content-relative path. +/// Path relative to the content folder, forward slashes. +/// Absolute path of the winning file on disk. +/// The mod that contributed the file. +public readonly record struct ModFile(string RelativePath, string FullPath, Mod Mod); + +/// +/// Merged view of one content folder (e.g. Textures) 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). +/// +public sealed class ModContentTree +{ + private readonly Dictionary _files; + + private ModContentTree(Dictionary files, IReadOnlyList ordered) + { + _files = files; + Files = ordered; + } + + /// Winning files, sorted by relative path — deterministic for identical mod sets. + public IReadOnlyList Files { get; } + + /// Returns the winning file for . + public bool TryGet(string relativePath, out ModFile file) => + _files.TryGetValue(Normalize(relativePath), out file); + + /// + /// Builds the merged tree of over + /// (in load order). With only matching files are included + /// (e.g. ".png"); without them, every file. + /// + public static ModContentTree Build(IReadOnlyList mods, string contentFolder, params string[] extensions) + { + var files = new Dictionary(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('\\', '/'); +} diff --git a/src/MrGameEng.Mods/ModInfo.cs b/src/MrGameEng.Mods/ModInfo.cs new file mode 100644 index 0000000..948116f --- /dev/null +++ b/src/MrGameEng.Mods/ModInfo.cs @@ -0,0 +1,36 @@ +using System.Text.Json; + +namespace MrGameEng.Mods; + +/// +/// Mod metadata loaded from About/About.json in the mod's root directory. +/// +public sealed class ModInfo +{ + /// JSON options shared by all mod content readers (camelCase, comments and trailing commas allowed). + internal static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + WriteIndented = true, + }; + + /// Unique mod id, referenced by of other mods. + public string Id { get; init; } = ""; + + /// Human-readable mod name. + public string Name { get; init; } = ""; + + /// Mod author. + public string Author { get; init; } = ""; + + /// Mod version string (informational). + public string Version { get; init; } = ""; + + /// Short description shown in mod lists. + public string Description { get; init; } = ""; + + /// Ids of mods that must be active and load before this one. + public List Dependencies { get; init; } = []; +} diff --git a/src/MrGameEng.Mods/ModLoader.cs b/src/MrGameEng.Mods/ModLoader.cs new file mode 100644 index 0000000..64d7f3f --- /dev/null +++ b/src/MrGameEng.Mods/ModLoader.cs @@ -0,0 +1,152 @@ +using System.Text.Json; +using MrGameEng.Core; + +namespace MrGameEng.Mods; + +/// +/// Discovers mods — subdirectories of a mods root containing About/About.json — +/// 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). +/// +public static class ModLoader +{ + /// + /// Walks up from looking for a Mods folder. + /// Lets dev builds run from bin/… while shipped builds keep Mods next to + /// the executable. Returns null when no such folder exists on the path to the root. + /// + 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; + } + + /// + /// Loads mods under in dependency order. With + /// only that subset is loaded (its dependencies must be + /// included); without it every discovered mod is active. + /// + public static IReadOnlyList Load(string modsRoot, IEnumerable? activeIds = null) + { + if (!Directory.Exists(modsRoot)) + { + throw new DirectoryNotFoundException($"Mods root not found: '{modsRoot}'."); + } + + var discovered = Discover(modsRoot); + + List 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 Discover(string modsRoot) + { + var discovered = new SortedDictionary(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(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 SortByDependencies(List active) + { + var byId = active.ToDictionary(m => m.Id, StringComparer.Ordinal); + var ordered = new List(active.Count); + var state = new Dictionary(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; + } +} diff --git a/src/MrGameEng.Mods/MrGameEng.Mods.csproj b/src/MrGameEng.Mods/MrGameEng.Mods.csproj new file mode 100644 index 0000000..d3fbd54 --- /dev/null +++ b/src/MrGameEng.Mods/MrGameEng.Mods.csproj @@ -0,0 +1,15 @@ + + + + net8.0 + + + + + + + + + + + diff --git a/tests/MrGameEng.Mods.Tests/DefDatabaseTests.cs b/tests/MrGameEng.Mods.Tests/DefDatabaseTests.cs new file mode 100644 index 0000000..bf4ad77 --- /dev/null +++ b/tests/MrGameEng.Mods.Tests/DefDatabaseTests.cs @@ -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 Tags { get; init; } = []; + } + + private readonly string _root = Directory.CreateTempSubdirectory("mrge-defs-tests-").FullName; + private int _modCounter; + + public void Dispose() => Directory.Delete(_root, recursive: true); + + /// Создаёт мод с одним файлом дефов и возвращает его (порядок загрузки = порядок создания). + 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("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().Select(d => d.DefName)); + Assert.Equal(9f, database.Get("Wolf").Speed); + Assert.Equal("волк", database.Get("Wolf").Label); + Assert.Equal(4, database.Get("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("Hare"); + Assert.Equal(12f, hare.Speed); // своё поле победило + Assert.Equal(["wild"], hare.Tags); // унаследовано + var snail = database.Get("Snail"); + Assert.Equal(5f, snail.Speed); // унаследовано + Assert.Equal(0, snail.Legs); + Assert.Equal(["slow", "slimy"], snail.Tags); // массив заменён целиком + Assert.False(database.TryGet("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("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(() => 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(() => LoadAnimals(mod)); + Assert.Contains("Ghost", exception.Message); + } + + [Fact] + public void Load_UnknownDefType_ThrowsWithFileAndMod() + { + var mod = WriteDefsMod("""{ "type": "Vehicle", "defs": [] }"""); + + var exception = Assert.Throws(() => 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(() => database.Get("Dodo")); + Assert.False(database.TryGet("Dodo", out _)); + } +} diff --git a/tests/MrGameEng.Mods.Tests/LanguageManagerTests.cs b/tests/MrGameEng.Mods.Tests/LanguageManagerTests.cs new file mode 100644 index 0000000..ebcc9a7 --- /dev/null +++ b/tests/MrGameEng.Mods.Tests/LanguageManagerTests.cs @@ -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(() => 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); + } +} diff --git a/tests/MrGameEng.Mods.Tests/ModLoaderTests.cs b/tests/MrGameEng.Mods.Tests/ModLoaderTests.cs new file mode 100644 index 0000000..4e04954 --- /dev/null +++ b/tests/MrGameEng.Mods.Tests/ModLoaderTests.cs @@ -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); + + /// Создаёт мод с About.json; зависимости — id модов, которые должны грузиться раньше. + 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(() => ModLoader.Load(ModsRoot)); + Assert.Contains("NoSuchMod", exception.Message); + } + + [Fact] + public void Load_CyclicDependency_Throws() + { + WriteMod("a", "b"); + WriteMod("b", "a"); + + var exception = Assert.Throws(() => 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; + } +} diff --git a/tests/MrGameEng.Mods.Tests/MrGameEng.Mods.Tests.csproj b/tests/MrGameEng.Mods.Tests/MrGameEng.Mods.Tests.csproj new file mode 100644 index 0000000..176b963 --- /dev/null +++ b/tests/MrGameEng.Mods.Tests/MrGameEng.Mods.Tests.csproj @@ -0,0 +1,19 @@ + + + + net8.0 + Exe + false + + + + + + + + + + + + +