NEO-89: Add INpcBehaviorDefinitionRegistry and DI registration.
parent
7e4e0ca843
commit
70dc293246
|
|
@ -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<string, NpcBehaviorDefRow> 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<string, NpcBehaviorDefRow>(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<string, NpcBehaviorDefRow>(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<string, NpcBehaviorDefRow>(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<string, NpcBehaviorDefRow>(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<string, NpcBehaviorDefRow>(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<string, NpcBehaviorDefRow>(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<string, NpcBehaviorDefRow>(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<string, NpcBehaviorDefRow>(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<string, NpcBehaviorDefRow>(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<INpcBehaviorDefinitionRegistry>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Npc;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only access to validated <see cref="NpcBehaviorDefRow"/> entries loaded at startup (<see cref="NpcBehaviorDefinitionCatalog"/>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>E5.M2 (NPC runtime / instance binding):</b> callers validating behavior ids and resolving aggro/telegraph metadata should depend on this interface
|
||||
/// rather than <see cref="NpcBehaviorDefinitionCatalog"/> so behavior defs stay centralized.</para>
|
||||
/// <para><b>NEO-90:</b> HTTP/read-model projections should depend on this interface rather than reaching into the catalog.</para>
|
||||
/// </remarks>
|
||||
public interface INpcBehaviorDefinitionRegistry
|
||||
{
|
||||
/// <summary>Attempts to resolve a behavior by stable <c>id</c> (see <c>npc-behavior-def.schema.json</c>). Unknown ids and <c>null</c> return <c>false</c> without throwing.</summary>
|
||||
bool TryGetDefinition(string? behaviorId, [NotNullWhen(true)] out NpcBehaviorDefRow? definition);
|
||||
|
||||
/// <summary>Trims and lowercases <paramref name="rawBehaviorId"/>; returns <c>true</c> when the normalized id exists in the loaded catalog. <c>null</c>, empty, and whitespace return <c>false</c> without throwing.</summary>
|
||||
bool TryNormalizeKnown(string? rawBehaviorId, [NotNullWhen(true)] out string normalized);
|
||||
|
||||
/// <summary>Every loaded definition, ordered by <see cref="NpcBehaviorDefRow.Id"/> (ordinal).</summary>
|
||||
IReadOnlyList<NpcBehaviorDefRow> GetDefinitionsInIdOrder();
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ namespace NeonSprawl.Server.Game.Npc;
|
|||
/// <summary>DI registration for the fail-fast NPC behavior catalog (NEO-88).</summary>
|
||||
public static class NpcBehaviorCatalogServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>Binds <see cref="ContentPathsOptions"/> and registers <see cref="NpcBehaviorDefinitionCatalog"/> as a singleton.</summary>
|
||||
/// <summary>Binds <see cref="ContentPathsOptions"/> and registers <see cref="NpcBehaviorDefinitionCatalog"/> and <see cref="INpcBehaviorDefinitionRegistry"/> as singletons.</summary>
|
||||
public static IServiceCollection AddNpcBehaviorDefinitionCatalog(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddOptions<ContentPathsOptions>()
|
||||
|
|
@ -32,6 +32,9 @@ public static class NpcBehaviorCatalogServiceCollectionExtensions
|
|||
return NpcBehaviorDefinitionCatalogLoader.Load(npcBehaviorsDir, schemaPath, logger);
|
||||
});
|
||||
|
||||
services.AddSingleton<INpcBehaviorDefinitionRegistry>(sp =>
|
||||
new NpcBehaviorDefinitionRegistry(sp.GetRequiredService<NpcBehaviorDefinitionCatalog>()));
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ using System.Collections.ObjectModel;
|
|||
|
||||
namespace NeonSprawl.Server.Game.Npc;
|
||||
|
||||
/// <summary>In-memory NPC behavior catalog loaded at startup (NEO-88). Game callers should use INpcBehaviorDefinitionRegistry (NEO-89).</summary>
|
||||
/// <summary>In-memory NPC behavior catalog loaded at startup (NEO-88). Game callers should use <see cref="INpcBehaviorDefinitionRegistry"/>.</summary>
|
||||
public sealed class NpcBehaviorDefinitionCatalog(
|
||||
string npcBehaviorsDirectory,
|
||||
IReadOnlyDictionary<string, NpcBehaviorDefRow> byId,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Npc;
|
||||
|
||||
/// <summary>Adapter over <see cref="NpcBehaviorDefinitionCatalog"/> (NEO-89).</summary>
|
||||
public sealed class NpcBehaviorDefinitionRegistry(NpcBehaviorDefinitionCatalog catalog) : INpcBehaviorDefinitionRegistry
|
||||
{
|
||||
private readonly IReadOnlyList<NpcBehaviorDefRow> _definitionsInIdOrder = BuildDefinitionsInIdOrder(catalog);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryGetDefinition(string? behaviorId, [NotNullWhen(true)] out NpcBehaviorDefRow? definition)
|
||||
{
|
||||
if (behaviorId is null)
|
||||
{
|
||||
definition = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
return catalog.TryGetBehavior(behaviorId, out definition);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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 _);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<NpcBehaviorDefRow> GetDefinitionsInIdOrder() => _definitionsInIdOrder;
|
||||
|
||||
private static IReadOnlyList<NpcBehaviorDefRow> BuildDefinitionsInIdOrder(NpcBehaviorDefinitionCatalog catalog)
|
||||
{
|
||||
var ids = catalog.ById.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray();
|
||||
var list = new List<NpcBehaviorDefRow>(ids.Length);
|
||||
foreach (var id in ids)
|
||||
{
|
||||
list.Add(catalog.ById[id]);
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
namespace NeonSprawl.Server.Game.Npc;
|
||||
|
||||
/// <summary>Stable prototype NPC behavior id constants for tests and fixtures (NEO-89). Validation and lookup use <see cref="INpcBehaviorDefinitionRegistry"/>.</summary>
|
||||
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";
|
||||
}
|
||||
Loading…
Reference in New Issue