using System.Text.Json.Nodes; using Microsoft.AspNetCore.Mvc.Testing; namespace NeonSprawl.Server.Tests.Game.Npc; public class NpcBehaviorDefinitionCatalogLoaderTests { private const string ValidPrototypeCatalogJson = """ { "schemaVersion": 1, "npcBehaviors": [ { "id": "prototype_melee_pressure", "displayName": "Melee Pressure", "archetypeKind": "melee_pressure", "maxHp": 100, "aggroRadius": 8.0, "leashRadius": 16.0, "telegraphWindupSeconds": 1.5, "attackDamage": 15, "attackCooldownSeconds": 3.0, "attackAbilityId": "prototype_npc_melee_strike" }, { "id": "prototype_ranged_control", "displayName": "Ranged Control", "archetypeKind": "ranged_control", "maxHp": 80, "aggroRadius": 10.0, "leashRadius": 20.0, "telegraphWindupSeconds": 2.0, "attackDamage": 12, "attackCooldownSeconds": 4.0, "attackAbilityId": "prototype_npc_ranged_shot" }, { "id": "prototype_elite_mini_boss", "displayName": "Elite Mini-Boss", "archetypeKind": "elite_mini_boss", "maxHp": 200, "aggroRadius": 8.0, "leashRadius": 18.0, "telegraphWindupSeconds": 2.5, "attackDamage": 25, "attackCooldownSeconds": 5.0, "attackAbilityId": "prototype_npc_elite_slam" } ] } """; private static (string Root, string NpcBehaviorsDir, string SchemaPath) CreateTempContentLayout() { var root = Directory.CreateTempSubdirectory("neon-sprawl-npcbehaviorcat-"); var npcBehaviorsDir = Path.Combine(root.FullName, "content", "npc-behaviors"); var schemaDir = Path.Combine(root.FullName, "content", "schemas"); Directory.CreateDirectory(npcBehaviorsDir); Directory.CreateDirectory(schemaDir); var schemaPath = Path.Combine(schemaDir, "npc-behavior-def.schema.json"); File.Copy(NpcBehaviorCatalogTestPaths.DiscoverRepoNpcBehaviorDefSchemaPath(), schemaPath, overwrite: true); return (root.FullName, npcBehaviorsDir, schemaPath); } private static void WriteCatalog(string npcBehaviorsDir, string catalogJson) => File.WriteAllText(Path.Combine(npcBehaviorsDir, "prototype_npc_behaviors.json"), catalogJson, Encoding.UTF8); private static JsonObject GetBehaviorRow(JsonObject catalogRoot, string behaviorId) { var npcBehaviors = catalogRoot["npcBehaviors"] as JsonArray ?? throw new InvalidOperationException("expected npcBehaviors array"); foreach (var node in npcBehaviors) { if (node is JsonObject row && row["id"]?.GetValue() == behaviorId) return row; } throw new InvalidOperationException($"npc behavior id not found: {behaviorId}"); } private static NpcBehaviorDefinitionCatalog LoadCatalog(string npcBehaviorsDir, string schemaPath) => NpcBehaviorDefinitionCatalogLoader.Load(npcBehaviorsDir, schemaPath, NullLogger.Instance); [Fact] public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract() { // Arrange var (_, npcBehaviorsDir, schemaPath) = CreateTempContentLayout(); WriteCatalog(npcBehaviorsDir, ValidPrototypeCatalogJson); // Act var catalog = LoadCatalog(npcBehaviorsDir, schemaPath); // Assert Assert.Equal(3, catalog.DistinctBehaviorCount); Assert.Equal(1, catalog.CatalogJsonFileCount); Assert.True(catalog.TryGetBehavior("prototype_melee_pressure", out var melee)); Assert.NotNull(melee); Assert.Equal("melee_pressure", melee!.ArchetypeKind); Assert.Equal(100, melee.MaxHp); Assert.Equal(8.0, melee.AggroRadius); Assert.Equal(16.0, melee.LeashRadius); Assert.Equal(15, melee.AttackDamage); } [Fact] public void Load_ShouldThrow_WhenNpcBehaviorsIsNotArray() { // Arrange var (_, npcBehaviorsDir, schemaPath) = CreateTempContentLayout(); File.WriteAllText( Path.Combine(npcBehaviorsDir, "bad_npc_behaviors.json"), """{"schemaVersion": 1, "npcBehaviors": "nope"}""", Encoding.UTF8); // Act var ex = Record.Exception(() => LoadCatalog(npcBehaviorsDir, schemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("bad_npc_behaviors.json", ioe.Message, StringComparison.Ordinal); Assert.Contains("expected top-level 'npcBehaviors' array", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenSchemaVersionIsNotOne() { // Arrange var (_, npcBehaviorsDir, schemaPath) = CreateTempContentLayout(); File.WriteAllText( Path.Combine(npcBehaviorsDir, "bad_npc_behaviors.json"), """{"schemaVersion": 2, "npcBehaviors": []}""", Encoding.UTF8); // Act var ex = Record.Exception(() => LoadCatalog(npcBehaviorsDir, schemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("expected schemaVersion 1", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenDuplicateBehaviorIdAcrossFiles() { // Arrange var (_, npcBehaviorsDir, schemaPath) = CreateTempContentLayout(); WriteCatalog(npcBehaviorsDir, ValidPrototypeCatalogJson); File.WriteAllText( Path.Combine(npcBehaviorsDir, "extra_npc_behaviors.json"), """ { "schemaVersion": 1, "npcBehaviors": [ { "id": "prototype_melee_pressure", "displayName": "Duplicate Melee", "archetypeKind": "melee_pressure", "maxHp": 1, "aggroRadius": 1.0, "leashRadius": 2.0, "telegraphWindupSeconds": 1.0, "attackDamage": 1, "attackCooldownSeconds": 1.0, "attackAbilityId": "prototype_npc_melee_strike" } ] } """, Encoding.UTF8); // Act var ex = Record.Exception(() => LoadCatalog(npcBehaviorsDir, schemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("duplicate npc behavior id 'prototype_melee_pressure'", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenE5M2GateFailsWithMissingId() { // Arrange var (_, npcBehaviorsDir, schemaPath) = CreateTempContentLayout(); var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject ?? throw new InvalidOperationException("expected object root"); var npcBehaviors = root["npcBehaviors"] as JsonArray ?? throw new InvalidOperationException("expected npcBehaviors array"); npcBehaviors.RemoveAt(npcBehaviors.Count - 1); WriteCatalog(npcBehaviorsDir, root.ToJsonString()); // Act var ex = Record.Exception(() => LoadCatalog(npcBehaviorsDir, schemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("prototype E5M2 expects exactly npc behavior ids", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenE5M2GateFailsWithExtraId() { // Arrange var (_, npcBehaviorsDir, schemaPath) = CreateTempContentLayout(); var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject ?? throw new InvalidOperationException("expected object root"); GetBehaviorRow(root, "prototype_elite_mini_boss")["id"] = "prototype_extra"; WriteCatalog(npcBehaviorsDir, root.ToJsonString()); // Act var ex = Record.Exception(() => LoadCatalog(npcBehaviorsDir, schemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("prototype E5M2 expects exactly npc behavior ids", ioe.Message, StringComparison.Ordinal); Assert.Contains("prototype_extra", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenLeashRadiusIsNotGreaterThanAggroRadius() { // Arrange var (_, npcBehaviorsDir, schemaPath) = CreateTempContentLayout(); var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject ?? throw new InvalidOperationException("expected object root"); GetBehaviorRow(root, "prototype_melee_pressure")["leashRadius"] = 8.0; WriteCatalog(npcBehaviorsDir, root.ToJsonString()); // Act var ex = Record.Exception(() => LoadCatalog(npcBehaviorsDir, schemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("leashRadius 8 must be > aggroRadius 8", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenAggroRadiusIsZero() { // Arrange var (_, npcBehaviorsDir, schemaPath) = CreateTempContentLayout(); var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject ?? throw new InvalidOperationException("expected object root"); GetBehaviorRow(root, "prototype_melee_pressure")["aggroRadius"] = 0; WriteCatalog(npcBehaviorsDir, root.ToJsonString()); // Act var ex = Record.Exception(() => LoadCatalog(npcBehaviorsDir, schemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("NPC behavior catalog validation failed", ioe.Message, StringComparison.Ordinal); Assert.Contains("aggroRadius", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenMaxHpIsZero() { // Arrange var (_, npcBehaviorsDir, schemaPath) = CreateTempContentLayout(); var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject ?? throw new InvalidOperationException("expected object root"); GetBehaviorRow(root, "prototype_melee_pressure")["maxHp"] = 0; WriteCatalog(npcBehaviorsDir, root.ToJsonString()); // Act var ex = Record.Exception(() => LoadCatalog(npcBehaviorsDir, schemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("NPC behavior catalog validation failed", ioe.Message, StringComparison.Ordinal); Assert.Contains("maxHp", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenDisplayNameIsEmpty() { // Arrange var (_, npcBehaviorsDir, schemaPath) = CreateTempContentLayout(); var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject ?? throw new InvalidOperationException("expected object root"); GetBehaviorRow(root, "prototype_melee_pressure")["displayName"] = ""; WriteCatalog(npcBehaviorsDir, root.ToJsonString()); // Act var ex = Record.Exception(() => LoadCatalog(npcBehaviorsDir, schemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("NPC behavior catalog validation failed", ioe.Message, StringComparison.Ordinal); Assert.Contains("displayName", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenNoNpcBehaviorCatalogFiles() { // Arrange var (_, npcBehaviorsDir, schemaPath) = CreateTempContentLayout(); // Act var ex = Record.Exception(() => LoadCatalog(npcBehaviorsDir, schemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("no *_npc_behaviors.json files", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenJsonIsInvalid() { // Arrange var (_, npcBehaviorsDir, schemaPath) = CreateTempContentLayout(); File.WriteAllText( Path.Combine(npcBehaviorsDir, "bad_npc_behaviors.json"), "{not json", Encoding.UTF8); // Act var ex = Record.Exception(() => LoadCatalog(npcBehaviorsDir, schemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("bad_npc_behaviors.json", ioe.Message, StringComparison.Ordinal); Assert.Contains("invalid JSON", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenNpcBehaviorsDirectoryMissing() { // Arrange var missingDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-no-npc-behaviors-" + Guid.NewGuid().ToString("n")); var schemaPath = NpcBehaviorCatalogTestPaths.DiscoverRepoNpcBehaviorDefSchemaPath(); // Act var ex = Record.Exception(() => NpcBehaviorDefinitionCatalogLoader.Load(missingDir, schemaPath, NullLogger.Instance)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("missing directory", ioe.Message, StringComparison.Ordinal); Assert.Contains(missingDir, ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenSchemaFileMissing() { // Arrange var (_, npcBehaviorsDir, _) = CreateTempContentLayout(); var missingSchema = Path.Combine(npcBehaviorsDir, "missing-npc-behavior-def.schema.json"); // Act var ex = Record.Exception(() => NpcBehaviorDefinitionCatalogLoader.Load(npcBehaviorsDir, missingSchema, NullLogger.Instance)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("missing schema file", ioe.Message, StringComparison.Ordinal); Assert.Contains(missingSchema, ioe.Message, StringComparison.Ordinal); } [Fact] public async Task Host_ShouldResolveCatalogFromDi_WhenStartupSucceeds() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); using var client = factory.CreateClient(); // Act var response = await client.GetAsync("/health"); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); var catalog = factory.Services.GetRequiredService(); Assert.Equal(3, catalog.DistinctBehaviorCount); Assert.True(catalog.TryGetBehavior("prototype_elite_mini_boss", out var elite)); Assert.Equal(200, elite!.MaxHp); Assert.Equal(25, elite.AttackDamage); Assert.Equal(5.0, elite.AttackCooldownSeconds); } [Fact] public void Host_ShouldFailStartup_WhenCatalogDirectoryInvalid() { // Arrange var badDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-empty-npc-behaviors-" + Guid.NewGuid().ToString("n")); Directory.CreateDirectory(badDir); try { // Act var ex = Record.Exception(() => { using var factory = new WebApplicationFactory().WithWebHostBuilder(b => b.UseSetting("Content:NpcBehaviorsDirectory", badDir)); factory.CreateClient(); }); // Assert Assert.NotNull(ex); Assert.Contains("NPC behavior catalog validation failed", ex.ToString(), StringComparison.Ordinal); } finally { if (Directory.Exists(badDir)) Directory.Delete(badDir); } } }