diff --git a/server/NeonSprawl.Server.Tests/Game/Npc/NpcBehaviorDefinitionRegistryTests.cs b/server/NeonSprawl.Server.Tests/Game/Npc/NpcBehaviorDefinitionRegistryTests.cs new file mode 100644 index 0000000..0f28a8e --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Npc/NpcBehaviorDefinitionRegistryTests.cs @@ -0,0 +1,302 @@ +using System.IO; +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 NpcBehaviorDefinitionRegistryTests +{ + private static NpcBehaviorDefinitionRegistry CreateRegistryFromRows(IReadOnlyDictionary byId) + { + var catalog = new NpcBehaviorDefinitionCatalog("/tmp/npc-behaviors", byId, catalogJsonFileCount: 1); + return new NpcBehaviorDefinitionRegistry(catalog); + } + + private static NpcBehaviorDefRow MeleeRow() => + new( + PrototypeNpcBehaviorRegistry.PrototypeMeleePressure, + "Melee Pressure", + "melee_pressure", + 100, + 8.0, + 16.0, + 1.5, + 15, + 3.0); + + private static NpcBehaviorDefRow RangedRow() => + new( + PrototypeNpcBehaviorRegistry.PrototypeRangedControl, + "Ranged Control", + "ranged_control", + 80, + 10.0, + 20.0, + 2.0, + 12, + 4.0); + + private static NpcBehaviorDefRow EliteRow() => + new( + PrototypeNpcBehaviorRegistry.PrototypeEliteMiniBoss, + "Elite Mini-Boss", + "elite_mini_boss", + 200, + 8.0, + 18.0, + 2.5, + 25, + 5.0); + + [Fact] + public void TryGetDefinition_ShouldReturnTrueAndExpectedMetadata_WhenMeleeExists() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + [PrototypeNpcBehaviorRegistry.PrototypeMeleePressure] = MeleeRow(), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryGetDefinition(PrototypeNpcBehaviorRegistry.PrototypeMeleePressure, out var def); + // Assert + Assert.True(found); + Assert.NotNull(def); + Assert.Equal(100, def.MaxHp); + Assert.Equal(8.0, def.AggroRadius); + Assert.Equal(16.0, def.LeashRadius); + Assert.Equal(15, def.AttackDamage); + Assert.Equal(1.5, def.TelegraphWindupSeconds); + Assert.Equal("melee_pressure", def.ArchetypeKind); + Assert.Equal("Melee Pressure", def.DisplayName); + } + + [Fact] + public void TryGetDefinition_ShouldReturnTrueAndExpectedMetadata_WhenEliteExists() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + [PrototypeNpcBehaviorRegistry.PrototypeEliteMiniBoss] = EliteRow(), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryGetDefinition(PrototypeNpcBehaviorRegistry.PrototypeEliteMiniBoss, out var def); + // Assert + Assert.True(found); + Assert.NotNull(def); + Assert.Equal(200, def.MaxHp); + Assert.Equal(25, def.AttackDamage); + Assert.Equal(2.5, def.TelegraphWindupSeconds); + } + + [Fact] + public void TryGetDefinition_ShouldReturnFalse_WhenBehaviorIdIsNull() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + [PrototypeNpcBehaviorRegistry.PrototypeMeleePressure] = MeleeRow(), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryGetDefinition(null, out var def); + // Assert + Assert.False(found); + Assert.Null(def); + } + + [Fact] + public void TryGetDefinition_ShouldReturnFalse_WhenIdUnknown() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + [PrototypeNpcBehaviorRegistry.PrototypeMeleePressure] = MeleeRow(), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryGetDefinition("not_a_real_behavior", out var def); + // Assert + Assert.False(found); + Assert.Null(def); + } + + [Fact] + public void TryNormalizeKnown_ShouldReturnTrueAndLowercaseId_WhenInputHasWhitespaceAndMixedCase() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + [PrototypeNpcBehaviorRegistry.PrototypeMeleePressure] = MeleeRow(), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryNormalizeKnown(" Prototype_Melee_Pressure ", out var normalized); + // Assert + Assert.True(found); + Assert.Equal(PrototypeNpcBehaviorRegistry.PrototypeMeleePressure, normalized); + } + + [Fact] + public void TryNormalizeKnown_ShouldReturnFalse_WhenInputIsNull() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + [PrototypeNpcBehaviorRegistry.PrototypeMeleePressure] = MeleeRow(), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryNormalizeKnown(null, out var normalized); + // Assert + Assert.False(found); + Assert.Equal(string.Empty, normalized); + } + + [Fact] + public void TryNormalizeKnown_ShouldReturnFalse_WhenInputIsWhitespaceOnly() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + [PrototypeNpcBehaviorRegistry.PrototypeMeleePressure] = MeleeRow(), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryNormalizeKnown(" ", out var normalized); + // Assert + Assert.False(found); + Assert.Equal(string.Empty, normalized); + } + + [Fact] + public void TryNormalizeKnown_ShouldReturnFalse_WhenIdNotInCatalog() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + [PrototypeNpcBehaviorRegistry.PrototypeMeleePressure] = MeleeRow(), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryNormalizeKnown("prototype_unknown", out var normalized); + // Assert + Assert.False(found); + Assert.Equal("prototype_unknown", normalized); + } + + [Fact] + public void GetDefinitionsInIdOrder_ShouldListAllRowsOrderedById_WhenMultipleBehaviors() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + [PrototypeNpcBehaviorRegistry.PrototypeRangedControl] = RangedRow(), + [PrototypeNpcBehaviorRegistry.PrototypeEliteMiniBoss] = EliteRow(), + [PrototypeNpcBehaviorRegistry.PrototypeMeleePressure] = MeleeRow(), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var list = registry.GetDefinitionsInIdOrder(); + // Assert + Assert.Equal(3, list.Count); + Assert.Equal(PrototypeNpcBehaviorRegistry.PrototypeEliteMiniBoss, list[0].Id); + Assert.Equal(PrototypeNpcBehaviorRegistry.PrototypeMeleePressure, list[1].Id); + Assert.Equal(PrototypeNpcBehaviorRegistry.PrototypeRangedControl, list[2].Id); + } + + [Fact] + public void TryGetDefinition_ShouldMatchLoaderCatalog_WhenUsingPrototypeFixture() + { + // Arrange + var root = Directory.CreateTempSubdirectory("neon-sprawl-npc-registry-loader-"); + try + { + 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); + File.Copy( + Path.Combine(NpcBehaviorCatalogTestPaths.DiscoverRepoNpcBehaviorsDirectory(), "prototype_npc_behaviors.json"), + Path.Combine(npcBehaviorsDir, "prototype_npc_behaviors.json"), + overwrite: true); + var loaded = NpcBehaviorDefinitionCatalogLoader.Load(npcBehaviorsDir, schemaPath, NullLogger.Instance); + var registry = new NpcBehaviorDefinitionRegistry(loaded); + // Act + var meleeOk = registry.TryGetDefinition(PrototypeNpcBehaviorRegistry.PrototypeMeleePressure, out var melee); + var rangedOk = registry.TryGetDefinition(PrototypeNpcBehaviorRegistry.PrototypeRangedControl, out var ranged); + var eliteOk = registry.TryGetDefinition(PrototypeNpcBehaviorRegistry.PrototypeEliteMiniBoss, out var elite); + var list = registry.GetDefinitionsInIdOrder(); + // Assert + Assert.True(meleeOk); + Assert.NotNull(melee); + Assert.Equal(100, melee!.MaxHp); + Assert.Equal(8.0, melee.AggroRadius); + Assert.Equal(16.0, melee.LeashRadius); + Assert.Equal(15, melee.AttackDamage); + Assert.Equal(1.5, melee.TelegraphWindupSeconds); + Assert.True(rangedOk); + Assert.NotNull(ranged); + Assert.Equal(80, ranged!.MaxHp); + Assert.Equal(10.0, ranged.AggroRadius); + Assert.Equal(20.0, ranged.LeashRadius); + Assert.Equal(12, ranged.AttackDamage); + Assert.Equal(2.0, ranged.TelegraphWindupSeconds); + Assert.True(eliteOk); + Assert.NotNull(elite); + Assert.Equal(200, elite!.MaxHp); + Assert.Equal(8.0, elite.AggroRadius); + Assert.Equal(18.0, elite.LeashRadius); + Assert.Equal(25, elite.AttackDamage); + Assert.Equal(2.5, elite.TelegraphWindupSeconds); + Assert.Equal(3, list.Count); + Assert.Equal(PrototypeNpcBehaviorRegistry.PrototypeEliteMiniBoss, list[0].Id); + Assert.Equal(PrototypeNpcBehaviorRegistry.PrototypeRangedControl, list[^1].Id); + } + finally + { + try + { + Directory.Delete(root.FullName, recursive: true); + } + catch (IOException) + { + // Best-effort: transient lock or race on some hosts; temp dir is unique per run. + } + } + } + + [Fact] + public async Task Host_ShouldResolveRegistryFromDi_WhenStartupSucceeds() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + using var client = factory.CreateClient(); + _ = await client.GetAsync("/health"); + // Act + var registry = factory.Services.GetRequiredService(); + var meleeFound = registry.TryGetDefinition(PrototypeNpcBehaviorRegistry.PrototypeMeleePressure, out var melee); + var unknown = registry.TryGetDefinition("not_a_real_behavior", out var missing); + var normalized = registry.TryNormalizeKnown(" Prototype_Ranged_Control ", out var rangedId); + var list = registry.GetDefinitionsInIdOrder(); + // Assert + Assert.True(meleeFound); + Assert.NotNull(melee); + Assert.Equal(100, melee!.MaxHp); + Assert.Equal(15, melee.AttackDamage); + Assert.False(unknown); + Assert.Null(missing); + Assert.True(normalized); + Assert.Equal(PrototypeNpcBehaviorRegistry.PrototypeRangedControl, rangedId); + Assert.Equal(3, list.Count); + Assert.Contains( + list, + b => b.Id == PrototypeNpcBehaviorRegistry.PrototypeEliteMiniBoss && b.MaxHp == 200 && b.AttackDamage == 25); + } +} diff --git a/server/NeonSprawl.Server/Game/Npc/INpcBehaviorDefinitionRegistry.cs b/server/NeonSprawl.Server/Game/Npc/INpcBehaviorDefinitionRegistry.cs new file mode 100644 index 0000000..b72582b --- /dev/null +++ b/server/NeonSprawl.Server/Game/Npc/INpcBehaviorDefinitionRegistry.cs @@ -0,0 +1,23 @@ +using System.Diagnostics.CodeAnalysis; + +namespace NeonSprawl.Server.Game.Npc; + +/// +/// Read-only access to validated entries loaded at startup (). +/// +/// +/// E5.M2 (NPC runtime / instance binding): callers validating behavior ids and resolving aggro/telegraph metadata should depend on this interface +/// rather than so behavior defs stay centralized. +/// NEO-90: HTTP/read-model projections should depend on this interface rather than reaching into the catalog. +/// +public interface INpcBehaviorDefinitionRegistry +{ + /// Attempts to resolve a behavior by stable id (see npc-behavior-def.schema.json). Unknown ids and null return false without throwing. + bool TryGetDefinition(string? behaviorId, [NotNullWhen(true)] out NpcBehaviorDefRow? definition); + + /// Trims and lowercases ; returns true when the normalized id exists in the loaded catalog. null, empty, and whitespace return false without throwing. + bool TryNormalizeKnown(string? rawBehaviorId, [NotNullWhen(true)] out string normalized); + + /// Every loaded definition, ordered by (ordinal). + IReadOnlyList GetDefinitionsInIdOrder(); +} diff --git a/server/NeonSprawl.Server/Game/Npc/NpcBehaviorCatalogServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorCatalogServiceCollectionExtensions.cs index 9f16625..9263535 100644 --- a/server/NeonSprawl.Server/Game/Npc/NpcBehaviorCatalogServiceCollectionExtensions.cs +++ b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorCatalogServiceCollectionExtensions.cs @@ -8,7 +8,7 @@ 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. + /// Binds and registers and as singletons. public static IServiceCollection AddNpcBehaviorDefinitionCatalog(this IServiceCollection services, IConfiguration configuration) { services.AddOptions() @@ -32,6 +32,9 @@ public static class NpcBehaviorCatalogServiceCollectionExtensions return NpcBehaviorDefinitionCatalogLoader.Load(npcBehaviorsDir, schemaPath, logger); }); + services.AddSingleton(sp => + new NpcBehaviorDefinitionRegistry(sp.GetRequiredService())); + return services; } } diff --git a/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionCatalog.cs b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionCatalog.cs index 14d2264..6122d97 100644 --- a/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionCatalog.cs +++ b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionCatalog.cs @@ -2,7 +2,7 @@ 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). +/// In-memory NPC behavior catalog loaded at startup (NEO-88). Game callers should use . public sealed class NpcBehaviorDefinitionCatalog( string npcBehaviorsDirectory, IReadOnlyDictionary byId, diff --git a/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionRegistry.cs b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionRegistry.cs new file mode 100644 index 0000000..2ae0b96 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionRegistry.cs @@ -0,0 +1,55 @@ +using System.Diagnostics.CodeAnalysis; + +namespace NeonSprawl.Server.Game.Npc; + +/// Adapter over (NEO-89). +public sealed class NpcBehaviorDefinitionRegistry(NpcBehaviorDefinitionCatalog catalog) : INpcBehaviorDefinitionRegistry +{ + private readonly IReadOnlyList _definitionsInIdOrder = BuildDefinitionsInIdOrder(catalog); + + /// + public bool TryGetDefinition(string? behaviorId, [NotNullWhen(true)] out NpcBehaviorDefRow? definition) + { + if (behaviorId is null) + { + definition = null; + return false; + } + + return catalog.TryGetBehavior(behaviorId, out definition); + } + + /// + public bool TryNormalizeKnown(string? rawBehaviorId, [NotNullWhen(true)] out string normalized) + { + if (rawBehaviorId is null) + { + normalized = string.Empty; + return false; + } + + normalized = rawBehaviorId.Trim().ToLowerInvariant(); + if (normalized.Length == 0) + { + normalized = string.Empty; + return false; + } + + return catalog.TryGetBehavior(normalized, out _); + } + + /// + public IReadOnlyList GetDefinitionsInIdOrder() => _definitionsInIdOrder; + + private static IReadOnlyList BuildDefinitionsInIdOrder(NpcBehaviorDefinitionCatalog catalog) + { + var ids = catalog.ById.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray(); + var list = new List(ids.Length); + foreach (var id in ids) + { + list.Add(catalog.ById[id]); + } + + return list; + } +} diff --git a/server/NeonSprawl.Server/Game/Npc/PrototypeNpcBehaviorRegistry.cs b/server/NeonSprawl.Server/Game/Npc/PrototypeNpcBehaviorRegistry.cs new file mode 100644 index 0000000..62d3b9e --- /dev/null +++ b/server/NeonSprawl.Server/Game/Npc/PrototypeNpcBehaviorRegistry.cs @@ -0,0 +1,9 @@ +namespace NeonSprawl.Server.Game.Npc; + +/// Stable prototype NPC behavior id constants for tests and fixtures (NEO-89). Validation and lookup use . +public static class PrototypeNpcBehaviorRegistry +{ + public const string PrototypeMeleePressure = "prototype_melee_pressure"; + public const string PrototypeRangedControl = "prototype_ranged_control"; + public const string PrototypeEliteMiniBoss = "prototype_elite_mini_boss"; +}