Remove obsolete project files for MrGameEng.AI, MrGameEng.Assets, MrGameEng.Atlases, and MrGameEng.Collisions modules. Introduce new AssetManager and related classes for asset loading and management, including support for texture atlases and mod definitions. Enhance mod loading capabilities with DefDatabase and LanguageManager for JSON-based definitions and localization. Implement a shelf packing algorithm for efficient texture atlas creation.

This commit is contained in:
Leonid Pershin
2026-06-12 07:47:04 +03:00
parent 1f87fb0b74
commit c30e2ce764
70 changed files with 0 additions and 233 deletions
@@ -0,0 +1,296 @@
using StbImageSharp;
using StbImageWriteSharp;
using Xunit;
namespace MrGameEng.Atlases.Tests;
public sealed class AtlasBuilderTests : IDisposable
{
private readonly string _root = Directory.CreateTempSubdirectory("mrge-atlas-tests-").FullName;
private string SourceDir => Path.Combine(_root, "Textures");
private string OutputDir => Path.Combine(_root, "Atlases");
public void Dispose() => Directory.Delete(_root, recursive: true);
/// <summary>Writes a PNG filled with one RGBA color.</summary>
private void WritePng(
string relativePath,
int width,
int height,
byte r,
byte g,
byte b,
byte a = 255
)
{
var fullPath = Path.Combine(SourceDir, relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
var data = new byte[width * height * 4];
for (var i = 0; i < data.Length; i += 4)
{
data[i] = r;
data[i + 1] = g;
data[i + 2] = b;
data[i + 3] = a;
}
using var stream = File.Create(fullPath);
new ImageWriter().WritePng(
data,
width,
height,
StbImageWriteSharp.ColorComponents.RedGreenBlueAlpha,
stream
);
}
private AtlasBuildOptions Options(int groupDepth = 1, bool force = false) =>
new()
{
SourceDirectory = SourceDir,
OutputDirectory = OutputDir,
GroupDepth = groupDepth,
MaxPageSize = 128,
Padding = 2,
Force = force,
};
[Theory]
[InlineData("Terrain/Surfaces/Marsh.png", 1, "Terrain", "Terrain/Surfaces/Marsh")]
[InlineData("Terrain/Surfaces/Marsh.png", 2, "Terrain.Surfaces", "Terrain/Surfaces/Marsh")]
[InlineData("Terrain/Surfaces/Marsh.png", 0, "Root", "Terrain/Surfaces/Marsh")]
[InlineData("loose.png", 3, "Root", "loose")]
public void ClassifyPath_GroupsByDepth(
string path,
int depth,
string expectedAtlas,
string expectedKey
)
{
var (atlas, key) = AtlasBuilder.ClassifyPath(path, depth, "Root");
Assert.Equal(expectedAtlas, atlas);
Assert.Equal(expectedKey, key);
}
[Fact]
public void Build_WritesMetadataAndPages_PixelsSurviveRoundtrip()
{
WritePng("Terrain/Grass.png", 16, 16, 10, 200, 30);
WritePng("Terrain/Water.png", 16, 8, 30, 40, 250);
var result = AtlasBuilder.Build(Options());
var group = Assert.Single(result.Groups);
Assert.Equal("Terrain", group.Name);
Assert.Equal(2, group.RegionCount);
Assert.False(group.Skipped);
var metadata = AtlasMetadata.FromJson(
File.ReadAllText(Path.Combine(OutputDir, "Terrain.atlas"))
);
Assert.Equal(["Terrain/Grass", "Terrain/Water"], metadata.Regions.Select(x => x.Key));
var grass = metadata.Regions.Single(x => x.Key == "Terrain/Grass");
var page = metadata.Pages[grass.Page];
using var stream = File.OpenRead(Path.Combine(OutputDir, page.File));
var pixels = ImageResult.FromStream(
stream,
StbImageSharp.ColorComponents.RedGreenBlueAlpha
);
// Центральный пиксель региона должен быть цветом исходной картинки.
var center = ((grass.Y + 8) * pixels.Width + grass.X + 8) * 4;
Assert.Equal((byte)10, pixels.Data[center]);
Assert.Equal((byte)200, pixels.Data[center + 1]);
Assert.Equal((byte)30, pixels.Data[center + 2]);
}
[Fact]
public void Build_SecondRunWithoutChanges_SkipsGroup()
{
WritePng("UI/button.png", 8, 8, 1, 2, 3);
var first = AtlasBuilder.Build(Options());
var second = AtlasBuilder.Build(Options());
Assert.False(Assert.Single(first.Groups).Skipped);
Assert.True(Assert.Single(second.Groups).Skipped);
}
[Fact]
public void Build_ChangedSource_Rebuilds()
{
WritePng("UI/button.png", 8, 8, 1, 2, 3);
AtlasBuilder.Build(Options());
File.SetLastWriteTimeUtc(
Path.Combine(SourceDir, "UI/button.png"),
DateTime.UtcNow.AddMinutes(1)
);
var result = AtlasBuilder.Build(Options());
Assert.False(Assert.Single(result.Groups).Skipped);
}
[Fact]
public void Build_AddedFile_RebuildsGroup()
{
WritePng("UI/button.png", 8, 8, 1, 2, 3);
AtlasBuilder.Build(Options());
WritePng("UI/icon.png", 8, 8, 4, 5, 6);
var result = AtlasBuilder.Build(Options());
var group = Assert.Single(result.Groups);
Assert.False(group.Skipped);
Assert.Equal(2, group.RegionCount);
}
[Fact]
public void Build_RemovedGroup_DeletesOrphanedAtlas()
{
WritePng("UI/button.png", 8, 8, 1, 2, 3);
WritePng("World/rock.png", 8, 8, 7, 8, 9);
AtlasBuilder.Build(Options());
Directory.Delete(Path.Combine(SourceDir, "World"), recursive: true);
var result = AtlasBuilder.Build(Options());
Assert.NotEmpty(result.DeletedOrphans);
Assert.False(File.Exists(Path.Combine(OutputDir, "World.atlas")));
Assert.False(File.Exists(Path.Combine(OutputDir, "World.atlas.0.png")));
Assert.True(File.Exists(Path.Combine(OutputDir, "UI.atlas")));
}
[Fact]
public void Build_ManyImages_SpillToMultiplePages()
{
for (var i = 0; i < 12; i++)
{
WritePng($"Things/sprite{i:D2}.png", 60, 60, (byte)i, 0, 0);
}
var result = AtlasBuilder.Build(Options());
// 60² с padding 2 на страницу 128² помещаются по 4 — минимум 3 страницы.
Assert.True(Assert.Single(result.Groups).PageCount >= 3);
}
[Fact]
public void Build_ContentChangedWithSameTimestamp_Rebuilds()
{
WritePng("UI/button.png", 8, 8, 1, 2, 3);
AtlasBuilder.Build(Options());
// Копия «с сохранением времени» (robocopy, zip): mtime прежний, контент другой.
var path = Path.Combine(SourceDir, "UI/button.png");
var timestamp = File.GetLastWriteTimeUtc(path);
WritePng("UI/button.png", 16, 16, 9, 9, 9);
File.SetLastWriteTimeUtc(path, timestamp);
var result = AtlasBuilder.Build(Options());
Assert.False(Assert.Single(result.Groups).Skipped);
}
[Fact]
public void Build_OldMetadataVersion_Rebuilds()
{
WritePng("UI/button.png", 8, 8, 1, 2, 3);
AtlasBuilder.Build(Options());
var metadataPath = Path.Combine(OutputDir, "UI.atlas");
var json = File.ReadAllText(metadataPath)
.Replace($"\"version\": {AtlasMetadata.CurrentVersion}", "\"version\": 1");
File.WriteAllText(metadataPath, json);
var result = AtlasBuilder.Build(Options());
Assert.False(Assert.Single(result.Groups).Skipped);
}
[Fact]
public void Metadata_JsonRoundtrip_PreservesEverything()
{
var metadata = new AtlasMetadata
{
Name = "Things.Pawn",
PageSize = 2048,
Padding = 2,
Pages =
[
new AtlasPage
{
File = "Things.Pawn.atlas.0.png",
Width = 256,
Height = 128,
},
],
Regions =
[
new AtlasRegion
{
Key = "Things/Pawn/Fox",
Page = 0,
X = 2,
Y = 4,
Width = 64,
Height = 32,
},
],
};
var parsed = AtlasMetadata.FromJson(metadata.ToJson());
Assert.Equal("Things.Pawn", parsed.Name);
Assert.Equal(2048, parsed.PageSize);
var page = Assert.Single(parsed.Pages);
Assert.Equal(("Things.Pawn.atlas.0.png", 256, 128), (page.File, page.Width, page.Height));
var region = Assert.Single(parsed.Regions);
Assert.Equal(
("Things/Pawn/Fox", 0, 2, 4, 64, 32),
(region.Key, region.Page, region.X, region.Y, region.Width, region.Height)
);
}
[Fact]
public void TextureAtlas_LooksUpRegions_FromMetadata()
{
var metadata = new AtlasMetadata
{
Name = "Test",
Pages =
[
new AtlasPage
{
File = "Test.atlas.0.png",
Width = 64,
Height = 64,
},
],
Regions =
[
new AtlasRegion
{
Key = "a/b",
Page = 0,
X = 2,
Y = 2,
Width = 10,
Height = 12,
},
],
};
// Texture2D == null допустим в headless-тестах (см. Texture2DRegion).
var atlas = new TextureAtlas(metadata, new Microsoft.Xna.Framework.Graphics.Texture2D[1]);
var region = atlas.GetRegion("a/b");
Assert.Equal(new Microsoft.Xna.Framework.Rectangle(2, 2, 10, 12), region.Bounds);
Assert.True(atlas.TryGetRegion("a/b", out _));
Assert.False(atlas.TryGetRegion("missing", out _));
Assert.Throws<KeyNotFoundException>(() => atlas.GetRegion("missing"));
}
}
@@ -0,0 +1,159 @@
using MrGameEng.Atlases;
using Xunit;
namespace MrGameEng.Atlases.Tests;
public class ShelfPackerTests
{
private static List<PackItem> Squares(int count, int size) =>
Enumerable.Range(0, count).Select(i => new PackItem($"item{i:D3}", size, size)).ToList();
[Fact]
public void Pack_PlacesEveryItem_WithinPageBounds()
{
var result = ShelfPacker.Pack(Squares(50, 60), maxPageSize: 256, padding: 2);
Assert.Equal(50, result.Placements.Count);
foreach (var p in result.Placements)
{
var (pageWidth, pageHeight) = result.PageSizes[p.Page];
Assert.True(p.X >= 2 && p.Y >= 2);
Assert.True(p.X + p.Width <= pageWidth);
Assert.True(p.Y + p.Height <= pageHeight);
}
}
[Fact]
public void Pack_NoTwoPlacements_Overlap()
{
var items = new List<PackItem>();
var random = new Random(42);
for (var i = 0; i < 200; i++)
{
items.Add(new PackItem($"r{i:D3}", random.Next(4, 90), random.Next(4, 90)));
}
var result = ShelfPacker.Pack(items, maxPageSize: 512, padding: 2);
var byPage = result.Placements.GroupBy(p => p.Page);
foreach (var page in byPage)
{
var list = page.ToList();
for (var i = 0; i < list.Count; i++)
{
for (var j = i + 1; j < list.Count; j++)
{
var a = list[i];
var b = list[j];
var separated =
a.X + a.Width + 2 <= b.X
|| b.X + b.Width + 2 <= a.X
|| a.Y + a.Height + 2 <= b.Y
|| b.Y + b.Height + 2 <= a.Y;
Assert.True(separated, $"{a.Key} overlaps {b.Key} (padding included)");
}
}
}
}
[Fact]
public void Pack_Overflows_ToMultiplePages()
{
// 9 квадратов 100² на страницу 256² помещаются максимум по 4.
var result = ShelfPacker.Pack(Squares(9, 100), maxPageSize: 256, padding: 2);
Assert.True(result.PageSizes.Count >= 3);
Assert.Equal(
Enumerable.Range(0, result.PageSizes.Count),
result.Placements.Select(p => p.Page).Distinct().Order()
);
}
[Fact]
public void Pack_OversizedItem_GetsDedicatedPage()
{
var items = Squares(2, 30);
items.Add(new PackItem("huge", 500, 40));
var result = ShelfPacker.Pack(items, maxPageSize: 256, padding: 2);
var huge = result.Placements.Single(p => p.Key == "huge");
Assert.Single(result.Placements, p => p.Page == huge.Page);
Assert.True(result.PageSizes[huge.Page].Width >= 504);
}
[Fact]
public void Pack_IsDeterministic_RegardlessOfInputOrder()
{
var items = Squares(30, 20)
.Concat(Squares(10, 50).Select(i => i with { Key = "b" + i.Key }))
.ToList();
var shuffled = items.AsEnumerable().Reverse().ToList();
var a = ShelfPacker.Pack(items, 128, 2);
var b = ShelfPacker.Pack(shuffled, 128, 2);
Assert.Equal(
a.Placements.OrderBy(p => p.Key, StringComparer.Ordinal),
b.Placements.OrderBy(p => p.Key, StringComparer.Ordinal)
);
}
[Fact]
public void Pack_TrimsPages_ToPowerOfTwo()
{
var result = ShelfPacker.Pack(Squares(1, 50), maxPageSize: 2048, padding: 2);
Assert.Equal((64, 64), result.PageSizes.Single());
}
[Theory]
[InlineData(1, 1)]
[InlineData(64, 64)]
[InlineData(65, 128)]
[InlineData(2048, 2048)]
public void NextPowerOfTwo_RoundsUp(int value, int expected)
{
Assert.Equal(expected, ShelfPacker.NextPowerOfTwo(value));
}
[Fact]
public void Pack_NonPowerOfTwoLimit_SharedPagesNeverExceedIt()
{
// 40² с padding на лимит 100: POT-округление дало бы 128 > лимита.
var result = ShelfPacker.Pack(Squares(10, 40), maxPageSize: 100, padding: 2);
Assert.All(
result.PageSizes,
size =>
{
Assert.True(size.Width <= 100, $"page width {size.Width} > 100");
Assert.True(size.Height <= 100, $"page height {size.Height} > 100");
}
);
}
[Fact]
public void Pack_OversizedItem_PageHasExactPaddedSize()
{
var result = ShelfPacker.Pack([new PackItem("big", 300, 50)], maxPageSize: 128, padding: 2);
Assert.Equal((304, 54), result.PageSizes.Single());
}
[Fact]
public void Pack_OversizedAmongSmall_DoesNotSplitSharedPage()
{
// Широкий и низкий негабарит сортируется в конец по высоте; раньше он закрывал
// наполовину заполненную общую страницу, и остаток уезжал на новую.
var items = new List<PackItem> { new("big", 300, 10) };
for (var i = 0; i < 4; i++)
{
items.Add(new PackItem($"s{i}", 30, 30));
}
var result = ShelfPacker.Pack(items, maxPageSize: 128, padding: 2);
Assert.Equal(2, result.PageSizes.Count); // отдельная страница big + одна общая
}
}
@@ -0,0 +1,146 @@
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,101 @@
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,115 @@
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;
}
}