diff --git a/bruno/neon-sprawl-server/ability-catalog/Health after ability catalog load.bru b/bruno/neon-sprawl-server/ability-catalog/Health after ability catalog load.bru new file mode 100644 index 0000000..757385e --- /dev/null +++ b/bruno/neon-sprawl-server/ability-catalog/Health after ability catalog load.bru @@ -0,0 +1,25 @@ +meta { + name: GET health (ability catalog boot NEO-77) + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/health + body: none + auth: none +} + +docs { + NEO-77 loads content/abilities/*_abilities.json at startup (fail-fast). No ability HTTP API in this story — use this request to confirm the host started after catalog validation. +} + +tests { + test("status 200", function () { + expect(res.getStatus()).to.equal(200); + }); + + test("service identity", function () { + expect(res.getBody().service).to.equal("NeonSprawl.Server"); + }); +} diff --git a/server/NeonSprawl.Server.Tests/Game/Combat/AbilityCatalogTestPaths.cs b/server/NeonSprawl.Server.Tests/Game/Combat/AbilityCatalogTestPaths.cs new file mode 100644 index 0000000..1965b73 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Combat/AbilityCatalogTestPaths.cs @@ -0,0 +1,17 @@ +using NeonSprawl.Server.Game.Combat; + +namespace NeonSprawl.Server.Tests.Game.Combat; + +internal static class AbilityCatalogTestPaths +{ + internal static string DiscoverRepoAbilitiesDirectory() => + AbilityCatalogPathResolution.TryDiscoverAbilitiesDirectory(AppContext.BaseDirectory) + ?? throw new InvalidOperationException( + "Could not discover repo content/abilities from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); + + internal static string DiscoverRepoAbilityDefSchemaPath() => + AbilityCatalogPathResolution.ResolveAbilityDefSchemaPath( + DiscoverRepoAbilitiesDirectory(), + configuredSchemaPath: null, + contentRootPath: string.Empty); +} diff --git a/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionCatalogLoaderTests.cs b/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionCatalogLoaderTests.cs new file mode 100644 index 0000000..30a7623 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionCatalogLoaderTests.cs @@ -0,0 +1,333 @@ +using System.Net; +using System.Text; +using System.Text.Json.Nodes; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NeonSprawl.Server.Game.Combat; +using NeonSprawl.Server.Tests; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Combat; + +public class AbilityDefinitionCatalogLoaderTests +{ + private const string ValidPrototypeCatalogJson = + """ + { + "schemaVersion": 1, + "abilities": [ + { + "id": "prototype_pulse", + "displayName": "Prototype Pulse", + "baseDamage": 25, + "cooldownSeconds": 3.0, + "abilityKind": "attack" + }, + { + "id": "prototype_guard", + "displayName": "Prototype Guard", + "baseDamage": 0, + "cooldownSeconds": 6.0, + "abilityKind": "utility" + }, + { + "id": "prototype_dash", + "displayName": "Prototype Dash", + "baseDamage": 0, + "cooldownSeconds": 4.0, + "abilityKind": "movement" + }, + { + "id": "prototype_burst", + "displayName": "Prototype Burst", + "baseDamage": 40, + "cooldownSeconds": 5.0, + "abilityKind": "attack" + } + ] + } + """; + + private static (string Root, string AbilitiesDir, string SchemaPath) CreateTempContentLayout() + { + var root = Directory.CreateTempSubdirectory("neon-sprawl-abilitycat-"); + 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); + return (root.FullName, abilitiesDir, schemaPath); + } + + private static void WriteCatalog(string abilitiesDir, string catalogJson) => + File.WriteAllText(Path.Combine(abilitiesDir, "prototype_abilities.json"), catalogJson, Encoding.UTF8); + + private static JsonObject GetAbilityRow(JsonObject catalogRoot, string abilityId) + { + var abilities = catalogRoot["abilities"] as JsonArray + ?? throw new InvalidOperationException("expected abilities array"); + foreach (var node in abilities) + { + if (node is JsonObject row && row["id"]?.GetValue() == abilityId) + return row; + } + + throw new InvalidOperationException($"ability id not found: {abilityId}"); + } + + private static AbilityDefinitionCatalog LoadCatalog(string abilitiesDir, string schemaPath) => + AbilityDefinitionCatalogLoader.Load(abilitiesDir, schemaPath, NullLogger.Instance); + + [Fact] + public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract() + { + // Arrange + var (_, abilitiesDir, schemaPath) = CreateTempContentLayout(); + WriteCatalog(abilitiesDir, ValidPrototypeCatalogJson); + // Act + var catalog = LoadCatalog(abilitiesDir, schemaPath); + // Assert + Assert.Equal(4, catalog.DistinctAbilityCount); + Assert.Equal(1, catalog.CatalogJsonFileCount); + Assert.True(catalog.TryGetAbility("prototype_pulse", out var pulse)); + Assert.NotNull(pulse); + Assert.Equal(25, pulse!.BaseDamage); + Assert.Equal(3.0, pulse.CooldownSeconds); + Assert.Equal("attack", pulse.AbilityKind); + } + + [Fact] + public void Load_ShouldThrow_WhenAbilitiesIsNotArray() + { + // Arrange + var (_, abilitiesDir, schemaPath) = CreateTempContentLayout(); + File.WriteAllText( + Path.Combine(abilitiesDir, "bad_abilities.json"), + """{"schemaVersion": 1, "abilities": "nope"}""", + Encoding.UTF8); + // Act + var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("bad_abilities.json", ioe.Message, StringComparison.Ordinal); + Assert.Contains("expected top-level 'abilities' array", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenSchemaVersionIsNotOne() + { + // Arrange + var (_, abilitiesDir, schemaPath) = CreateTempContentLayout(); + File.WriteAllText( + Path.Combine(abilitiesDir, "bad_abilities.json"), + """{"schemaVersion": 2, "abilities": []}""", + Encoding.UTF8); + // Act + var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("expected schemaVersion 1", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenDuplicateAbilityIdAcrossFiles() + { + // Arrange + var (_, abilitiesDir, schemaPath) = CreateTempContentLayout(); + WriteCatalog(abilitiesDir, ValidPrototypeCatalogJson); + File.WriteAllText( + Path.Combine(abilitiesDir, "extra_abilities.json"), + """ + { + "schemaVersion": 1, + "abilities": [ + { + "id": "prototype_pulse", + "displayName": "Duplicate Pulse", + "baseDamage": 1, + "cooldownSeconds": 1.0 + } + ] + } + """, + Encoding.UTF8); + // Act + var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("duplicate ability id 'prototype_pulse'", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenE5M1GateFails() + { + // Arrange + var (_, abilitiesDir, schemaPath) = CreateTempContentLayout(); + var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object root"); + var abilities = root["abilities"] as JsonArray + ?? throw new InvalidOperationException("expected abilities array"); + abilities.RemoveAt(abilities.Count - 1); + WriteCatalog(abilitiesDir, root.ToJsonString()); + // Act + var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("prototype E5M1 expects exactly ability ids", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenBaseDamageIsNegative() + { + // Arrange + var (_, abilitiesDir, schemaPath) = CreateTempContentLayout(); + var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object root"); + GetAbilityRow(root, "prototype_pulse")["baseDamage"] = -1; + WriteCatalog(abilitiesDir, root.ToJsonString()); + // Act + var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("Ability catalog validation failed", ioe.Message, StringComparison.Ordinal); + Assert.Contains("baseDamage", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenCooldownSecondsIsZero() + { + // Arrange + var (_, abilitiesDir, schemaPath) = CreateTempContentLayout(); + var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object root"); + GetAbilityRow(root, "prototype_pulse")["cooldownSeconds"] = 0; + WriteCatalog(abilitiesDir, root.ToJsonString()); + // Act + var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("Ability catalog validation failed", ioe.Message, StringComparison.Ordinal); + Assert.Contains("cooldownSeconds", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenDisplayNameIsEmpty() + { + // Arrange + var (_, abilitiesDir, schemaPath) = CreateTempContentLayout(); + var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object root"); + GetAbilityRow(root, "prototype_pulse")["displayName"] = ""; + WriteCatalog(abilitiesDir, root.ToJsonString()); + // Act + var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("Ability catalog validation failed", ioe.Message, StringComparison.Ordinal); + Assert.Contains("displayName", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenNoAbilityCatalogFiles() + { + // Arrange + var (_, abilitiesDir, schemaPath) = CreateTempContentLayout(); + // Act + var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("no *_abilities.json files", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenJsonIsInvalid() + { + // Arrange + var (_, abilitiesDir, schemaPath) = CreateTempContentLayout(); + File.WriteAllText( + Path.Combine(abilitiesDir, "bad_abilities.json"), + "{not json", + Encoding.UTF8); + // Act + var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("bad_abilities.json", ioe.Message, StringComparison.Ordinal); + Assert.Contains("invalid JSON", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenAbilitiesDirectoryMissing() + { + // Arrange + var missingDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-no-abilities-" + Guid.NewGuid().ToString("n")); + var schemaPath = AbilityCatalogTestPaths.DiscoverRepoAbilityDefSchemaPath(); + // Act + var ex = Record.Exception(() => + AbilityDefinitionCatalogLoader.Load(missingDir, schemaPath, NullLogger.Instance)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("missing directory", ioe.Message, StringComparison.Ordinal); + Assert.Contains(missingDir, ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenSchemaFileMissing() + { + // Arrange + var (_, abilitiesDir, _) = CreateTempContentLayout(); + var missingSchema = Path.Combine(abilitiesDir, "missing-ability-def.schema.json"); + // Act + var ex = Record.Exception(() => + AbilityDefinitionCatalogLoader.Load(abilitiesDir, missingSchema, NullLogger.Instance)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("missing schema file", ioe.Message, StringComparison.Ordinal); + Assert.Contains(missingSchema, ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task Host_ShouldResolveCatalogFromDi_WhenStartupSucceeds() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + using var client = factory.CreateClient(); + // Act + var response = await client.GetAsync("/health"); + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var catalog = factory.Services.GetRequiredService(); + Assert.Equal(4, catalog.DistinctAbilityCount); + Assert.True(catalog.TryGetAbility("prototype_burst", out var burst)); + Assert.Equal(40, burst!.BaseDamage); + Assert.Equal(5.0, burst.CooldownSeconds); + } + + [Fact] + public void Host_ShouldFailStartup_WhenCatalogDirectoryInvalid() + { + // Arrange + var badDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-empty-abilities-" + Guid.NewGuid().ToString("n")); + Directory.CreateDirectory(badDir); + try + { + // Act + var ex = Record.Exception(() => + { + using var factory = new WebApplicationFactory().WithWebHostBuilder(b => + b.UseSetting("Content:AbilitiesDirectory", badDir)); + factory.CreateClient(); + }); + // Assert + Assert.NotNull(ex); + Assert.Contains("Ability catalog validation failed", ex.ToString(), StringComparison.Ordinal); + } + finally + { + if (Directory.Exists(badDir)) + Directory.Delete(badDir); + } + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs index f9efdbe..c1f96b7 100644 --- a/server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Time.Testing; using NeonSprawl.Server.Game.AbilityInput; +using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Gathering; using NeonSprawl.Server.Game.Items; @@ -41,11 +42,15 @@ public sealed class SalvageActivityDeniedRegistryWebApplicationFactory : WebAppl var recipesDir = RecipeCatalogPathResolution.TryDiscoverRecipesDirectory(AppContext.BaseDirectory) ?? throw new InvalidOperationException( "Could not discover repo content/recipes from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); + var abilitiesDir = AbilityCatalogPathResolution.TryDiscoverAbilitiesDirectory(AppContext.BaseDirectory) + ?? throw new InvalidOperationException( + "Could not discover repo content/abilities from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); builder.UseSetting("Content:SkillsDirectory", skillsDir); builder.UseSetting("Content:MasteryDirectory", masteryDir); builder.UseSetting("Content:ItemsDirectory", itemsDir); builder.UseSetting("Content:ResourceNodesDirectory", resourceNodesDir); builder.UseSetting("Content:RecipesDirectory", recipesDir); + builder.UseSetting("Content:AbilitiesDirectory", abilitiesDir); builder.UseSetting("Game:EnableMasteryFixtureApi", "true"); builder.ConfigureTestServices(services => diff --git a/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresWebApplicationFactory.cs index 0337336..dd6072b 100644 --- a/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresWebApplicationFactory.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.Configuration; +using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Gathering; using NeonSprawl.Server.Game.Items; @@ -39,11 +40,15 @@ public sealed class PostgresWebApplicationFactory : WebApplicationFactory { diff --git a/server/NeonSprawl.Server.Tests/Game/Skills/MissionRewardDeniedRegistryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/Game/Skills/MissionRewardDeniedRegistryWebApplicationFactory.cs index 1e29349..fb336d1 100644 --- a/server/NeonSprawl.Server.Tests/Game/Skills/MissionRewardDeniedRegistryWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/Game/Skills/MissionRewardDeniedRegistryWebApplicationFactory.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Time.Testing; using NeonSprawl.Server.Game.AbilityInput; +using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.PositionState; @@ -33,9 +34,13 @@ public sealed class MissionRewardDeniedRegistryWebApplicationFactory : WebApplic var recipesDir = RecipeCatalogPathResolution.TryDiscoverRecipesDirectory(AppContext.BaseDirectory) ?? throw new InvalidOperationException( "Could not discover repo content/recipes from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); + var abilitiesDir = AbilityCatalogPathResolution.TryDiscoverAbilitiesDirectory(AppContext.BaseDirectory) + ?? throw new InvalidOperationException( + "Could not discover repo content/abilities from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); builder.UseSetting("Content:SkillsDirectory", skillsDir); builder.UseSetting("Content:MasteryDirectory", masteryDir); builder.UseSetting("Content:RecipesDirectory", recipesDir); + builder.UseSetting("Content:AbilitiesDirectory", abilitiesDir); builder.ConfigureTestServices(services => { diff --git a/server/NeonSprawl.Server.Tests/Game/Skills/RefineActivityDeniedRegistryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/Game/Skills/RefineActivityDeniedRegistryWebApplicationFactory.cs index ea04cc8..b0b9a23 100644 --- a/server/NeonSprawl.Server.Tests/Game/Skills/RefineActivityDeniedRegistryWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/Game/Skills/RefineActivityDeniedRegistryWebApplicationFactory.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Time.Testing; using NeonSprawl.Server.Game.AbilityInput; +using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.PositionState; @@ -33,9 +34,13 @@ public sealed class RefineActivityDeniedRegistryWebApplicationFactory : WebAppli var recipesDir = RecipeCatalogPathResolution.TryDiscoverRecipesDirectory(AppContext.BaseDirectory) ?? throw new InvalidOperationException( "Could not discover repo content/recipes from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); + var abilitiesDir = AbilityCatalogPathResolution.TryDiscoverAbilitiesDirectory(AppContext.BaseDirectory) + ?? throw new InvalidOperationException( + "Could not discover repo content/abilities from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); builder.UseSetting("Content:SkillsDirectory", skillsDir); builder.UseSetting("Content:MasteryDirectory", masteryDir); builder.UseSetting("Content:RecipesDirectory", recipesDir); + builder.UseSetting("Content:AbilitiesDirectory", abilitiesDir); builder.ConfigureTestServices(services => { diff --git a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs index 0afbe68..dc283ad 100644 --- a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Time.Testing; using NeonSprawl.Server.Game.AbilityInput; +using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Gathering; using NeonSprawl.Server.Game.Items; @@ -42,11 +43,15 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory diff --git a/server/NeonSprawl.Server/Game/Combat/AbilityCatalogPathResolution.cs b/server/NeonSprawl.Server/Game/Combat/AbilityCatalogPathResolution.cs new file mode 100644 index 0000000..cf75380 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Combat/AbilityCatalogPathResolution.cs @@ -0,0 +1,60 @@ +namespace NeonSprawl.Server.Game.Combat; + +/// Resolves ability catalog paths for local dev, tests, and container layouts (NEO-77). +public static class AbilityCatalogPathResolution +{ + /// Walks and parents for an existing content/abilities directory. + public static string? TryDiscoverAbilitiesDirectory(string startDirectory) + { + for (var dir = new DirectoryInfo(startDirectory); dir is not null; dir = dir.Parent) + { + var candidate = Path.Combine(dir.FullName, "content", "abilities"); + if (Directory.Exists(candidate)) + return Path.GetFullPath(candidate); + } + + return null; + } + + /// + /// Resolves the abilities catalog directory. + /// Empty triggers discovery from . + /// + public static string ResolveAbilitiesDirectory(string? configuredAbilitiesDirectory, string contentRootPath) + { + if (string.IsNullOrWhiteSpace(configuredAbilitiesDirectory)) + { + var discovered = TryDiscoverAbilitiesDirectory(AppContext.BaseDirectory); + if (discovered is not null) + return discovered; + + throw new InvalidOperationException( + "Content:AbilitiesDirectory is not set and auto-discovery failed (no ancestor of AppContext.BaseDirectory contains 'content/abilities'). " + + "Set Content:AbilitiesDirectory in configuration or environment (e.g. Content__AbilitiesDirectory)."); + } + + var trimmed = configuredAbilitiesDirectory.Trim(); + if (Path.IsPathRooted(trimmed)) + return Path.GetFullPath(trimmed); + + return Path.GetFullPath(Path.Combine(contentRootPath, trimmed)); + } + + /// Resolves JSON Schema path for a single ability row (Draft 2020-12). + public static string ResolveAbilityDefSchemaPath( + string abilitiesDirectory, + string? configuredSchemaPath, + string contentRootPath) + { + if (!string.IsNullOrWhiteSpace(configuredSchemaPath)) + { + var trimmed = configuredSchemaPath.Trim(); + if (Path.IsPathRooted(trimmed)) + return Path.GetFullPath(trimmed); + + return Path.GetFullPath(Path.Combine(contentRootPath, trimmed)); + } + + return Path.GetFullPath(Path.Combine(abilitiesDirectory, "..", "schemas", "ability-def.schema.json")); + } +} diff --git a/server/NeonSprawl.Server/Game/Combat/AbilityCatalogServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Combat/AbilityCatalogServiceCollectionExtensions.cs new file mode 100644 index 0000000..68e7c9f --- /dev/null +++ b/server/NeonSprawl.Server/Game/Combat/AbilityCatalogServiceCollectionExtensions.cs @@ -0,0 +1,37 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using NeonSprawl.Server.Game.Skills; + +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. + public static IServiceCollection AddAbilityDefinitionCatalog(this IServiceCollection services, IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration.GetSection(ContentPathsOptions.SectionName)); + + services.AddSingleton(sp => + { + var hostEnv = sp.GetRequiredService(); + var opts = sp.GetRequiredService>().Value; + var logger = sp.GetRequiredService() + .CreateLogger("NeonSprawl.Server.Game.Combat.AbilityCatalog"); + + var abilitiesDir = AbilityCatalogPathResolution.ResolveAbilitiesDirectory( + opts.AbilitiesDirectory, + hostEnv.ContentRootPath); + var schemaPath = AbilityCatalogPathResolution.ResolveAbilityDefSchemaPath( + abilitiesDir, + opts.AbilityDefSchemaPath, + hostEnv.ContentRootPath); + + return AbilityDefinitionCatalogLoader.Load(abilitiesDir, schemaPath, logger); + }); + + return services; + } +} diff --git a/server/NeonSprawl.Server/Game/Combat/AbilityDefRow.cs b/server/NeonSprawl.Server/Game/Combat/AbilityDefRow.cs new file mode 100644 index 0000000..34ff4cb --- /dev/null +++ b/server/NeonSprawl.Server/Game/Combat/AbilityDefRow.cs @@ -0,0 +1,9 @@ +namespace NeonSprawl.Server.Game.Combat; + +/// One validated AbilityDef row from content/abilities/*_abilities.json (NEO-77). +public sealed record AbilityDefRow( + string Id, + string DisplayName, + int BaseDamage, + double CooldownSeconds, + string? AbilityKind); diff --git a/server/NeonSprawl.Server/Game/Combat/AbilityDefinitionCatalog.cs b/server/NeonSprawl.Server/Game/Combat/AbilityDefinitionCatalog.cs new file mode 100644 index 0000000..1c5b10f --- /dev/null +++ b/server/NeonSprawl.Server/Game/Combat/AbilityDefinitionCatalog.cs @@ -0,0 +1,24 @@ +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). +public sealed class AbilityDefinitionCatalog( + string abilitiesDirectory, + IReadOnlyDictionary byId, + int catalogJsonFileCount) +{ + /// Absolute path to the directory that was enumerated for *_abilities.json catalogs. + public string AbilitiesDirectory { get; } = abilitiesDirectory; + + public IReadOnlyDictionary ById { get; } = new ReadOnlyDictionary(new Dictionary(byId, StringComparer.Ordinal)); + + public int DistinctAbilityCount => ById.Count; + + /// Number of *_abilities.json files under . + public int CatalogJsonFileCount { get; } = catalogJsonFileCount; + + /// Resolves a catalog row by stable ability . + public bool TryGetAbility(string id, out AbilityDefRow? row) => + ById.TryGetValue(id, out row); +} diff --git a/server/NeonSprawl.Server/Game/Combat/AbilityDefinitionCatalogLoader.cs b/server/NeonSprawl.Server/Game/Combat/AbilityDefinitionCatalogLoader.cs new file mode 100644 index 0000000..5eb5e83 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Combat/AbilityDefinitionCatalogLoader.cs @@ -0,0 +1,190 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Json.Schema; +using Microsoft.Extensions.Logging; + +namespace NeonSprawl.Server.Game.Combat; + +/// Loads and validates content/abilities/*_abilities.json using the same rules as scripts/validate_content.py (NEO-77). +public static class AbilityDefinitionCatalogLoader +{ + /// Loads catalogs from disk or throws with actionable messages. + public static AbilityDefinitionCatalog Load(string abilitiesDirectory, string schemaPath, ILogger logger) + { + abilitiesDirectory = Path.GetFullPath(abilitiesDirectory); + schemaPath = Path.GetFullPath(schemaPath); + + var errors = new List(); + + if (!File.Exists(schemaPath)) + errors.Add($"error: missing schema file {schemaPath}"); + + if (!Directory.Exists(abilitiesDirectory)) + errors.Add($"error: missing directory {abilitiesDirectory}"); + + if (errors.Count > 0) + ThrowIfAny(errors); + + var jsonFiles = Directory.GetFiles(abilitiesDirectory, "*_abilities.json", SearchOption.TopDirectoryOnly) + .OrderBy(p => p, StringComparer.Ordinal) + .ToArray(); + if (jsonFiles.Length == 0) + errors.Add($"error: no *_abilities.json files under {abilitiesDirectory}"); + + if (errors.Count > 0) + ThrowIfAny(errors); + + var schema = JsonSchema.FromText(File.ReadAllText(schemaPath)); + var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List }; + + var abilityIdToSourceFile = new Dictionary(StringComparer.Ordinal); + var rows = new Dictionary(StringComparer.Ordinal); + + foreach (var path in jsonFiles) + { + JsonNode? root; + try + { + root = JsonNode.Parse(File.ReadAllText(path)); + } + catch (JsonException ex) + { + errors.Add($"error: {path}: invalid JSON: {ex.Message}"); + continue; + } + + if (root is not JsonObject rootObj) + { + errors.Add($"error: {path}: expected JSON object at root"); + continue; + } + + var schemaVersionNode = rootObj["schemaVersion"]; + if (schemaVersionNode is not JsonValue schemaVersionValue || + !schemaVersionValue.TryGetValue(out var schemaVersion) || + schemaVersion != 1) + { + var got = schemaVersionNode?.ToJsonString() ?? "null"; + errors.Add($"error: {path}: expected schemaVersion 1, got {got}"); + continue; + } + + var abilitiesNode = rootObj["abilities"]; + if (abilitiesNode is not JsonArray abilitiesArray) + { + errors.Add($"error: {path}: expected top-level 'abilities' array"); + continue; + } + + for (var i = 0; i < abilitiesArray.Count; i++) + { + var ability = abilitiesArray[i]; + if (ability is not JsonObject rowObj) + { + errors.Add($"error: {path}: abilities[{i}] must be an object"); + continue; + } + + var eval = schema.Evaluate(rowObj, evalOptions); + var schemaMsgs = CollectSchemaMessages(eval, path, i).OrderBy(m => m, StringComparer.Ordinal).ToList(); + if (!eval.IsValid) + { + if (schemaMsgs.Count == 0) + schemaMsgs.Add($"error: {path} abilities[{i}] (root): schema validation failed"); + + errors.AddRange(schemaMsgs); + } + + var rowSchemaErrors = schemaMsgs.Count; + + var aid = (rowObj["id"] as JsonValue)?.GetValue(); + if (aid is not null && rowSchemaErrors == 0) + { + if (abilityIdToSourceFile.TryGetValue(aid, out var prevPath)) + { + errors.Add($"error: duplicate ability id '{aid}' in {prevPath} and {path}"); + continue; + } + + abilityIdToSourceFile[aid] = path; + rows[aid] = ParseRow(rowObj); + } + } + } + + ThrowIfAny(errors); + + var e5m1 = PrototypeE5M1AbilityCatalogRules.TryGetE5M1GateError(abilityIdToSourceFile); + if (e5m1 is not null) + { + errors.Add(e5m1); + ThrowIfAny(errors); + } + + if (logger.IsEnabled(LogLevel.Information)) + { + logger.LogInformation( + "Loaded ability catalog from {AbilitiesDirectory}: {AbilityCount} ability(s) across {CatalogFileCount} JSON catalog file(s).", + abilitiesDirectory, + rows.Count, + jsonFiles.Length); + } + + return new AbilityDefinitionCatalog(abilitiesDirectory, rows, jsonFiles.Length); + } + + private static AbilityDefRow ParseRow(JsonObject rowObj) + { + var id = (rowObj["id"] as JsonValue)!.GetValue(); + var displayName = (rowObj["displayName"] as JsonValue)!.GetValue(); + var baseDamage = (rowObj["baseDamage"] as JsonValue)!.GetValue(); + var cooldownSeconds = (rowObj["cooldownSeconds"] as JsonValue)!.GetValue(); + string? abilityKind = null; + if (rowObj["abilityKind"] is JsonValue abilityKindValue) + abilityKind = abilityKindValue.GetValue(); + + return new AbilityDefRow(id, displayName, baseDamage, cooldownSeconds, abilityKind); + } + + private static List CollectSchemaMessages(EvaluationResults eval, string filePath, int index) + { + var sink = new List(); + AppendSchemaMessages(eval, filePath, index, sink); + return sink; + } + + private static void AppendSchemaMessages(EvaluationResults r, string filePath, int index, List sink) + { + if (r.HasDetails) + { + foreach (var d in r.Details!) + AppendSchemaMessages(d, filePath, index, sink); + } + + if (!r.HasErrors) + return; + + foreach (var kv in r.Errors!) + { + var loc = r.InstanceLocation?.ToString(); + if (string.IsNullOrEmpty(loc) || loc == "#") + loc = "(root)"; + + sink.Add($"error: {filePath} abilities[{index}] {loc}: {kv.Key} — {kv.Value}"); + } + } + + private static void ThrowIfAny(List errors) + { + if (errors.Count == 0) + return; + + var sb = new StringBuilder(); + sb.AppendLine("Ability catalog validation failed:"); + foreach (var e in errors.OrderBy(x => x, StringComparer.Ordinal)) + sb.AppendLine(e); + + throw new InvalidOperationException(sb.ToString().TrimEnd()); + } +} diff --git a/server/NeonSprawl.Server/Game/Combat/PrototypeE5M1AbilityCatalogRules.cs b/server/NeonSprawl.Server/Game/Combat/PrototypeE5M1AbilityCatalogRules.cs new file mode 100644 index 0000000..30f8b89 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Combat/PrototypeE5M1AbilityCatalogRules.cs @@ -0,0 +1,35 @@ +using System.Collections.Frozen; + +namespace NeonSprawl.Server.Game.Combat; + +/// +/// Prototype E5M1 roster gate (NEO-76 / NEO-77), mirrored from scripts/validate_content.py +/// PROTOTYPE_E5M1_ABILITY_IDS / _prototype_e5m1_ability_gate. +/// +public static class PrototypeE5M1AbilityCatalogRules +{ + /// Keep in sync with scripts/validate_content.py PROTOTYPE_E5M1_ABILITY_IDS. + public static readonly FrozenSet ExpectedAbilityIds = FrozenSet.ToFrozenSet( + [ + "prototype_pulse", + "prototype_guard", + "prototype_dash", + "prototype_burst", + ], + StringComparer.Ordinal); + + /// Returns a human-readable error if the E5M1 ability contract fails, otherwise . + public static string? TryGetE5M1GateError(IReadOnlyDictionary abilityIdToSourceFile) + { + var ids = abilityIdToSourceFile.Keys.ToFrozenSet(StringComparer.Ordinal); + if (!ids.SetEquals(ExpectedAbilityIds)) + { + return + "error: prototype E5M1 expects exactly ability ids " + + $"[{string.Join(", ", ExpectedAbilityIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " + + $"got [{string.Join(", ", ids.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}]"; + } + + return null; + } +} diff --git a/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs b/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs index 4655375..93da821 100644 --- a/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs +++ b/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs @@ -76,4 +76,16 @@ public sealed class ContentPathsOptions /// When unset, resolved as {parent of recipes directory}/schemas/recipe-io-row.schema.json. /// public string? RecipeIoRowSchemaPath { get; set; } + + /// + /// Optional. Absolute path, or path relative to . + /// When unset, the host walks ancestors of for a content/abilities directory. + /// + public string? AbilitiesDirectory { get; set; } + + /// + /// Optional override for ability-def.schema.json. + /// When unset, resolved as {parent of abilities directory}/schemas/ability-def.schema.json. + /// + public string? AbilityDefSchemaPath { get; set; } } diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index 645dd15..87ae584 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -1,4 +1,5 @@ using NeonSprawl.Server.Game.AbilityInput; +using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Gathering; using NeonSprawl.Server.Game.Interaction; @@ -19,6 +20,7 @@ builder.Services.AddSkillDefinitionCatalog(builder.Configuration); builder.Services.AddItemDefinitionCatalog(builder.Configuration); builder.Services.AddResourceNodeCatalog(builder.Configuration); builder.Services.AddRecipeDefinitionCatalog(builder.Configuration); +builder.Services.AddAbilityDefinitionCatalog(builder.Configuration); builder.Services.AddMasteryCatalog(builder.Configuration); var app = builder.Build(); @@ -26,6 +28,7 @@ _ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); +_ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); diff --git a/server/NeonSprawl.Server/appsettings.json b/server/NeonSprawl.Server/appsettings.json index 3e9cd5a..b13ffb7 100644 --- a/server/NeonSprawl.Server/appsettings.json +++ b/server/NeonSprawl.Server/appsettings.json @@ -13,7 +13,9 @@ "ItemDefSchemaPath": "", "RecipesDirectory": "", "RecipeDefSchemaPath": "", - "RecipeIoRowSchemaPath": "" + "RecipeIoRowSchemaPath": "", + "AbilitiesDirectory": "", + "AbilityDefSchemaPath": "" }, "Game": { "DevPlayerId": "dev-local-1", diff --git a/server/README.md b/server/README.md index d8321d1..0277adf 100644 --- a/server/README.md +++ b/server/README.md @@ -80,6 +80,19 @@ On startup the host loads every **`*_recipes.json`** under the recipes directory On success, **Information** logs include the resolved recipes directory path, distinct recipe count, and catalog file count. Game code should use **`IRecipeDefinitionRegistry`** for lookups (NEO-67). The catalog singleton remains for fail-fast startup only; do not inject **`RecipeDefinitionCatalog`** in new game code. +## Ability catalog (`content/abilities`, NEO-77) + +On startup the host loads every **`*_abilities.json`** under the abilities directory, validates each row against **`content/schemas/ability-def.schema.json`**, requires **`schemaVersion` 1** per file, rejects **duplicate `id`** values across files, and enforces the **prototype E5M1** four-id roster gate (same rules as **`scripts/validate_content.py`**). If anything is missing or invalid, the process **exits during startup** with an actionable error—there is no silent fallback. + +| Config | Meaning | +|--------|---------| +| **`Content:AbilitiesDirectory`** | Optional. Absolute path, or path relative to the server **content root**. When unset, walks **ancestors of `AppContext.BaseDirectory`** until it finds **`content/abilities`**. | +| **`Content:AbilityDefSchemaPath`** | Optional override for **`ability-def.schema.json`**. When unset, **`{parent of abilities directory}/schemas/ability-def.schema.json`**. | + +**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. + ## Recipe definitions (NEO-68) **`GET /game/world/recipe-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`recipes`**) backed by **`IRecipeDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`recipeKind`**, **`requiredSkillId`**, and nested **`inputs`** / **`outputs`** (`itemId`, `quantity`). Plan: [NEO-68 implementation plan](../../docs/plans/NEO-68-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-68.md`](../../docs/manual-qa/NEO-68.md); Bruno: `bruno/neon-sprawl-server/recipe-definitions/`.