diff --git a/bruno/neon-sprawl-server/npc-behavior-catalog/Health after npc behavior catalog load.bru b/bruno/neon-sprawl-server/npc-behavior-catalog/Health after npc behavior catalog load.bru new file mode 100644 index 0000000..17bd525 --- /dev/null +++ b/bruno/neon-sprawl-server/npc-behavior-catalog/Health after npc behavior catalog load.bru @@ -0,0 +1,25 @@ +meta { + name: GET health (npc behavior catalog boot NEO-88) + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/health + body: none + auth: none +} + +docs { + NEO-88 loads content/npc-behaviors/*_npc_behaviors.json at startup (fail-fast). No NPC behavior HTTP API in this story — use this request to confirm the host started after catalog validation. +} + +tests { + test("status 200", function () { + expect(res.getStatus()).to.equal(200); + }); + + test("service identity", function () { + expect(res.getBody().service).to.equal("NeonSprawl.Server"); + }); +} diff --git a/bruno/neon-sprawl-server/npc-behavior-catalog/folder.bru b/bruno/neon-sprawl-server/npc-behavior-catalog/folder.bru new file mode 100644 index 0000000..74951ca --- /dev/null +++ b/bruno/neon-sprawl-server/npc-behavior-catalog/folder.bru @@ -0,0 +1,3 @@ +meta { + name: npc-behavior-catalog +} diff --git a/server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs index c1f96b7..7a1ae90 100644 --- a/server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs @@ -11,6 +11,7 @@ using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Gathering; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; +using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.Skills; using Npgsql; @@ -45,12 +46,16 @@ public sealed class SalvageActivityDeniedRegistryWebApplicationFactory : WebAppl var abilitiesDir = AbilityCatalogPathResolution.TryDiscoverAbilitiesDirectory(AppContext.BaseDirectory) ?? throw new InvalidOperationException( "Could not discover repo content/abilities from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); + var npcBehaviorsDir = NpcBehaviorCatalogPathResolution.TryDiscoverNpcBehaviorsDirectory(AppContext.BaseDirectory) + ?? throw new InvalidOperationException( + "Could not discover repo content/npc-behaviors from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); builder.UseSetting("Content:SkillsDirectory", skillsDir); builder.UseSetting("Content:MasteryDirectory", masteryDir); builder.UseSetting("Content:ItemsDirectory", itemsDir); builder.UseSetting("Content:ResourceNodesDirectory", resourceNodesDir); builder.UseSetting("Content:RecipesDirectory", recipesDir); builder.UseSetting("Content:AbilitiesDirectory", abilitiesDir); + builder.UseSetting("Content:NpcBehaviorsDirectory", npcBehaviorsDir); builder.UseSetting("Game:EnableMasteryFixtureApi", "true"); builder.ConfigureTestServices(services => diff --git a/server/NeonSprawl.Server.Tests/Game/Npc/NpcBehaviorCatalogTestPaths.cs b/server/NeonSprawl.Server.Tests/Game/Npc/NpcBehaviorCatalogTestPaths.cs new file mode 100644 index 0000000..0b41c6b --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Npc/NpcBehaviorCatalogTestPaths.cs @@ -0,0 +1,17 @@ +using NeonSprawl.Server.Game.Npc; + +namespace NeonSprawl.Server.Tests.Game.Npc; + +internal static class NpcBehaviorCatalogTestPaths +{ + internal static string DiscoverRepoNpcBehaviorsDirectory() => + NpcBehaviorCatalogPathResolution.TryDiscoverNpcBehaviorsDirectory(AppContext.BaseDirectory) + ?? throw new InvalidOperationException( + "Could not discover repo content/npc-behaviors from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); + + internal static string DiscoverRepoNpcBehaviorDefSchemaPath() => + NpcBehaviorCatalogPathResolution.ResolveNpcBehaviorDefSchemaPath( + DiscoverRepoNpcBehaviorsDirectory(), + configuredSchemaPath: null, + contentRootPath: string.Empty); +} diff --git a/server/NeonSprawl.Server.Tests/Game/Npc/NpcBehaviorDefinitionCatalogLoaderTests.cs b/server/NeonSprawl.Server.Tests/Game/Npc/NpcBehaviorDefinitionCatalogLoaderTests.cs new file mode 100644 index 0000000..b595075 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Npc/NpcBehaviorDefinitionCatalogLoaderTests.cs @@ -0,0 +1,379 @@ +using System.Net; +using System.Text; +using System.Text.Json.Nodes; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NeonSprawl.Server.Game.Npc; +using NeonSprawl.Server.Tests; +using Xunit; + +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 + }, + { + "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 + }, + { + "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 + } + ] + } + """; + + 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 + } + ] + } + """, + 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); + } + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresWebApplicationFactory.cs index dd6072b..f52eeb0 100644 --- a/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresWebApplicationFactory.cs @@ -6,6 +6,7 @@ using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Gathering; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; +using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Game.Skills; namespace NeonSprawl.Server.Tests.Game.PositionState; @@ -43,12 +44,16 @@ public sealed class PostgresWebApplicationFactory : WebApplicationFactory { diff --git a/server/NeonSprawl.Server.Tests/Game/Skills/MissionRewardDeniedRegistryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/Game/Skills/MissionRewardDeniedRegistryWebApplicationFactory.cs index fb336d1..b4bcd82 100644 --- a/server/NeonSprawl.Server.Tests/Game/Skills/MissionRewardDeniedRegistryWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/Game/Skills/MissionRewardDeniedRegistryWebApplicationFactory.cs @@ -9,6 +9,7 @@ using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Mastery; +using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.Skills; using Npgsql; @@ -37,10 +38,14 @@ public sealed class MissionRewardDeniedRegistryWebApplicationFactory : WebApplic var abilitiesDir = AbilityCatalogPathResolution.TryDiscoverAbilitiesDirectory(AppContext.BaseDirectory) ?? throw new InvalidOperationException( "Could not discover repo content/abilities from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); + var npcBehaviorsDir = NpcBehaviorCatalogPathResolution.TryDiscoverNpcBehaviorsDirectory(AppContext.BaseDirectory) + ?? throw new InvalidOperationException( + "Could not discover repo content/npc-behaviors from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); builder.UseSetting("Content:SkillsDirectory", skillsDir); builder.UseSetting("Content:MasteryDirectory", masteryDir); builder.UseSetting("Content:RecipesDirectory", recipesDir); builder.UseSetting("Content:AbilitiesDirectory", abilitiesDir); + builder.UseSetting("Content:NpcBehaviorsDirectory", npcBehaviorsDir); builder.ConfigureTestServices(services => { diff --git a/server/NeonSprawl.Server.Tests/Game/Skills/RefineActivityDeniedRegistryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/Game/Skills/RefineActivityDeniedRegistryWebApplicationFactory.cs index b0b9a23..5d46c3e 100644 --- a/server/NeonSprawl.Server.Tests/Game/Skills/RefineActivityDeniedRegistryWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/Game/Skills/RefineActivityDeniedRegistryWebApplicationFactory.cs @@ -9,6 +9,7 @@ using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Mastery; +using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.Skills; using Npgsql; @@ -37,10 +38,14 @@ public sealed class RefineActivityDeniedRegistryWebApplicationFactory : WebAppli var abilitiesDir = AbilityCatalogPathResolution.TryDiscoverAbilitiesDirectory(AppContext.BaseDirectory) ?? throw new InvalidOperationException( "Could not discover repo content/abilities from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); + var npcBehaviorsDir = NpcBehaviorCatalogPathResolution.TryDiscoverNpcBehaviorsDirectory(AppContext.BaseDirectory) + ?? throw new InvalidOperationException( + "Could not discover repo content/npc-behaviors from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); builder.UseSetting("Content:SkillsDirectory", skillsDir); builder.UseSetting("Content:MasteryDirectory", masteryDir); builder.UseSetting("Content:RecipesDirectory", recipesDir); builder.UseSetting("Content:AbilitiesDirectory", abilitiesDir); + builder.UseSetting("Content:NpcBehaviorsDirectory", npcBehaviorsDir); builder.ConfigureTestServices(services => { diff --git a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs index 65b2821..52b36c9 100644 --- a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs @@ -12,6 +12,7 @@ using NeonSprawl.Server.Game.Gigs; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.Mastery; +using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Game.Skills; using Npgsql; @@ -47,12 +48,16 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory diff --git a/server/NeonSprawl.Server/Game/Npc/NpcBehaviorCatalogPathResolution.cs b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorCatalogPathResolution.cs new file mode 100644 index 0000000..15e59fe --- /dev/null +++ b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorCatalogPathResolution.cs @@ -0,0 +1,60 @@ +namespace NeonSprawl.Server.Game.Npc; + +/// Resolves NPC behavior catalog paths for local dev, tests, and container layouts (NEO-88). +public static class NpcBehaviorCatalogPathResolution +{ + /// Walks and parents for an existing content/npc-behaviors directory. + public static string? TryDiscoverNpcBehaviorsDirectory(string startDirectory) + { + for (var dir = new DirectoryInfo(startDirectory); dir is not null; dir = dir.Parent) + { + var candidate = Path.Combine(dir.FullName, "content", "npc-behaviors"); + if (Directory.Exists(candidate)) + return Path.GetFullPath(candidate); + } + + return null; + } + + /// + /// Resolves the npc-behaviors catalog directory. + /// Empty triggers discovery from . + /// + public static string ResolveNpcBehaviorsDirectory(string? configuredNpcBehaviorsDirectory, string contentRootPath) + { + if (string.IsNullOrWhiteSpace(configuredNpcBehaviorsDirectory)) + { + var discovered = TryDiscoverNpcBehaviorsDirectory(AppContext.BaseDirectory); + if (discovered is not null) + return discovered; + + throw new InvalidOperationException( + "Content:NpcBehaviorsDirectory is not set and auto-discovery failed (no ancestor of AppContext.BaseDirectory contains 'content/npc-behaviors'). " + + "Set Content:NpcBehaviorsDirectory in configuration or environment (e.g. Content__NpcBehaviorsDirectory)."); + } + + var trimmed = configuredNpcBehaviorsDirectory.Trim(); + if (Path.IsPathRooted(trimmed)) + return Path.GetFullPath(trimmed); + + return Path.GetFullPath(Path.Combine(contentRootPath, trimmed)); + } + + /// Resolves JSON Schema path for a single NPC behavior row (Draft 2020-12). + public static string ResolveNpcBehaviorDefSchemaPath( + string npcBehaviorsDirectory, + string? configuredSchemaPath, + string contentRootPath) + { + if (!string.IsNullOrWhiteSpace(configuredSchemaPath)) + { + var trimmed = configuredSchemaPath.Trim(); + if (Path.IsPathRooted(trimmed)) + return Path.GetFullPath(trimmed); + + return Path.GetFullPath(Path.Combine(contentRootPath, trimmed)); + } + + return Path.GetFullPath(Path.Combine(npcBehaviorsDirectory, "..", "schemas", "npc-behavior-def.schema.json")); + } +} diff --git a/server/NeonSprawl.Server/Game/Npc/NpcBehaviorCatalogServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorCatalogServiceCollectionExtensions.cs new file mode 100644 index 0000000..9f16625 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorCatalogServiceCollectionExtensions.cs @@ -0,0 +1,37 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using NeonSprawl.Server.Game.Skills; + +namespace NeonSprawl.Server.Game.Npc; + +/// DI registration for the fail-fast NPC behavior catalog (NEO-88). +public static class NpcBehaviorCatalogServiceCollectionExtensions +{ + /// Binds and registers as a singleton. + public static IServiceCollection AddNpcBehaviorDefinitionCatalog(this IServiceCollection services, IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration.GetSection(ContentPathsOptions.SectionName)); + + services.AddSingleton(sp => + { + var hostEnv = sp.GetRequiredService(); + var opts = sp.GetRequiredService>().Value; + var logger = sp.GetRequiredService() + .CreateLogger("NeonSprawl.Server.Game.Npc.NpcBehaviorCatalog"); + + var npcBehaviorsDir = NpcBehaviorCatalogPathResolution.ResolveNpcBehaviorsDirectory( + opts.NpcBehaviorsDirectory, + hostEnv.ContentRootPath); + var schemaPath = NpcBehaviorCatalogPathResolution.ResolveNpcBehaviorDefSchemaPath( + npcBehaviorsDir, + opts.NpcBehaviorDefSchemaPath, + hostEnv.ContentRootPath); + + return NpcBehaviorDefinitionCatalogLoader.Load(npcBehaviorsDir, schemaPath, logger); + }); + + return services; + } +} diff --git a/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefRow.cs b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefRow.cs new file mode 100644 index 0000000..1f93d9a --- /dev/null +++ b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefRow.cs @@ -0,0 +1,13 @@ +namespace NeonSprawl.Server.Game.Npc; + +/// One validated NpcBehaviorDef row from content/npc-behaviors/*_npc_behaviors.json (NEO-88). +public sealed record NpcBehaviorDefRow( + string Id, + string DisplayName, + string ArchetypeKind, + int MaxHp, + double AggroRadius, + double LeashRadius, + double TelegraphWindupSeconds, + int AttackDamage, + double AttackCooldownSeconds); diff --git a/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionCatalog.cs b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionCatalog.cs new file mode 100644 index 0000000..14d2264 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionCatalog.cs @@ -0,0 +1,25 @@ +using System.Collections.ObjectModel; + +namespace NeonSprawl.Server.Game.Npc; + +/// In-memory NPC behavior catalog loaded at startup (NEO-88). Game callers should use INpcBehaviorDefinitionRegistry (NEO-89). +public sealed class NpcBehaviorDefinitionCatalog( + string npcBehaviorsDirectory, + IReadOnlyDictionary byId, + int catalogJsonFileCount) +{ + /// Absolute path to the directory that was enumerated for *_npc_behaviors.json catalogs. + public string NpcBehaviorsDirectory { get; } = npcBehaviorsDirectory; + + public IReadOnlyDictionary ById { get; } = + new ReadOnlyDictionary(new Dictionary(byId, StringComparer.Ordinal)); + + public int DistinctBehaviorCount => ById.Count; + + /// Number of *_npc_behaviors.json files under . + public int CatalogJsonFileCount { get; } = catalogJsonFileCount; + + /// Resolves a catalog row by stable behavior . + public bool TryGetBehavior(string id, out NpcBehaviorDefRow? row) => + ById.TryGetValue(id, out row); +} diff --git a/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionCatalogLoader.cs b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionCatalogLoader.cs new file mode 100644 index 0000000..be7cddd --- /dev/null +++ b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionCatalogLoader.cs @@ -0,0 +1,208 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Json.Schema; +using Microsoft.Extensions.Logging; + +namespace NeonSprawl.Server.Game.Npc; + +/// Loads and validates content/npc-behaviors/*_npc_behaviors.json using the same rules as scripts/validate_content.py (NEO-88). +public static class NpcBehaviorDefinitionCatalogLoader +{ + /// Loads catalogs from disk or throws with actionable messages. + public static NpcBehaviorDefinitionCatalog Load(string npcBehaviorsDirectory, string schemaPath, ILogger logger) + { + npcBehaviorsDirectory = Path.GetFullPath(npcBehaviorsDirectory); + schemaPath = Path.GetFullPath(schemaPath); + + var errors = new List(); + + if (!File.Exists(schemaPath)) + errors.Add($"error: missing schema file {schemaPath}"); + + if (!Directory.Exists(npcBehaviorsDirectory)) + errors.Add($"error: missing directory {npcBehaviorsDirectory}"); + + if (errors.Count > 0) + ThrowIfAny(errors); + + var jsonFiles = Directory.GetFiles(npcBehaviorsDirectory, "*_npc_behaviors.json", SearchOption.TopDirectoryOnly) + .OrderBy(p => p, StringComparer.Ordinal) + .ToArray(); + if (jsonFiles.Length == 0) + errors.Add($"error: no *_npc_behaviors.json files under {npcBehaviorsDirectory}"); + + if (errors.Count > 0) + ThrowIfAny(errors); + + var schema = JsonSchema.FromText(File.ReadAllText(schemaPath)); + var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List }; + + var behaviorIdToSourceFile = new Dictionary(StringComparer.Ordinal); + var rows = new Dictionary(StringComparer.Ordinal); + + foreach (var path in jsonFiles) + { + JsonNode? root; + try + { + root = JsonNode.Parse(File.ReadAllText(path)); + } + catch (JsonException ex) + { + errors.Add($"error: {path}: invalid JSON: {ex.Message}"); + continue; + } + + if (root is not JsonObject rootObj) + { + errors.Add($"error: {path}: expected JSON object at root"); + continue; + } + + var schemaVersionNode = rootObj["schemaVersion"]; + if (schemaVersionNode is not JsonValue schemaVersionValue || + !schemaVersionValue.TryGetValue(out var schemaVersion) || + schemaVersion != 1) + { + var got = schemaVersionNode?.ToJsonString() ?? "null"; + errors.Add($"error: {path}: expected schemaVersion 1, got {got}"); + continue; + } + + var npcBehaviorsNode = rootObj["npcBehaviors"]; + if (npcBehaviorsNode is not JsonArray npcBehaviorsArray) + { + errors.Add($"error: {path}: expected top-level 'npcBehaviors' array"); + continue; + } + + for (var i = 0; i < npcBehaviorsArray.Count; i++) + { + var behavior = npcBehaviorsArray[i]; + if (behavior is not JsonObject rowObj) + { + errors.Add($"error: {path}: npcBehaviors[{i}] must be an object"); + continue; + } + + var eval = schema.Evaluate(rowObj, evalOptions); + var schemaMsgs = CollectSchemaMessages(eval, path, i).OrderBy(m => m, StringComparer.Ordinal).ToList(); + if (!eval.IsValid) + { + if (schemaMsgs.Count == 0) + schemaMsgs.Add($"error: {path} npcBehaviors[{i}] (root): schema validation failed"); + + errors.AddRange(schemaMsgs); + } + + var rowSchemaErrors = schemaMsgs.Count; + + var bid = (rowObj["id"] as JsonValue)?.GetValue(); + if (bid is not null && rowSchemaErrors == 0) + { + if (behaviorIdToSourceFile.TryGetValue(bid, out var prevPath)) + { + errors.Add($"error: duplicate npc behavior id '{bid}' in {prevPath} and {path}"); + continue; + } + + behaviorIdToSourceFile[bid] = path; + rows[bid] = ParseRow(rowObj); + } + } + } + + ThrowIfAny(errors); + + var e5m2 = PrototypeE5M2NpcBehaviorCatalogRules.TryGetE5M2GateError(behaviorIdToSourceFile); + if (e5m2 is not null) + { + errors.Add(e5m2); + ThrowIfAny(errors); + } + + var numeric = PrototypeE5M2NpcBehaviorCatalogRules.TryGetNumericGateError(rows); + if (numeric is not null) + { + errors.Add(numeric); + ThrowIfAny(errors); + } + + if (logger.IsEnabled(LogLevel.Information)) + { + logger.LogInformation( + "Loaded NPC behavior catalog from {NpcBehaviorsDirectory}: {BehaviorCount} behavior(s) across {CatalogFileCount} JSON catalog file(s).", + npcBehaviorsDirectory, + rows.Count, + jsonFiles.Length); + } + + return new NpcBehaviorDefinitionCatalog(npcBehaviorsDirectory, rows, jsonFiles.Length); + } + + private static NpcBehaviorDefRow ParseRow(JsonObject rowObj) + { + var id = (rowObj["id"] as JsonValue)!.GetValue(); + var displayName = (rowObj["displayName"] as JsonValue)!.GetValue(); + var archetypeKind = (rowObj["archetypeKind"] as JsonValue)!.GetValue(); + var maxHp = (rowObj["maxHp"] as JsonValue)!.GetValue(); + var aggroRadius = (rowObj["aggroRadius"] as JsonValue)!.GetValue(); + var leashRadius = (rowObj["leashRadius"] as JsonValue)!.GetValue(); + var telegraphWindupSeconds = (rowObj["telegraphWindupSeconds"] as JsonValue)!.GetValue(); + var attackDamage = (rowObj["attackDamage"] as JsonValue)!.GetValue(); + var attackCooldownSeconds = (rowObj["attackCooldownSeconds"] as JsonValue)!.GetValue(); + + return new NpcBehaviorDefRow( + id, + displayName, + archetypeKind, + maxHp, + aggroRadius, + leashRadius, + telegraphWindupSeconds, + attackDamage, + attackCooldownSeconds); + } + + private static List CollectSchemaMessages(EvaluationResults eval, string filePath, int index) + { + var sink = new List(); + AppendSchemaMessages(eval, filePath, index, sink); + return sink; + } + + private static void AppendSchemaMessages(EvaluationResults r, string filePath, int index, List sink) + { + if (r.HasDetails) + { + foreach (var d in r.Details!) + AppendSchemaMessages(d, filePath, index, sink); + } + + if (!r.HasErrors) + return; + + foreach (var kv in r.Errors!) + { + var loc = r.InstanceLocation?.ToString(); + if (string.IsNullOrEmpty(loc) || loc == "#") + loc = "(root)"; + + sink.Add($"error: {filePath} npcBehaviors[{index}] {loc}: {kv.Key} — {kv.Value}"); + } + } + + private static void ThrowIfAny(List errors) + { + if (errors.Count == 0) + return; + + var sb = new StringBuilder(); + sb.AppendLine("NPC behavior catalog validation failed:"); + foreach (var e in errors.OrderBy(x => x, StringComparer.Ordinal)) + sb.AppendLine(e); + + throw new InvalidOperationException(sb.ToString().TrimEnd()); + } +} diff --git a/server/NeonSprawl.Server/Game/Npc/PrototypeE5M2NpcBehaviorCatalogRules.cs b/server/NeonSprawl.Server/Game/Npc/PrototypeE5M2NpcBehaviorCatalogRules.cs new file mode 100644 index 0000000..3bb2de9 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Npc/PrototypeE5M2NpcBehaviorCatalogRules.cs @@ -0,0 +1,50 @@ +using System.Collections.Frozen; + +namespace NeonSprawl.Server.Game.Npc; + +/// +/// Prototype E5M2 roster + numeric gates (NEO-87 / NEO-88), mirrored from scripts/validate_content.py +/// PROTOTYPE_E5M2_NPC_BEHAVIOR_IDS, _prototype_e5m2_npc_behavior_gate, and +/// _prototype_e5m2_npc_behavior_numeric_gate. +/// +public static class PrototypeE5M2NpcBehaviorCatalogRules +{ + /// Keep in sync with scripts/validate_content.py PROTOTYPE_E5M2_NPC_BEHAVIOR_IDS. + public static readonly FrozenSet ExpectedBehaviorIds = FrozenSet.ToFrozenSet( + [ + "prototype_melee_pressure", + "prototype_ranged_control", + "prototype_elite_mini_boss", + ], + StringComparer.Ordinal); + + /// Returns a human-readable error if the E5M2 behavior contract fails, otherwise . + public static string? TryGetE5M2GateError(IReadOnlyDictionary behaviorIdToSourceFile) + { + var ids = behaviorIdToSourceFile.Keys.ToFrozenSet(StringComparer.Ordinal); + if (!ids.SetEquals(ExpectedBehaviorIds)) + { + return + "error: prototype E5M2 expects exactly npc behavior ids " + + $"[{string.Join(", ", ExpectedBehaviorIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " + + $"got [{string.Join(", ", ids.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}]"; + } + + return null; + } + + /// Returns a human-readable error when leashRadius <= aggroRadius, otherwise . + public static string? TryGetNumericGateError(IReadOnlyDictionary rowsById) + { + foreach (var (bid, row) in rowsById) + { + if (row.LeashRadius <= row.AggroRadius) + { + return + $"error: npc behavior '{bid}': leashRadius {row.LeashRadius} must be > aggroRadius {row.AggroRadius}"; + } + } + + return null; + } +} diff --git a/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs b/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs index 93da821..d04a590 100644 --- a/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs +++ b/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs @@ -88,4 +88,16 @@ public sealed class ContentPathsOptions /// When unset, resolved as {parent of abilities directory}/schemas/ability-def.schema.json. /// public string? AbilityDefSchemaPath { get; set; } + + /// + /// Optional. Absolute path, or path relative to . + /// When unset, the host walks ancestors of for a content/npc-behaviors directory. + /// + public string? NpcBehaviorsDirectory { get; set; } + + /// + /// Optional override for npc-behavior-def.schema.json. + /// When unset, resolved as {parent of npc-behaviors directory}/schemas/npc-behavior-def.schema.json. + /// + public string? NpcBehaviorDefSchemaPath { get; set; } } diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index f10b458..d3b7020 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -6,6 +6,7 @@ using NeonSprawl.Server.Game.Gigs; using NeonSprawl.Server.Game.Interaction; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; +using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Game.Targeting; @@ -23,6 +24,7 @@ builder.Services.AddItemDefinitionCatalog(builder.Configuration); builder.Services.AddResourceNodeCatalog(builder.Configuration); builder.Services.AddRecipeDefinitionCatalog(builder.Configuration); builder.Services.AddAbilityDefinitionCatalog(builder.Configuration); +builder.Services.AddNpcBehaviorDefinitionCatalog(builder.Configuration); builder.Services.AddMasteryCatalog(builder.Configuration); var app = builder.Build(); @@ -31,6 +33,7 @@ _ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); +_ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); diff --git a/server/NeonSprawl.Server/appsettings.json b/server/NeonSprawl.Server/appsettings.json index b13ffb7..313e931 100644 --- a/server/NeonSprawl.Server/appsettings.json +++ b/server/NeonSprawl.Server/appsettings.json @@ -15,7 +15,9 @@ "RecipeDefSchemaPath": "", "RecipeIoRowSchemaPath": "", "AbilitiesDirectory": "", - "AbilityDefSchemaPath": "" + "AbilityDefSchemaPath": "", + "NpcBehaviorsDirectory": "", + "NpcBehaviorDefSchemaPath": "" }, "Game": { "DevPlayerId": "dev-local-1", diff --git a/server/README.md b/server/README.md index aeda2c6..ab9a4e9 100644 --- a/server/README.md +++ b/server/README.md @@ -93,6 +93,19 @@ On startup the host loads every **`*_abilities.json`** under the abilities direc On success, **Information** logs include the resolved abilities directory path, distinct ability count, and catalog file count. Game code should use **`IAbilityDefinitionRegistry`** for lookups (NEO-79). The catalog singleton remains for fail-fast startup only; do not inject **`AbilityDefinitionCatalog`** in new game code. Hotbar loadout and ability cast routes validate ability ids via **`IAbilityDefinitionRegistry.TryNormalizeKnown`** (backed by the loaded catalog, not a separate static allowlist). +## NPC behavior catalog (`content/npc-behaviors`, NEO-88) + +On startup the host loads every **`*_npc_behaviors.json`** under the npc-behaviors directory, validates each row against **`content/schemas/npc-behavior-def.schema.json`**, requires **`schemaVersion` 1** per file, rejects **duplicate `id`** values across files, enforces the **prototype E5M2** three-id roster gate, and requires **`leashRadius > aggroRadius`** per row (same rules as **`scripts/validate_content.py`**). If anything is missing or invalid, the process **exits during startup** with an actionable error—there is no silent fallback. + +| Config | Meaning | +|--------|---------| +| **`Content:NpcBehaviorsDirectory`** | Optional. Absolute path, or path relative to the server **content root**. When unset, walks **ancestors of `AppContext.BaseDirectory`** until it finds **`content/npc-behaviors`**. | +| **`Content:NpcBehaviorDefSchemaPath`** | Optional override for **`npc-behavior-def.schema.json`**. When unset, **`{parent of npc-behaviors directory}/schemas/npc-behavior-def.schema.json`**. | + +**Docker / CI:** include **`content/npc-behaviors`** and **`content/schemas/npc-behavior-def.schema.json`** in the mounted **`content/`** tree; set **`Content__NpcBehaviorsDirectory`** when layout differs. + +On success, **Information** logs include the resolved npc-behaviors directory path, distinct behavior count, and catalog file count. Game code should use **`INpcBehaviorDefinitionRegistry`** for lookups (NEO-89). The catalog singleton remains for fail-fast startup only; do not inject **`NpcBehaviorDefinitionCatalog`** in new game code. + ## Ability definitions (NEO-78) **`GET /game/world/ability-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`abilities`**) backed by **`IAbilityDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`baseDamage`**, **`cooldownSeconds`**, and **`abilityKind`** when present in content. Plan: [NEO-78 implementation plan](../../docs/plans/NEO-78-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/ability-definitions/`.