NEO-79: Add IAbilityDefinitionRegistry and migrate hotbar/cast validation.

Injectable registry backed by AbilityDefinitionCatalog; PrototypeAbilityRegistry
keeps id constants only. Bruno smoke for unknown_ability deny paths.
pull/112/head
VinPropane 2026-05-24 22:05:27 -04:00
parent 8bafec9d79
commit bd4912004a
11 changed files with 458 additions and 26 deletions

View File

@ -0,0 +1,36 @@
meta {
name: POST cast unknown ability deny
type: http
seq: 9
}
docs {
NEO-79: unknown ability ids deny with reasonCode unknown_ability (catalog-backed IAbilityDefinitionRegistry).
}
post {
url: {{baseUrl}}/game/players/dev-local-1/ability-cast
body: json
auth: none
}
body:json {
{
"schemaVersion": 1,
"slotIndex": 0,
"abilityId": "not_a_real_ability",
"targetId": null
}
}
tests {
test("status 200", function () {
expect(res.getStatus()).to.equal(200);
});
test("accepted false with unknown_ability", function () {
const body = res.getBody();
expect(body.accepted).to.equal(false);
expect(body.reasonCode).to.equal("unknown_ability");
});
}

View File

@ -0,0 +1,39 @@
meta {
name: POST hotbar loadout unknown ability deny
type: http
seq: 5
}
docs {
NEO-79: unknown ability ids deny with reasonCode unknown_ability (catalog-backed IAbilityDefinitionRegistry).
}
post {
url: {{baseUrl}}/game/players/dev-local-1/hotbar-loadout
body: json
auth: none
}
body:json {
{
"schemaVersion": 1,
"slots": [
{
"slotIndex": 2,
"abilityId": "missing_ability"
}
]
}
}
tests {
test("status 200", function () {
expect(res.getStatus()).to.equal(200);
});
test("updated false with unknown_ability", function () {
const body = res.getBody();
expect(body.updated).to.equal(false);
expect(body.reasonCode).to.equal("unknown_ability");
});
}

View File

