diff --git a/docs/plans/NEO-35-implementation-plan.md b/docs/plans/NEO-35-implementation-plan.md index 9ef4dd4..476c17c 100644 --- a/docs/plans/NEO-35-implementation-plan.md +++ b/docs/plans/NEO-35-implementation-plan.md @@ -32,10 +32,10 @@ ## Acceptance criteria checklist -- [ ] Tests: **happy path** — known prototype `id` (e.g. from existing test fixtures / `salvage` trio) resolves with expected metadata. -- [ ] Tests: **unknown id** — lookup indicates absence / `Try*` returns false **without throwing**. -- [ ] Tests: **`allowedXpSourceKinds`** — at least one assertion on a known skill’s list (per E2.M1 contract). -- [ ] **`ISkillDefinitionRegistry`** (or chosen public interface name) is **registered in DI** alongside the catalog so **NEO-36** can inject it without reaching for `SkillDefinitionCatalog` internals. +- [x] Tests: **happy path** — known prototype `id` (e.g. from existing test fixtures / `salvage` trio) resolves with expected metadata. +- [x] Tests: **unknown id** — lookup indicates absence / `Try*` returns false **without throwing**. +- [x] Tests: **`allowedXpSourceKinds`** — at least one assertion on a known skill’s list (per E2.M1 contract). +- [x] **`ISkillDefinitionRegistry`** (or chosen public interface name) is **registered in DI** alongside the catalog so **NEO-36** can inject it without reaching for `SkillDefinitionCatalog` internals. ## Technical approach diff --git a/server/NeonSprawl.Server.Tests/Game/Skills/SkillDefinitionRegistryTests.cs b/server/NeonSprawl.Server.Tests/Game/Skills/SkillDefinitionRegistryTests.cs new file mode 100644 index 0000000..6d20643 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Skills/SkillDefinitionRegistryTests.cs @@ -0,0 +1,157 @@ +using System.Net; +using System.Text; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NeonSprawl.Server.Game.Skills; +using NeonSprawl.Server.Tests; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Skills; + +public class SkillDefinitionRegistryTests +{ + private static SkillDefinitionRegistry CreateRegistryFromRows(IReadOnlyDictionary byId) + { + var catalog = new SkillDefinitionCatalog("/tmp/catalog", byId, catalogJsonFileCount: 1); + return new SkillDefinitionRegistry(catalog); + } + + [Fact] + public void TryGetDefinition_ShouldReturnTrueAndExpectedXpKinds_WhenIdExists() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + ["intrusion"] = new SkillDefRow( + "intrusion", + "tech", + "Intrusion", + new[] { "activity", "mission_reward", "book_or_item" }), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryGetDefinition("intrusion", out var def); + // Assert + Assert.True(found); + Assert.NotNull(def); + Assert.Equal("tech", def.Category); + Assert.Equal("Intrusion", def.DisplayName); + Assert.Contains("activity", def.AllowedXpSourceKinds, StringComparer.Ordinal); + Assert.Contains("book_or_item", def.AllowedXpSourceKinds, StringComparer.Ordinal); + } + + [Fact] + public void TryGetDefinition_ShouldReturnFalse_WhenIdUnknown() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + ["salvage"] = new SkillDefRow("salvage", "gather", "Salvage", new[] { "activity" }), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryGetDefinition("not_a_real_skill", out var def); + // Assert + Assert.False(found); + Assert.Null(def); + } + + [Fact] + public void GetDefinitionsInIdOrder_ShouldListAllRowsOrderedById_WhenMultipleSkills() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + ["salvage"] = new SkillDefRow("salvage", "gather", "Salvage", new[] { "activity", "mission_reward" }), + ["refine"] = new SkillDefRow("refine", "process", "Refine", new[] { "activity" }), + ["intrusion"] = new SkillDefRow("intrusion", "tech", "Intrusion", new[] { "trainer" }), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var list = registry.GetDefinitionsInIdOrder(); + // Assert + Assert.Equal(3, list.Count); + Assert.Equal("intrusion", list[0].Id); + Assert.Equal("refine", list[1].Id); + Assert.Equal("salvage", list[2].Id); + } + + [Fact] + public void TryGetDefinition_ShouldMatchLoaderCatalog_WhenUsingPrototypeFixture() + { + // Arrange + var root = Directory.CreateTempSubdirectory("neon-sprawl-registry-loader-"); + try + { + 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); + var json = """ + { + "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"] + } + ] + } + """; + File.WriteAllText(Path.Combine(skillsDir, "prototype.json"), json, Encoding.UTF8); + var loaded = SkillDefinitionCatalogLoader.Load(skillsDir, schemaPath, NullLogger.Instance); + var registry = new SkillDefinitionRegistry(loaded); + // Act + var ok = registry.TryGetDefinition("refine", out var refine); + // Assert + Assert.True(ok); + Assert.NotNull(refine); + Assert.Equal("process", refine.Category); + Assert.Contains("trainer", refine.AllowedXpSourceKinds, StringComparer.Ordinal); + } + finally + { + try + { + Directory.Delete(root.FullName, recursive: true); + } + catch + { + // best-effort cleanup for temp dir + } + } + } + + [Fact] + public async Task Host_ShouldResolveRegistryFromDi_WhenStartupSucceeds() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + using var client = factory.CreateClient(); + // Act + var response = await client.GetAsync("/health"); + var registry = factory.Services.GetRequiredService(); + var found = registry.TryGetDefinition("salvage", out var salvage); + var status = response.StatusCode; + // Assert + Assert.Equal(HttpStatusCode.OK, status); + Assert.True(found); + Assert.NotNull(salvage); + Assert.Contains("activity", salvage.AllowedXpSourceKinds, StringComparer.Ordinal); + } +} diff --git a/server/NeonSprawl.Server/Game/Skills/ISkillDefinitionRegistry.cs b/server/NeonSprawl.Server/Game/Skills/ISkillDefinitionRegistry.cs new file mode 100644 index 0000000..ef7cfb8 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Skills/ISkillDefinitionRegistry.cs @@ -0,0 +1,21 @@ +using System.Diagnostics.CodeAnalysis; + +namespace NeonSprawl.Server.Game.Skills; + +/// +/// Read-only access to validated entries loaded at startup (). +/// +/// +/// E2.M2 (XP grants): callers issuing skill XP must ensure XpGrantEvent.sourceKind (or successor) +/// is listed on the target skill’s ; the award engine should reject +/// grants outside that set once implemented. +/// NEO-36: HTTP/read-model projections should depend on this interface rather than reaching into the catalog. +/// +public interface ISkillDefinitionRegistry +{ + /// Attempts to resolve a skill by stable id (see skill-def.schema.json). Misses do not throw. + bool TryGetDefinition(string skillId, [NotNullWhen(true)] out SkillDefRow? definition); + + /// Every loaded definition, ordered by (ordinal). + IReadOnlyList GetDefinitionsInIdOrder(); +} diff --git a/server/NeonSprawl.Server/Game/Skills/SkillCatalogServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Skills/SkillCatalogServiceCollectionExtensions.cs index 19a187c..dcf4d15 100644 --- a/server/NeonSprawl.Server/Game/Skills/SkillCatalogServiceCollectionExtensions.cs +++ b/server/NeonSprawl.Server/Game/Skills/SkillCatalogServiceCollectionExtensions.cs @@ -7,7 +7,7 @@ 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. + /// Binds and registers and as singletons. public static IServiceCollection AddSkillDefinitionCatalog(this IServiceCollection services, IConfiguration configuration) { services.AddOptions() @@ -29,6 +29,9 @@ public static class SkillCatalogServiceCollectionExtensions return SkillDefinitionCatalogLoader.Load(skillsDir, schemaPath, logger); }); + services.AddSingleton(sp => + new SkillDefinitionRegistry(sp.GetRequiredService())); + return services; } } diff --git a/server/NeonSprawl.Server/Game/Skills/SkillDefinitionCatalog.cs b/server/NeonSprawl.Server/Game/Skills/SkillDefinitionCatalog.cs index afbac8a..21b6e1f 100644 --- a/server/NeonSprawl.Server/Game/Skills/SkillDefinitionCatalog.cs +++ b/server/NeonSprawl.Server/Game/Skills/SkillDefinitionCatalog.cs @@ -2,7 +2,7 @@ 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. +/// In-memory skill catalog loaded at startup (NEO-34). Game code should prefer for lookups (NEO-35). public sealed class SkillDefinitionCatalog { public SkillDefinitionCatalog( diff --git a/server/NeonSprawl.Server/Game/Skills/SkillDefinitionRegistry.cs b/server/NeonSprawl.Server/Game/Skills/SkillDefinitionRegistry.cs new file mode 100644 index 0000000..d929af0 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Skills/SkillDefinitionRegistry.cs @@ -0,0 +1,33 @@ +using System.Diagnostics.CodeAnalysis; + +namespace NeonSprawl.Server.Game.Skills; + +/// Adapter over (NEO-35). +public sealed class SkillDefinitionRegistry(SkillDefinitionCatalog catalog) : ISkillDefinitionRegistry +{ + /// + public bool TryGetDefinition(string skillId, [NotNullWhen(true)] out SkillDefRow? definition) + { + if (catalog.ById.TryGetValue(skillId, out var row)) + { + definition = row; + return true; + } + + definition = null; + return false; + } + + /// + public IReadOnlyList GetDefinitionsInIdOrder() + { + var ids = catalog.ById.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray(); + var list = new List(ids.Length); + foreach (var id in ids) + { + list.Add(catalog.ById[id]); + } + + return list; + } +}