NEO-35: add ISkillDefinitionRegistry and lookup tests
parent
c2ca511a35
commit
fb863a457b
|
|
@ -32,10 +32,10 @@
|
||||||
|
|
||||||
## Acceptance criteria checklist
|
## Acceptance criteria checklist
|
||||||
|
|
||||||
- [ ] Tests: **happy path** — known prototype `id` (e.g. from existing test fixtures / `salvage` trio) resolves with expected metadata.
|
- [x] 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**.
|
- [x] 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).
|
- [x] 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] **`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
|
## Technical approach
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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<string, SkillDefRow> 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<string, SkillDefRow>(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<string, SkillDefRow>(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<string, SkillDefRow>(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<ISkillDefinitionRegistry>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Game.Skills;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read-only access to validated <see cref="SkillDefRow"/> entries loaded at startup (<see cref="SkillDefinitionCatalog"/>).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para><b>E2.M2 (XP grants):</b> callers issuing skill XP must ensure <c>XpGrantEvent.sourceKind</c> (or successor)
|
||||||
|
/// is listed on the target skill’s <see cref="SkillDefRow.AllowedXpSourceKinds"/>; the award engine should reject
|
||||||
|
/// grants outside that set once implemented.</para>
|
||||||
|
/// <para><b>NEO-36:</b> HTTP/read-model projections should depend on this interface rather than reaching into the catalog.</para>
|
||||||
|
/// </remarks>
|
||||||
|
public interface ISkillDefinitionRegistry
|
||||||
|
{
|
||||||
|
/// <summary>Attempts to resolve a skill by stable <c>id</c> (see <c>skill-def.schema.json</c>). Misses do not throw.</summary>
|
||||||
|
bool TryGetDefinition(string skillId, [NotNullWhen(true)] out SkillDefRow? definition);
|
||||||
|
|
||||||
|
/// <summary>Every loaded definition, ordered by <see cref="SkillDefRow.Id"/> (ordinal).</summary>
|
||||||
|
IReadOnlyList<SkillDefRow> GetDefinitionsInIdOrder();
|
||||||
|
}
|
||||||
|
|
@ -7,7 +7,7 @@ namespace NeonSprawl.Server.Game.Skills;
|
||||||
/// <summary>DI registration for the fail-fast skill catalog (NEO-34).</summary>
|
/// <summary>DI registration for the fail-fast skill catalog (NEO-34).</summary>
|
||||||
public static class SkillCatalogServiceCollectionExtensions
|
public static class SkillCatalogServiceCollectionExtensions
|
||||||
{
|
{
|
||||||
/// <summary>Binds <see cref="ContentPathsOptions"/> and registers <see cref="SkillDefinitionCatalog"/> as a singleton.</summary>
|
/// <summary>Binds <see cref="ContentPathsOptions"/> and registers <see cref="SkillDefinitionCatalog"/> and <see cref="ISkillDefinitionRegistry"/> as singletons.</summary>
|
||||||
public static IServiceCollection AddSkillDefinitionCatalog(this IServiceCollection services, IConfiguration configuration)
|
public static IServiceCollection AddSkillDefinitionCatalog(this IServiceCollection services, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
services.AddOptions<ContentPathsOptions>()
|
services.AddOptions<ContentPathsOptions>()
|
||||||
|
|
@ -29,6 +29,9 @@ public static class SkillCatalogServiceCollectionExtensions
|
||||||
return SkillDefinitionCatalogLoader.Load(skillsDir, schemaPath, logger);
|
return SkillDefinitionCatalogLoader.Load(skillsDir, schemaPath, logger);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
services.AddSingleton<ISkillDefinitionRegistry>(sp =>
|
||||||
|
new SkillDefinitionRegistry(sp.GetRequiredService<SkillDefinitionCatalog>()));
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ using System.Collections.ObjectModel;
|
||||||
|
|
||||||
namespace NeonSprawl.Server.Game.Skills;
|
namespace NeonSprawl.Server.Game.Skills;
|
||||||
|
|
||||||
/// <summary>In-memory skill catalog loaded at startup (NEO-34). NEO-35 may wrap with lookup services.</summary>
|
/// <summary>In-memory skill catalog loaded at startup (NEO-34). Game code should prefer <see cref="ISkillDefinitionRegistry"/> for lookups (NEO-35).</summary>
|
||||||
public sealed class SkillDefinitionCatalog
|
public sealed class SkillDefinitionCatalog
|
||||||
{
|
{
|
||||||
public SkillDefinitionCatalog(
|
public SkillDefinitionCatalog(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Game.Skills;
|
||||||
|
|
||||||
|
/// <summary>Adapter over <see cref="SkillDefinitionCatalog"/> (NEO-35).</summary>
|
||||||
|
public sealed class SkillDefinitionRegistry(SkillDefinitionCatalog catalog) : ISkillDefinitionRegistry
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyList<SkillDefRow> GetDefinitionsInIdOrder()
|
||||||
|
{
|
||||||
|
var ids = catalog.ById.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray();
|
||||||
|
var list = new List<SkillDefRow>(ids.Length);
|
||||||
|
foreach (var id in ids)
|
||||||
|
{
|
||||||
|
list.Add(catalog.ById[id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue