From 1a9088c77c07005467370f55d63e4b0852d1916d Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sat, 2 May 2026 19:44:10 -0400 Subject: [PATCH] NEO-34: fail-fast skill catalog load at server startup Register SkillDefinitionCatalog from content/skills with JsonSchema.Net validation mirroring validate_content.py and the prototype Slice 1 gate. Resolve paths via Content:SkillsDirectory or discovery from AppContext.BaseDirectory. Eager-load after WebApplication build so bad catalogs block startup. Add unit and host tests, pin test factories to repo skills, document config in server/README, add manual QA checklist, and extend Bruno GET /health smoke tests. --- bruno/neon-sprawl-server/core/Health.bru | 10 + docs/manual-qa/NEO-34.md | 12 ++ docs/plans/NEO-34-implementation-plan.md | 8 +- .../PostgresWebApplicationFactory.cs | 6 + .../Game/Skills/SkillCatalogTestPaths.cs | 17 ++ .../SkillDefinitionCatalogLoaderTests.cs | 199 ++++++++++++++++++ .../InMemoryWebApplicationFactory.cs | 6 + .../Game/Skills/ContentPathsOptions.cs | 19 ++ .../PrototypeSlice1SkillCatalogRules.cs | 47 +++++ .../Game/Skills/SkillCatalogPathResolution.cs | 60 ++++++ ...SkillCatalogServiceCollectionExtensions.cs | 34 +++ .../Game/Skills/SkillDefRow.cs | 8 + .../Game/Skills/SkillDefinitionCatalog.cs | 27 +++ .../Skills/SkillDefinitionCatalogLoader.cs | 181 ++++++++++++++++ .../NeonSprawl.Server.csproj | 1 + server/NeonSprawl.Server/Program.cs | 3 + server/README.md | 13 ++ 17 files changed, 647 insertions(+), 4 deletions(-) create mode 100644 docs/manual-qa/NEO-34.md create mode 100644 server/NeonSprawl.Server.Tests/Game/Skills/SkillCatalogTestPaths.cs create mode 100644 server/NeonSprawl.Server.Tests/Game/Skills/SkillDefinitionCatalogLoaderTests.cs create mode 100644 server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs create mode 100644 server/NeonSprawl.Server/Game/Skills/PrototypeSlice1SkillCatalogRules.cs create mode 100644 server/NeonSprawl.Server/Game/Skills/SkillCatalogPathResolution.cs create mode 100644 server/NeonSprawl.Server/Game/Skills/SkillCatalogServiceCollectionExtensions.cs create mode 100644 server/NeonSprawl.Server/Game/Skills/SkillDefRow.cs create mode 100644 server/NeonSprawl.Server/Game/Skills/SkillDefinitionCatalog.cs create mode 100644 server/NeonSprawl.Server/Game/Skills/SkillDefinitionCatalogLoader.cs diff --git a/bruno/neon-sprawl-server/core/Health.bru b/bruno/neon-sprawl-server/core/Health.bru index b31f314..b39e0a5 100644 --- a/bruno/neon-sprawl-server/core/Health.bru +++ b/bruno/neon-sprawl-server/core/Health.bru @@ -9,3 +9,13 @@ get { body: none auth: none } + +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/docs/manual-qa/NEO-34.md b/docs/manual-qa/NEO-34.md new file mode 100644 index 0000000..3edcd7b --- /dev/null +++ b/docs/manual-qa/NEO-34.md @@ -0,0 +1,12 @@ +# Manual QA — NEO-34 (skill catalog at startup) + +## Prerequisites + +- Repo clone with `content/skills` and `content/schemas/skill-def.schema.json` present. + +## Checks + +- [ ] From `server/NeonSprawl.Server`, run `dotnet run` (no extra `Content` env). Startup logs an **Information** line from category `NeonSprawl.Server.Game.Skills.SkillCatalog` with the **absolute** skills directory path and **3** skills (prototype catalog). +- [ ] `GET /health` returns **200** after startup. +- [ ] Introduce a deliberate catalog error (e.g. temporarily duplicate an `id` in `content/skills/prototype_skills.json`). `dotnet run` **fails** before listening; stderr shows **Skill catalog validation failed** and a path/rule hint. Revert the edit after the check. +- [ ] Optional: set `Content__SkillsDirectory` to a temp directory with invalid JSON and confirm the same fail-fast behavior. diff --git a/docs/plans/NEO-34-implementation-plan.md b/docs/plans/NEO-34-implementation-plan.md index 3efa414..ac1215c 100644 --- a/docs/plans/NEO-34-implementation-plan.md +++ b/docs/plans/NEO-34-implementation-plan.md @@ -32,10 +32,10 @@ ## Acceptance criteria checklist -- [ ] Integration or host-level test: **valid** fixture directory → `WebApplicationFactory` (or equivalent) starts and catalog is resolvable from DI. -- [ ] Integration or host-level test: **invalid** fixture (malformed JSON, schema violation, duplicate `id`, or Slice-1 gate failure) → host startup **fails** with an **actionable** error (message includes file path and rule hint where practical). -- [ ] At success: log includes **skill count** and **catalog directory** (Information or Debug per choice above). -- [ ] No silent acceptance of bad catalog at runtime (matches E2.M1 acceptance). +- [x] Integration or host-level test: **valid** fixture directory → `WebApplicationFactory` (or equivalent) starts and catalog is resolvable from DI. +- [x] Integration or host-level test: **invalid** fixture (malformed JSON, schema violation, duplicate `id`, or Slice-1 gate failure) → host startup **fails** with an **actionable** error (message includes file path and rule hint where practical). +- [x] At success: log includes **skill count** and **catalog directory** (Information or Debug per choice above). +- [x] No silent acceptance of bad catalog at runtime (matches E2.M1 acceptance). ## Technical approach diff --git a/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresWebApplicationFactory.cs index b8a96b6..43f8466 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.Skills; namespace NeonSprawl.Server.Tests.Game.PositionState; @@ -17,6 +18,11 @@ public sealed class PostgresWebApplicationFactory : WebApplicationFactory { config.AddInMemoryCollection(new Dictionary diff --git a/server/NeonSprawl.Server.Tests/Game/Skills/SkillCatalogTestPaths.cs b/server/NeonSprawl.Server.Tests/Game/Skills/SkillCatalogTestPaths.cs new file mode 100644 index 0000000..336d1b4 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Skills/SkillCatalogTestPaths.cs @@ -0,0 +1,17 @@ +using NeonSprawl.Server.Game.Skills; + +namespace NeonSprawl.Server.Tests.Game.Skills; + +internal static class SkillCatalogTestPaths +{ + internal static string DiscoverRepoSkillsDirectory() => + SkillCatalogPathResolution.TryDiscoverSkillsDirectory(AppContext.BaseDirectory) + ?? throw new InvalidOperationException( + "Could not discover repo content/skills from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); + + internal static string DiscoverRepoSkillDefSchemaPath() => + SkillCatalogPathResolution.ResolveSkillDefSchemaPath( + DiscoverRepoSkillsDirectory(), + configuredSchemaPath: null, + contentRootPath: string.Empty); +} diff --git a/server/NeonSprawl.Server.Tests/Game/Skills/SkillDefinitionCatalogLoaderTests.cs b/server/NeonSprawl.Server.Tests/Game/Skills/SkillDefinitionCatalogLoaderTests.cs new file mode 100644 index 0000000..2deff89 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Skills/SkillDefinitionCatalogLoaderTests.cs @@ -0,0 +1,199 @@ +using System.Net; +using System.Text; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using NeonSprawl.Server.Tests; +using Microsoft.Extensions.Logging.Abstractions; +using NeonSprawl.Server.Game.Skills; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Skills; + +public class SkillDefinitionCatalogLoaderTests +{ + private const string ValidPrototypeCatalogJson = + """ + { + "schemaVersion": 1, + "skills": [ + { + "id": "salvage", + "category": "gather", + "displayName": "Salvage", + "allowedXpSourceKinds": ["activity", "mission_reward"] + }, + { + "id": "refine", + "category": "process", + "displayName": "Refine", + "allowedXpSourceKinds": ["activity", "mission_reward", "trainer"] + }, + { + "id": "intrusion", + "category": "tech", + "displayName": "Intrusion", + "allowedXpSourceKinds": ["activity", "mission_reward", "book_or_item"] + } + ] + } + """; + + private static (string Root, string SkillsDir, string SchemaPath) CreateTempContentLayout() + { + var root = Directory.CreateTempSubdirectory("neon-sprawl-skillcat-"); + var skillsDir = Path.Combine(root.FullName, "content", "skills"); + var schemaDir = Path.Combine(root.FullName, "content", "schemas"); + Directory.CreateDirectory(skillsDir); + Directory.CreateDirectory(schemaDir); + var schemaPath = Path.Combine(schemaDir, "skill-def.schema.json"); + File.Copy(SkillCatalogTestPaths.DiscoverRepoSkillDefSchemaPath(), schemaPath, overwrite: true); + return (root.FullName, skillsDir, schemaPath); + } + + [Fact] + public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract() + { + // Arrange + var (_, skillsDir, schemaPath) = CreateTempContentLayout(); + File.WriteAllText(Path.Combine(skillsDir, "prototype.json"), ValidPrototypeCatalogJson, Encoding.UTF8); + // Act + var catalog = SkillDefinitionCatalogLoader.Load(skillsDir, schemaPath, NullLogger.Instance); + // Assert + Assert.Equal(3, catalog.DistinctSkillCount); + Assert.Equal(1, catalog.CatalogJsonFileCount); + Assert.True(catalog.ById.ContainsKey("salvage")); + Assert.Equal("gather", catalog.ById["salvage"].Category); + } + + [Fact] + public void Load_ShouldThrow_WhenSkillsIsNotArray() + { + // Arrange + var (_, skillsDir, schemaPath) = CreateTempContentLayout(); + File.WriteAllText(Path.Combine(skillsDir, "bad.json"), """{"skills": "nope"}""", Encoding.UTF8); + // Act + var ex = Record.Exception(() => SkillDefinitionCatalogLoader.Load(skillsDir, schemaPath, NullLogger.Instance)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("bad.json", ioe.Message, StringComparison.Ordinal); + Assert.Contains("expected top-level 'skills' array", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenRowViolatesSchema() + { + // Arrange + var (_, skillsDir, schemaPath) = CreateTempContentLayout(); + var bad = """ + { + "skills": [ + { + "id": "salvage", + "category": "gather", + "displayName": "", + "allowedXpSourceKinds": ["activity"] + } + ] + } + """; + File.WriteAllText(Path.Combine(skillsDir, "bad.json"), bad, Encoding.UTF8); + // Act + var ex = Record.Exception(() => SkillDefinitionCatalogLoader.Load(skillsDir, schemaPath, NullLogger.Instance)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("bad.json", ioe.Message, StringComparison.Ordinal); + Assert.Contains("Skill catalog validation failed", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenDuplicateIdAcrossFiles() + { + // Arrange + var (_, skillsDir, schemaPath) = CreateTempContentLayout(); + var singleSalvage = """ + { + "skills": [ + { + "id": "salvage", + "category": "gather", + "displayName": "Salvage", + "allowedXpSourceKinds": ["activity"] + } + ] + } + """; + File.WriteAllText(Path.Combine(skillsDir, "a.json"), singleSalvage, Encoding.UTF8); + File.WriteAllText(Path.Combine(skillsDir, "b.json"), singleSalvage, Encoding.UTF8); + // Act + var ex = Record.Exception(() => SkillDefinitionCatalogLoader.Load(skillsDir, schemaPath, NullLogger.Instance)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("duplicate skill id", ioe.Message, StringComparison.Ordinal); + Assert.Contains("salvage", ioe.Message, StringComparison.Ordinal); + Assert.Contains("a.json", ioe.Message, StringComparison.Ordinal); + Assert.Contains("b.json", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenSlice1IdsIncomplete() + { + // Arrange + var (_, skillsDir, schemaPath) = CreateTempContentLayout(); + var twoOnly = """ + { + "skills": [ + { + "id": "salvage", + "category": "gather", + "displayName": "Salvage", + "allowedXpSourceKinds": ["activity"] + }, + { + "id": "refine", + "category": "process", + "displayName": "Refine", + "allowedXpSourceKinds": ["activity"] + } + ] + } + """; + File.WriteAllText(Path.Combine(skillsDir, "partial.json"), twoOnly, Encoding.UTF8); + // Act + var ex = Record.Exception(() => SkillDefinitionCatalogLoader.Load(skillsDir, schemaPath, NullLogger.Instance)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("prototype Slice 1 expects exactly skill ids", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task Host_ShouldResolveCatalogFromDi_WhenStartupSucceeds() + { + // Arrange — InMemoryWebApplicationFactory strips Postgres and pins Content:SkillsDirectory to the repo catalog. + await using var factory = new InMemoryWebApplicationFactory(); + using var client = factory.CreateClient(); + // Act + var response = await client.GetAsync("/health"); + var catalog = factory.Services.GetRequiredService(); + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(3, catalog.DistinctSkillCount); + } + + [Fact] + public void Host_ShouldFailStartup_WhenCatalogDirectoryInvalid() + { + // Arrange + var badDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-empty-skills-" + Guid.NewGuid().ToString("n")); + Directory.CreateDirectory(badDir); + // Act + var ex = Record.Exception(() => + { + using var factory = new WebApplicationFactory().WithWebHostBuilder(b => + b.UseSetting("Content:SkillsDirectory", badDir)); + factory.CreateClient(); + }); + // Assert + Assert.NotNull(ex); + Assert.Contains("Skill catalog validation failed", ex.ToString(), StringComparison.Ordinal); + } +} diff --git a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs index d93b96b..7395778 100644 --- a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Time.Testing; using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Game.Skills; using Npgsql; namespace NeonSprawl.Server.Tests; @@ -18,6 +19,11 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory { for (var i = services.Count - 1; i >= 0; i--) diff --git a/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs b/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs new file mode 100644 index 0000000..2df659e --- /dev/null +++ b/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs @@ -0,0 +1,19 @@ +namespace NeonSprawl.Server.Game.Skills; + +/// Configuration for loading authoring content from disk (NEO-34). +public sealed class ContentPathsOptions +{ + public const string SectionName = "Content"; + + /// + /// Optional. Absolute path, or path relative to . + /// When unset, the host walks ancestors of for a content/skills directory. + /// + public string? SkillsDirectory { get; set; } + + /// + /// Optional override for skill-def.schema.json. + /// When unset, resolved as {parent of skills directory}/schemas/skill-def.schema.json (same layout as the repo). + /// + public string? SkillDefSchemaPath { get; set; } +} diff --git a/server/NeonSprawl.Server/Game/Skills/PrototypeSlice1SkillCatalogRules.cs b/server/NeonSprawl.Server/Game/Skills/PrototypeSlice1SkillCatalogRules.cs new file mode 100644 index 0000000..5b450b7 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Skills/PrototypeSlice1SkillCatalogRules.cs @@ -0,0 +1,47 @@ +using System.Collections.Frozen; + +namespace NeonSprawl.Server.Game.Skills; + +/// +/// Prototype Slice 1 roster gate (NEO-33), mirrored from scripts/validate_content.py +/// PROTOTYPE_SLICE1_SKILL_IDS / _prototype_slice1_gate. +/// +public static class PrototypeSlice1SkillCatalogRules +{ + /// Keep in sync with scripts/validate_content.py PROTOTYPE_SLICE1_SKILL_IDS. + public static readonly FrozenSet ExpectedSkillIds = FrozenSet.ToFrozenSet( + ["salvage", "refine", "intrusion"], + StringComparer.Ordinal); + + /// Returns a human-readable error if the Slice 1 contract fails, otherwise . + public static string? TryGetSlice1GateError( + IReadOnlyDictionary skillIdToSourceFile, + IReadOnlyDictionary skillIdToCategory) + { + var ids = skillIdToSourceFile.Keys.ToFrozenSet(StringComparer.Ordinal); + if (!ids.SetEquals(ExpectedSkillIds)) + { + return + "error: prototype Slice 1 expects exactly skill ids " + + $"[{string.Join(", ", ExpectedSkillIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " + + $"got [{string.Join(", ", ids.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}]"; + } + + var cats = skillIdToCategory.Values.ToHashSet(StringComparer.Ordinal); + if (!cats.Contains("gather") || !cats.Contains("tech")) + { + return + "error: prototype Slice 1 requires at least one gather and one tech skill; " + + $"categories seen: [{string.Join(", ", cats.Order(StringComparer.Ordinal).Select(c => "'" + c + "'"))}]"; + } + + if (!cats.Contains("process") && !cats.Contains("make")) + { + return + "error: prototype Slice 1 requires at least one process or make skill; " + + $"categories seen: [{string.Join(", ", cats.Order(StringComparer.Ordinal).Select(c => "'" + c + "'"))}]"; + } + + return null; + } +} diff --git a/server/NeonSprawl.Server/Game/Skills/SkillCatalogPathResolution.cs b/server/NeonSprawl.Server/Game/Skills/SkillCatalogPathResolution.cs new file mode 100644 index 0000000..e40225c --- /dev/null +++ b/server/NeonSprawl.Server/Game/Skills/SkillCatalogPathResolution.cs @@ -0,0 +1,60 @@ +namespace NeonSprawl.Server.Game.Skills; + +/// Resolves catalog paths for local dev, tests, and container layouts (NEO-34). +public static class SkillCatalogPathResolution +{ + /// Walks and parents for an existing content/skills directory. + public static string? TryDiscoverSkillsDirectory(string startDirectory) + { + for (var dir = new DirectoryInfo(startDirectory); dir is not null; dir = dir.Parent) + { + var candidate = Path.Combine(dir.FullName, "content", "skills"); + if (Directory.Exists(candidate)) + return Path.GetFullPath(candidate); + } + + return null; + } + + /// + /// Resolves the skills catalog directory. + /// Empty triggers discovery from . + /// + public static string ResolveSkillsDirectory(string? configuredSkillsDirectory, string contentRootPath) + { + if (string.IsNullOrWhiteSpace(configuredSkillsDirectory)) + { + var discovered = TryDiscoverSkillsDirectory(AppContext.BaseDirectory); + if (discovered is not null) + return discovered; + + throw new InvalidOperationException( + "Content:SkillsDirectory is not set and auto-discovery failed (no ancestor of AppContext.BaseDirectory contains 'content/skills'). " + + "Set Content:SkillsDirectory in configuration or environment (e.g. Content__SkillsDirectory)."); + } + + var trimmed = configuredSkillsDirectory.Trim(); + if (Path.IsPathRooted(trimmed)) + return Path.GetFullPath(trimmed); + + return Path.GetFullPath(Path.Combine(contentRootPath, trimmed)); + } + + /// Resolves JSON Schema path for a single skill row (Draft 2020-12). + public static string ResolveSkillDefSchemaPath( + string skillsDirectory, + 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(skillsDirectory, "..", "schemas", "skill-def.schema.json")); + } +} diff --git a/server/NeonSprawl.Server/Game/Skills/SkillCatalogServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Skills/SkillCatalogServiceCollectionExtensions.cs new file mode 100644 index 0000000..19a187c --- /dev/null +++ b/server/NeonSprawl.Server/Game/Skills/SkillCatalogServiceCollectionExtensions.cs @@ -0,0 +1,34 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; + +namespace NeonSprawl.Server.Game.Skills; + +/// DI registration for the fail-fast skill catalog (NEO-34). +public static class SkillCatalogServiceCollectionExtensions +{ + /// Binds and registers as a singleton. + public static IServiceCollection AddSkillDefinitionCatalog(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.Skills.SkillCatalog"); + + var skillsDir = SkillCatalogPathResolution.ResolveSkillsDirectory(opts.SkillsDirectory, hostEnv.ContentRootPath); + var schemaPath = SkillCatalogPathResolution.ResolveSkillDefSchemaPath( + skillsDir, + opts.SkillDefSchemaPath, + hostEnv.ContentRootPath); + + return SkillDefinitionCatalogLoader.Load(skillsDir, schemaPath, logger); + }); + + return services; + } +} diff --git a/server/NeonSprawl.Server/Game/Skills/SkillDefRow.cs b/server/NeonSprawl.Server/Game/Skills/SkillDefRow.cs new file mode 100644 index 0000000..1324513 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Skills/SkillDefRow.cs @@ -0,0 +1,8 @@ +namespace NeonSprawl.Server.Game.Skills; + +/// One validated SkillDef row from content/skills/*.json (NEO-34). +public sealed record SkillDefRow( + string Id, + string Category, + string DisplayName, + IReadOnlyList AllowedXpSourceKinds); diff --git a/server/NeonSprawl.Server/Game/Skills/SkillDefinitionCatalog.cs b/server/NeonSprawl.Server/Game/Skills/SkillDefinitionCatalog.cs new file mode 100644 index 0000000..afbac8a --- /dev/null +++ b/server/NeonSprawl.Server/Game/Skills/SkillDefinitionCatalog.cs @@ -0,0 +1,27 @@ +using System.Collections.ObjectModel; + +namespace NeonSprawl.Server.Game.Skills; + +/// In-memory skill catalog loaded at startup (NEO-34). NEO-35 may wrap with lookup services. +public sealed class SkillDefinitionCatalog +{ + public SkillDefinitionCatalog( + string skillsDirectory, + IReadOnlyDictionary byId, + int catalogJsonFileCount) + { + SkillsDirectory = skillsDirectory; + ById = new ReadOnlyDictionary(new Dictionary(byId, StringComparer.Ordinal)); + CatalogJsonFileCount = catalogJsonFileCount; + } + + /// Absolute path to the directory that was enumerated for *.json catalogs. + public string SkillsDirectory { get; } + + public IReadOnlyDictionary ById { get; } + + public int DistinctSkillCount => ById.Count; + + /// Number of *.json files under . + public int CatalogJsonFileCount { get; } +} diff --git a/server/NeonSprawl.Server/Game/Skills/SkillDefinitionCatalogLoader.cs b/server/NeonSprawl.Server/Game/Skills/SkillDefinitionCatalogLoader.cs new file mode 100644 index 0000000..bec5e07 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Skills/SkillDefinitionCatalogLoader.cs @@ -0,0 +1,181 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Json.Schema; +using Microsoft.Extensions.Logging; + +namespace NeonSprawl.Server.Game.Skills; + +/// Loads and validates content/skills/*.json using the same rules as scripts/validate_content.py (NEO-34). +public static class SkillDefinitionCatalogLoader +{ + /// Loads catalogs from disk or throws with actionable messages. + public static SkillDefinitionCatalog Load(string skillsDirectory, string schemaPath, ILogger logger) + { + skillsDirectory = Path.GetFullPath(skillsDirectory); + schemaPath = Path.GetFullPath(schemaPath); + + var errors = new List(); + + if (!File.Exists(schemaPath)) + errors.Add($"error: missing schema file {schemaPath}"); + + if (!Directory.Exists(skillsDirectory)) + errors.Add($"error: missing directory {skillsDirectory}"); + + if (errors.Count > 0) + ThrowIfAny(errors); + + var jsonFiles = Directory.GetFiles(skillsDirectory, "*.json", SearchOption.TopDirectoryOnly) + .OrderBy(p => p, StringComparer.Ordinal) + .ToArray(); + if (jsonFiles.Length == 0) + errors.Add($"error: no JSON files under {skillsDirectory}"); + + if (errors.Count > 0) + ThrowIfAny(errors); + + var schema = JsonSchema.FromText(File.ReadAllText(schemaPath)); + var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List }; + + var skillIdToSourceFile = new Dictionary(StringComparer.Ordinal); + var skillIdToCategory = 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; + } + + var skillsNode = root?["skills"]; + if (skillsNode is not JsonArray skillsArray) + { + errors.Add($"error: {path}: expected top-level 'skills' array"); + continue; + } + + for (var i = 0; i < skillsArray.Count; i++) + { + var item = skillsArray[i]; + if (item is not JsonObject rowObj) + { + errors.Add($"error: {path}: skills[{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} skills[{i}] (root): schema validation failed"); + + errors.AddRange(schemaMsgs); + } + + var rowSchemaErrors = schemaMsgs.Count; + + var sid = (rowObj["id"] as JsonValue)?.GetValue(); + if (sid is not null && rowSchemaErrors == 0) + { + if (skillIdToSourceFile.TryGetValue(sid, out var prevPath)) + { + errors.Add($"error: duplicate skill id '{sid}' in {prevPath} and {path}"); + continue; + } + + skillIdToSourceFile[sid] = path; + + var category = (rowObj["category"] as JsonValue)?.GetValue(); + if (category is not null) + skillIdToCategory[sid] = category; + + rows[sid] = ParseRow(rowObj); + } + } + } + + ThrowIfAny(errors); + + var slice1 = PrototypeSlice1SkillCatalogRules.TryGetSlice1GateError(skillIdToSourceFile, skillIdToCategory); + if (slice1 is not null) + { + errors.Add(slice1); + ThrowIfAny(errors); + } + + logger.LogInformation( + "Loaded skill catalog from {SkillsDirectory}: {SkillCount} skill(s) across {CatalogFileCount} JSON catalog file(s).", + skillsDirectory, + rows.Count, + jsonFiles.Length); + + return new SkillDefinitionCatalog(skillsDirectory, rows, jsonFiles.Length); + } + + private static SkillDefRow ParseRow(JsonObject rowObj) + { + var id = (rowObj["id"] as JsonValue)!.GetValue(); + var category = (rowObj["category"] as JsonValue)!.GetValue(); + var displayName = (rowObj["displayName"] as JsonValue)!.GetValue(); + var kindsArray = rowObj["allowedXpSourceKinds"] as JsonArray + ?? throw new InvalidOperationException("allowedXpSourceKinds missing"); + var kinds = new List(); + foreach (var n in kindsArray) + { + if (n is JsonValue v) + kinds.Add(v.GetValue()); + } + + return new SkillDefRow(id, category, displayName, kinds); + } + + 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} skills[{index}] {loc}: {kv.Key} — {kv.Value}"); + } + } + + private static void ThrowIfAny(List errors) + { + if (errors.Count == 0) + return; + + var sb = new StringBuilder(); + sb.AppendLine("Skill 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/NeonSprawl.Server.csproj b/server/NeonSprawl.Server/NeonSprawl.Server.csproj index 73ca39d..51be18c 100644 --- a/server/NeonSprawl.Server/NeonSprawl.Server.csproj +++ b/server/NeonSprawl.Server/NeonSprawl.Server.csproj @@ -19,6 +19,7 @@ + diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index 70ddda9..3a645f9 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -1,6 +1,7 @@ using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.Interaction; using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Game.Targeting; var builder = WebApplication.CreateBuilder(args); @@ -8,8 +9,10 @@ builder.Services.AddPositionStateStore(builder.Configuration); builder.Services.AddHotbarLoadoutStore(builder.Configuration); builder.Services.AddAbilityCooldownStore(); builder.Services.AddSingleton(); +builder.Services.AddSkillDefinitionCatalog(builder.Configuration); var app = builder.Build(); +_ = app.Services.GetRequiredService(); app.MapGet("/", () => Results.Text( "Neon Sprawl game server — GET /health for JSON status.", diff --git a/server/README.md b/server/README.md index a23eb52..787decb 100644 --- a/server/README.md +++ b/server/README.md @@ -24,6 +24,19 @@ NEON_SPRAWL_API_LOG=1 dotnet run --project server/NeonSprawl.Server/NeonSprawl.S IDE: use launch profile **`http+apiLog`** (same URL as **`http`**, sets the variable). +## Skill catalog (`content/skills`, NEO-34) + +On startup the host loads every **`*.json`** under the skills directory, validates each row against **`content/schemas/skill-def.schema.json`**, rejects **duplicate `id`** values across files, and enforces the **prototype Slice 1** 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:SkillsDirectory`** | Optional. Absolute path, or path relative to the server **content root** (the project directory for `dotnet run`). When unset, the host walks **ancestors of `AppContext.BaseDirectory`** until it finds a **`content/skills`** directory (works for `dotnet run` from `server/NeonSprawl.Server` without extra config). | +| **`Content:SkillDefSchemaPath`** | Optional override for **`skill-def.schema.json`**. When unset, resolved as **`{parent of skills directory}/schemas/skill-def.schema.json`** (repo layout: `content/schemas` next to `content/skills`). | + +**Docker / CI:** mount or copy the repo **`content/`** tree (at least **`content/skills`** and **`content/schemas`**) and set **`Content__SkillsDirectory`** / **`Content__SkillDefSchemaPath`** if the layout differs from the default discovery rule. + +On success, **Information** logs include the resolved skills directory path and distinct skill count. + ## Position persistence (NEO-8) When **`ConnectionStrings:NeonSprawl`** is set to a valid PostgreSQL connection string, the server uses the **`player_position`** table (relational columns `pos_x` … `sequence`, not JSONB). DDL lives in [`server/db/migrations/V001__player_position.sql`](../db/migrations/V001__player_position.sql); the app applies that script on startup and on first store use (idempotent `CREATE TABLE IF NOT EXISTS`). The configured dev player (`Game:DevPlayerId` / `Game:DefaultPosition`) is seeded once at host startup, matching the in-memory store’s constructor seed (not on every HTTP request).