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
parent
8bafec9d79
commit
bd4912004a
|
|
@ -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");
|
||||
});
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue