From bd4912004a030299a8e44eb839f26f3d439e16dc Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 24 May 2026 22:05:27 -0400 Subject: [PATCH] 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. --- .../Post cast unknown ability deny.bru | 36 +++ .../Post loadout unknown ability deny.bru | 39 +++ .../Combat/AbilityDefinitionRegistryTests.cs | 298 ++++++++++++++++++ .../Game/AbilityInput/AbilityCastApi.cs | 4 +- .../Game/AbilityInput/HotbarLoadoutApi.cs | 5 +- .../AbilityInput/PrototypeAbilityRegistry.cs | 21 +- ...ilityCatalogServiceCollectionExtensions.cs | 5 +- .../Game/Combat/AbilityDefinitionCatalog.cs | 2 +- .../Game/Combat/AbilityDefinitionRegistry.cs | 49 +++ .../Game/Combat/IAbilityDefinitionRegistry.cs | 23 ++ server/README.md | 2 +- 11 files changed, 458 insertions(+), 26 deletions(-) create mode 100644 bruno/neon-sprawl-server/ability-cast/Post cast unknown ability deny.bru create mode 100644 bruno/neon-sprawl-server/hotbar-loadout/Post loadout unknown ability deny.bru create mode 100644 server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionRegistryTests.cs create mode 100644 server/NeonSprawl.Server/Game/Combat/AbilityDefinitionRegistry.cs create mode 100644 server/NeonSprawl.Server/Game/Combat/IAbilityDefinitionRegistry.cs diff --git a/bruno/neon-sprawl-server/ability-cast/Post cast unknown ability deny.bru b/bruno/neon-sprawl-server/ability-cast/Post cast unknown ability deny.bru new file mode 100644 index 0000000..a411329 --- /dev/null +++ b/bruno/neon-sprawl-server/ability-cast/Post cast unknown ability deny.bru @@ -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"); + }); +} diff --git a/bruno/neon-sprawl-server/hotbar-loadout/Post loadout unknown ability deny.bru b/bruno/neon-sprawl-server/hotbar-loadout/Post loadout unknown ability deny.bru new file mode 100644 index 0000000..76eb857 --- /dev/null +++ b/bruno/neon-sprawl-server/hotbar-loadout/Post loadout unknown ability deny.bru @@ -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"); + }); +} diff --git a/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionRegistryTests.cs b/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionRegistryTests.cs new file mode 100644 index 0000000..cd15482 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionRegistryTests.cs @@ -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 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(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(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(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(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(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(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(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(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(); + 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); + } +} diff --git a/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs index fec55fc..763c996 100644 --- a/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs +++ b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs @@ -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( diff --git a/server/NeonSprawl.Server/Game/AbilityInput/HotbarLoadoutApi.cs b/server/NeonSprawl.Server/Game/AbilityInput/HotbarLoadoutApi.cs index 0e3dec3..ad66490 100644 --- a/server/NeonSprawl.Server/Game/AbilityInput/HotbarLoadoutApi.cs +++ b/server/NeonSprawl.Server/Game/AbilityInput/HotbarLoadoutApi.cs @@ -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 diff --git a/server/NeonSprawl.Server/Game/AbilityInput/PrototypeAbilityRegistry.cs b/server/NeonSprawl.Server/Game/AbilityInput/PrototypeAbilityRegistry.cs index fd1a2ca..dd0771b 100644 --- a/server/NeonSprawl.Server/Game/AbilityInput/PrototypeAbilityRegistry.cs +++ b/server/NeonSprawl.Server/Game/AbilityInput/PrototypeAbilityRegistry.cs @@ -1,29 +1,10 @@ namespace NeonSprawl.Server.Game.AbilityInput; -/// Prototype ability allowlist for NEO-29 hotbar bindings. +/// Stable prototype ability id constants for tests and fixtures (NEO-29). Allowlist validation uses . 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 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); - } } diff --git a/server/NeonSprawl.Server/Game/Combat/AbilityCatalogServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Combat/AbilityCatalogServiceCollectionExtensions.cs index 68e7c9f..9644fc5 100644 --- a/server/NeonSprawl.Server/Game/Combat/AbilityCatalogServiceCollectionExtensions.cs +++ b/server/NeonSprawl.Server/Game/Combat/AbilityCatalogServiceCollectionExtensions.cs @@ -8,7 +8,7 @@ namespace NeonSprawl.Server.Game.Combat; /// DI registration for the fail-fast ability catalog (NEO-77). public static class AbilityCatalogServiceCollectionExtensions { - /// Binds and registers as a singleton. + /// Binds and registers and as singletons. public static IServiceCollection AddAbilityDefinitionCatalog(this IServiceCollection services, IConfiguration configuration) { services.AddOptions() @@ -32,6 +32,9 @@ public static class AbilityCatalogServiceCollectionExtensions return AbilityDefinitionCatalogLoader.Load(abilitiesDir, schemaPath, logger); }); + services.AddSingleton(sp => + new AbilityDefinitionRegistry(sp.GetRequiredService())); + return services; } } diff --git a/server/NeonSprawl.Server/Game/Combat/AbilityDefinitionCatalog.cs b/server/NeonSprawl.Server/Game/Combat/AbilityDefinitionCatalog.cs index 1c5b10f..4c622de 100644 --- a/server/NeonSprawl.Server/Game/Combat/AbilityDefinitionCatalog.cs +++ b/server/NeonSprawl.Server/Game/Combat/AbilityDefinitionCatalog.cs @@ -2,7 +2,7 @@ using System.Collections.ObjectModel; namespace NeonSprawl.Server.Game.Combat; -/// In-memory ability catalog loaded at startup (NEO-77). Game callers should use IAbilityDefinitionRegistry (NEO-79). +/// In-memory ability catalog loaded at startup (NEO-77). Game callers should use . public sealed class AbilityDefinitionCatalog( string abilitiesDirectory, IReadOnlyDictionary byId, diff --git a/server/NeonSprawl.Server/Game/Combat/AbilityDefinitionRegistry.cs b/server/NeonSprawl.Server/Game/Combat/AbilityDefinitionRegistry.cs new file mode 100644 index 0000000..2d1e135 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Combat/AbilityDefinitionRegistry.cs @@ -0,0 +1,49 @@ +using System.Diagnostics.CodeAnalysis; + +namespace NeonSprawl.Server.Game.Combat; + +/// Adapter over (NEO-79). +public sealed class AbilityDefinitionRegistry(AbilityDefinitionCatalog catalog) : IAbilityDefinitionRegistry +{ + private readonly IReadOnlyList _definitionsInIdOrder = BuildDefinitionsInIdOrder(catalog); + + /// + public bool TryGetDefinition(string? abilityId, [NotNullWhen(true)] out AbilityDefRow? definition) + { + if (abilityId is null) + { + definition = null; + return false; + } + + return catalog.TryGetAbility(abilityId, out definition); + } + + /// + 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 _); + } + + /// + public IReadOnlyList GetDefinitionsInIdOrder() => _definitionsInIdOrder; + + private static IReadOnlyList BuildDefinitionsInIdOrder(AbilityDefinitionCatalog 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/Combat/IAbilityDefinitionRegistry.cs b/server/NeonSprawl.Server/Game/Combat/IAbilityDefinitionRegistry.cs new file mode 100644 index 0000000..3c670fe --- /dev/null +++ b/server/NeonSprawl.Server/Game/Combat/IAbilityDefinitionRegistry.cs @@ -0,0 +1,23 @@ +using System.Diagnostics.CodeAnalysis; + +namespace NeonSprawl.Server.Game.Combat; + +/// +/// Read-only access to validated entries loaded at startup (). +/// +/// +/// E5.M1 (combat / hotbar / cast): callers validating ability ids and resolving damage/cooldown metadata should depend on this interface +/// rather than so ability defs stay centralized. +/// NEO-78: HTTP/read-model projections should depend on this interface rather than reaching into the catalog. +/// +public interface IAbilityDefinitionRegistry +{ + /// Attempts to resolve an ability by stable id (see ability-def.schema.json). Unknown ids and null return false without throwing. + bool TryGetDefinition(string? abilityId, [NotNullWhen(true)] out AbilityDefRow? definition); + + /// Trims and lowercases ; returns true when the normalized id exists in the loaded catalog. + bool TryNormalizeKnown(string rawAbilityId, [NotNullWhen(true)] out string normalized); + + /// Every loaded definition, ordered by (ordinal). + IReadOnlyList GetDefinitionsInIdOrder(); +} diff --git a/server/README.md b/server/README.md index 0277adf..45021c9 100644 --- a/server/README.md +++ b/server/README.md @@ -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)