@ -0,0 +1,298 @@
using System.IO;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Combat;
public class AbilityDefinitionRegistryTests
{
private static AbilityDefinitionRegistry CreateRegistryFromRows(IReadOnlyDictionary<string, AbilityDefRow> byId)
{
var catalog = new AbilityDefinitionCatalog("/tmp/abilities", byId, catalogJsonFileCount: 1);
return new AbilityDefinitionRegistry(catalog);
}
[Fact]
public void TryGetDefinition_ShouldReturnTrueAndExpectedMetadata_WhenPulseExists()
{
// Arrange
var rows = new Dictionary<string, AbilityDefRow>(StringComparer.Ordinal)
{
[PrototypeAbilityRegistry.PrototypePulse] = new AbilityDefRow(
PrototypeAbilityRegistry.PrototypePulse,
"Prototype Pulse",
25,
3.0,
"attack"),
};
var registry = CreateRegistryFromRows(rows);
// Act
var found = registry.TryGetDefinition(PrototypeAbilityRegistry.PrototypePulse, out var def);
// Assert
Assert.True(found);
Assert.NotNull(def);
Assert.Equal(25, def.BaseDamage);
Assert.Equal(3.0, def.CooldownSeconds);
Assert.Equal("attack", def.AbilityKind);
Assert.Equal("Prototype Pulse", def.DisplayName);
}
[Fact]
public void TryGetDefinition_ShouldReturnTrueAndExpectedMetadata_WhenBurstExists()
{
// Arrange
var rows = new Dictionary<string, AbilityDefRow>(StringComparer.Ordinal)
{
[PrototypeAbilityRegistry.PrototypeBurst] = new AbilityDefRow(
PrototypeAbilityRegistry.PrototypeBurst,
"Prototype Burst",
40,
5.0,
"attack"),
};
var registry = CreateRegistryFromRows(rows);
// Act
var found = registry.TryGetDefinition(PrototypeAbilityRegistry.PrototypeBurst, out var def);
// Assert
Assert.True(found);
Assert.NotNull(def);
Assert.Equal(40, def.BaseDamage);
Assert.Equal(5.0, def.CooldownSeconds);
}
[Fact]
public void TryGetDefinition_ShouldReturnFalse_WhenAbilityIdIsNull()
{
// Arrange
var rows = new Dictionary<string, AbilityDefRow>(StringComparer.Ordinal)
{
[PrototypeAbilityRegistry.PrototypePulse] = new AbilityDefRow(
PrototypeAbilityRegistry.PrototypePulse,
"Prototype Pulse",
25,
3.0,
"attack"),
};
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, AbilityDefRow>(StringComparer.Ordinal)
{
[PrototypeAbilityRegistry.PrototypePulse] = new AbilityDefRow(
PrototypeAbilityRegistry.PrototypePulse,
"Prototype Pulse",
25,
3.0,
"attack"),
};
var registry = CreateRegistryFromRows(rows);
// Act
var found = registry.TryGetDefinition("not_a_real_ability", out var def);
// Assert
Assert.False(found);
Assert.Null(def);
}
[Fact]
public void TryNormalizeKnown_ShouldReturnTrueAndLowercaseId_WhenInputHasWhitespaceAndMixedCase()
{
// Arrange
var rows = new Dictionary<string, AbilityDefRow>(StringComparer.Ordinal)
{
[PrototypeAbilityRegistry.PrototypePulse] = new AbilityDefRow(
PrototypeAbilityRegistry.PrototypePulse,
"Prototype Pulse",
25,
3.0,
"attack"),
};
var registry = CreateRegistryFromRows(rows);
// Act
var found = registry.TryNormalizeKnown(" Prototype_Pulse ", out var normalized);
// Assert
Assert.True(found);
Assert.Equal(PrototypeAbilityRegistry.PrototypePulse, normalized);
}
[Fact]
public void TryNormalizeKnown_ShouldReturnFalse_WhenInputIsWhitespaceOnly()
{
// Arrange
var rows = new Dictionary<string, AbilityDefRow>(StringComparer.Ordinal)
{
[PrototypeAbilityRegistry.PrototypePulse] = new AbilityDefRow(
PrototypeAbilityRegistry.PrototypePulse,
"Prototype Pulse",
25,
3.0,
"attack"),
};
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, AbilityDefRow>(StringComparer.Ordinal)
{
[PrototypeAbilityRegistry.PrototypePulse] = new AbilityDefRow(
PrototypeAbilityRegistry.PrototypePulse,
"Prototype Pulse",
25,
3.0,
"attack"),
};
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_WhenMultipleAbilities()
{
// Arrange
var rows = new Dictionary<string, AbilityDefRow>(StringComparer.Ordinal)
{
[PrototypeAbilityRegistry.PrototypeBurst] = new AbilityDefRow(
PrototypeAbilityRegistry.PrototypeBurst,
"Prototype Burst",
40,
5.0,
"attack"),
[PrototypeAbilityRegistry.PrototypeDash] = new AbilityDefRow(
PrototypeAbilityRegistry.PrototypeDash,
"Prototype Dash",
0,
4.0,
"movement"),
[PrototypeAbilityRegistry.PrototypeGuard] = new AbilityDefRow(
PrototypeAbilityRegistry.PrototypeGuard,
"Prototype Guard",
0,
6.0,
"utility"),
[PrototypeAbilityRegistry.PrototypePulse] = new AbilityDefRow(
PrototypeAbilityRegistry.PrototypePulse,
"Prototype Pulse",
25,
3.0,
"attack"),
};
var registry = CreateRegistryFromRows(rows);
// Act
var list = registry.GetDefinitionsInIdOrder();
// Assert
Assert.Equal(4, list.Count);
Assert.Equal(PrototypeAbilityRegistry.PrototypeBurst, list[0].Id);
Assert.Equal(PrototypeAbilityRegistry.PrototypeDash, list[1].Id);
Assert.Equal(PrototypeAbilityRegistry.PrototypeGuard, list[2].Id);
Assert.Equal(PrototypeAbilityRegistry.PrototypePulse, list[3].Id);
}
[Fact]
public void TryGetDefinition_ShouldMatchLoaderCatalog_WhenUsingPrototypeFixture()
{
// Arrange
var root = Directory.CreateTempSubdirectory("neon-sprawl-ability-registry-loader-");
try
{
var abilitiesDir = Path.Combine(root.FullName, "content", "abilities");
var schemaDir = Path.Combine(root.FullName, "content", "schemas");
Directory.CreateDirectory(abilitiesDir);
Directory.CreateDirectory(schemaDir);
var schemaPath = Path.Combine(schemaDir, "ability-def.schema.json");
File.Copy(AbilityCatalogTestPaths.DiscoverRepoAbilityDefSchemaPath(), schemaPath, overwrite: true);
File.Copy(
Path.Combine(AbilityCatalogTestPaths.DiscoverRepoAbilitiesDirectory(), "prototype_abilities.json"),
Path.Combine(abilitiesDir, "prototype_abilities.json"),
overwrite: true);
var loaded = AbilityDefinitionCatalogLoader.Load(abilitiesDir, schemaPath, NullLogger.Instance);
var registry = new AbilityDefinitionRegistry(loaded);
// Act
var pulseOk = registry.TryGetDefinition(PrototypeAbilityRegistry.PrototypePulse, out var pulse);
var guardOk = registry.TryGetDefinition(PrototypeAbilityRegistry.PrototypeGuard, out var guard);
var dashOk = registry.TryGetDefinition(PrototypeAbilityRegistry.PrototypeDash, out var dash);
var burstOk = registry.TryGetDefinition(PrototypeAbilityRegistry.PrototypeBurst, out var burst);
var list = registry.GetDefinitionsInIdOrder();
// Assert
Assert.True(pulseOk);
Assert.NotNull(pulse);
Assert.Equal(25, pulse!.BaseDamage);
Assert.Equal(3.0, pulse.CooldownSeconds);
Assert.True(guardOk);
Assert.NotNull(guard);
Assert.Equal(0, guard!.BaseDamage);
Assert.Equal(6.0, guard.CooldownSeconds);
Assert.True(dashOk);
Assert.NotNull(dash);
Assert.Equal(0, dash!.BaseDamage);
Assert.Equal(4.0, dash.CooldownSeconds);
Assert.True(burstOk);
Assert.NotNull(burst);
Assert.Equal(40, burst!.BaseDamage);
Assert.Equal(5.0, burst.CooldownSeconds);
Assert.Equal(4, list.Count);
Assert.Equal(PrototypeAbilityRegistry.PrototypeBurst, list[0].Id);
Assert.Equal(PrototypeAbilityRegistry.PrototypePulse, 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<IAbilityDefinitionRegistry>();
var pulseFound = registry.TryGetDefinition(PrototypeAbilityRegistry.PrototypePulse, out var pulse);
var unknown = registry.TryGetDefinition("not_a_real_ability", out var missing);
var normalized = registry.TryNormalizeKnown(" Prototype_Burst ", out var burstId);
var list = registry.GetDefinitionsInIdOrder();
// Assert
Assert.True(pulseFound);
Assert.NotNull(pulse);
Assert.Equal(25, pulse!.BaseDamage);
Assert.Equal(3.0, pulse.CooldownSeconds);
Assert.False(unknown);
Assert.Null(missing);
Assert.True(normalized);
Assert.Equal(PrototypeAbilityRegistry.PrototypeBurst, burstId);
Assert.Equal(4, list.Count);
Assert.Contains(list, a => a.Id == PrototypeAbilityRegistry.PrototypeGuard && a.BaseDamage == 0);
}
}

View File

@ -1,4 +1,5 @@
using NeonSprawl.Server.Diagnostics;
using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Targeting;
using NeonSprawl.Server.Game.World;
@ -57,6 +58,7 @@ public static class AbilityCastApi
IPlayerHotbarLoadoutStore store,
IPlayerTargetLockStore locks,
IPlayerAbilityCooldownStore cooldowns,
IAbilityDefinitionRegistry abilities,
TimeProvider clock) =>
{
// NEO-30: not a Slice 3 `ability_cast_denied` site — no AbilityCastResponse body.
@ -105,7 +107,7 @@ public static class AbilityCastApi
}
if (string.IsNullOrWhiteSpace(body.AbilityId) ||
!PrototypeAbilityRegistry.TryNormalizeKnown(body.AbilityId, out var normalizedRequest))
!abilities.TryNormalizeKnown(body.AbilityId, out var normalizedRequest))
{
// NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit).
return JsonAbilityCast(

View File

@ -1,3 +1,4 @@
using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.PositionState;
namespace NeonSprawl.Server.Game.AbilityInput;
@ -30,7 +31,7 @@ public static class HotbarLoadoutApi
app.MapPost(
"/game/players/{id}/hotbar-loadout",
(string id, HotbarLoadoutUpdateRequest? body, IPositionStateStore positions, IPlayerHotbarLoadoutStore store) =>
(string id, HotbarLoadoutUpdateRequest? body, IPositionStateStore positions, IPlayerHotbarLoadoutStore store, IAbilityDefinitionRegistry abilities) =>
{
if (body is null || body.SchemaVersion != HotbarLoadoutUpdateRequest.CurrentSchemaVersion)
{
@ -75,7 +76,7 @@ public static class HotbarLoadoutApi
}
if (string.IsNullOrWhiteSpace(update.AbilityId) ||
!PrototypeAbilityRegistry.TryNormalizeKnown(update.AbilityId, out var normalizedAbilityId))
!abilities.TryNormalizeKnown(update.AbilityId, out var normalizedAbilityId))
{
return Results.Json(
new HotbarLoadoutUpdateResponse

View File

@ -1,29 +1,10 @@
namespace NeonSprawl.Server.Game.AbilityInput;
/// <summary>Prototype ability allowlist for NEO-29 hotbar bindings.</summary>
/// <summary>Stable prototype ability id constants for tests and fixtures (NEO-29). Allowlist validation uses <see cref="Combat.IAbilityDefinitionRegistry"/>.</summary>
public static class PrototypeAbilityRegistry
{
public const string PrototypePulse = "prototype_pulse";
public const string PrototypeGuard = "prototype_guard";
public const string PrototypeDash = "prototype_dash";
public const string PrototypeBurst = "prototype_burst";
private static readonly HashSet<string> Allowed = new(StringComparer.Ordinal)
{
PrototypePulse,
PrototypeGuard,
PrototypeDash,
PrototypeBurst,
};
public static bool TryNormalizeKnown(string rawAbilityId, out string normalized)
{
normalized = rawAbilityId.Trim().ToLowerInvariant();
if (normalized.Length == 0)
{
return false;
}
return Allowed.Contains(normalized);
}
}

View File

@ -8,7 +8,7 @@ namespace NeonSprawl.Server.Game.Combat;
/// <summary>DI registration for the fail-fast ability catalog (NEO-77).</summary>
public static class AbilityCatalogServiceCollectionExtensions
{
/// <summary>Binds <see cref="ContentPathsOptions"/> and registers <see cref="AbilityDefinitionCatalog"/> as a singleton.</summary>
/// <summary>Binds <see cref="ContentPathsOptions"/> and registers <see cref="AbilityDefinitionCatalog"/> and <see cref="IAbilityDefinitionRegistry"/> as singletons.</summary>
public static IServiceCollection AddAbilityDefinitionCatalog(this IServiceCollection services, IConfiguration configuration)
{
services.AddOptions<ContentPathsOptions>()
@ -32,6 +32,9 @@ public static class AbilityCatalogServiceCollectionExtensions
return AbilityDefinitionCatalogLoader.Load(abilitiesDir, schemaPath, logger);
});
services.AddSingleton<IAbilityDefinitionRegistry>(sp =>
new AbilityDefinitionRegistry(sp.GetRequiredService<AbilityDefinitionCatalog>()));
return services;
}
}

View File

@ -2,7 +2,7 @@ using System.Collections.ObjectModel;
namespace NeonSprawl.Server.Game.Combat;
/// <summary>In-memory ability catalog loaded at startup (NEO-77). Game callers should use <c>IAbilityDefinitionRegistry</c> (NEO-79).</summary>
/// <summary>In-memory ability catalog loaded at startup (NEO-77). Game callers should use <see cref="IAbilityDefinitionRegistry"/>.</summary>
public sealed class AbilityDefinitionCatalog(
string abilitiesDirectory,
IReadOnlyDictionary<string, AbilityDefRow> byId,

View File

@ -0,0 +1,49 @@
using System.Diagnostics.CodeAnalysis;
namespace NeonSprawl.Server.Game.Combat;
/// <summary>Adapter over <see cref="AbilityDefinitionCatalog"/> (NEO-79).</summary>
public sealed class AbilityDefinitionRegistry(AbilityDefinitionCatalog catalog) : IAbilityDefinitionRegistry
{
private readonly IReadOnlyList<AbilityDefRow> _definitionsInIdOrder = BuildDefinitionsInIdOrder(catalog);
/// <inheritdoc />
public bool TryGetDefinition(string? abilityId, [NotNullWhen(true)] out AbilityDefRow? definition)
{
if (abilityId is null)
{
definition = null;
return false;
}
return catalog.TryGetAbility(abilityId, out definition);
}
/// <inheritdoc />
public bool TryNormalizeKnown(string rawAbilityId, [NotNullWhen(true)] out string normalized)
{
normalized = rawAbilityId.Trim().ToLowerInvariant();
if (normalized.Length == 0)
{
normalized = string.Empty;
return false;
}
return catalog.TryGetAbility(normalized, out _);
}
/// <inheritdoc />
public IReadOnlyList<AbilityDefRow> GetDefinitionsInIdOrder() => _definitionsInIdOrder;
private static IReadOnlyList<AbilityDefRow> BuildDefinitionsInIdOrder(AbilityDefinitionCatalog catalog)
{
var ids = catalog.ById.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray();
var list = new List<AbilityDefRow>(ids.Length);
foreach (var id in ids)
{
list.Add(catalog.ById[id]);
}
return list;
}
}

View File

@ -0,0 +1,23 @@
using System.Diagnostics.CodeAnalysis;
namespace NeonSprawl.Server.Game.Combat;
/// <summary>
/// Read-only access to validated <see cref="AbilityDefRow"/> entries loaded at startup (<see cref="AbilityDefinitionCatalog"/>).
/// </summary>
/// <remarks>
/// <para><b>E5.M1 (combat / hotbar / cast):</b> callers validating ability ids and resolving damage/cooldown metadata should depend on this interface
/// rather than <see cref="AbilityDefinitionCatalog"/> so ability defs stay centralized.</para>
/// <para><b>NEO-78:</b> HTTP/read-model projections should depend on this interface rather than reaching into the catalog.</para>
/// </remarks>
public interface IAbilityDefinitionRegistry
{
/// <summary>Attempts to resolve an ability by stable <c>id</c> (see <c>ability-def.schema.json</c>). Unknown ids and <c>null</c> return <c>false</c> without throwing.</summary>
bool TryGetDefinition(string? abilityId, [NotNullWhen(true)] out AbilityDefRow? definition);
/// <summary>Trims and lowercases <paramref name="rawAbilityId"/>; returns <c>true</c> when the normalized id exists in the loaded catalog.</summary>
bool TryNormalizeKnown(string rawAbilityId, [NotNullWhen(true)] out string normalized);
/// <summary>Every loaded definition, ordered by <see cref="AbilityDefRow.Id"/> (ordinal).</summary>
IReadOnlyList<AbilityDefRow> GetDefinitionsInIdOrder();
}

View File

@ -91,7 +91,7 @@ On startup the host loads every **`*_abilities.json`** under the abilities direc
**Docker / CI:** include **`content/abilities`** and **`content/schemas/ability-def.schema.json`** in the mounted **`content/`** tree; set **`Content__AbilitiesDirectory`** when layout differs.
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; **`PrototypeAbilityRegistry`** hotbar/cast allowlist is unchanged until NEO-79.
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).
## Recipe definitions (NEO-68)