NEO-52: add IItemDefinitionRegistry with DI and lookup tests

Thin adapter over ItemDefinitionCatalog mirroring NEO-35 skill registry;
registers singleton in AddItemDefinitionCatalog with TryGetDefinition,
GetDefinitionsInIdOrder, and AAA unit + host DI tests.
pull/87/head
VinPropane 2026-05-23 18:11:33 -04:00
parent 1e9f1e9a3a
commit 792b38d8a9
8 changed files with 265 additions and 8 deletions

View File

@ -54,7 +54,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not
| E2.M3 | In Progress | **NEO-45 landed:** prototype **`salvage`** mastery catalog + CI gates (see [NEO-45 plan](../../plans/NEO-45-implementation-plan.md)). **NEO-46 landed:** fail-fast server load under `server/NeonSprawl.Server/Game/Mastery/``MasteryCatalogLoader`, `IMasteryCatalogRegistry`, cross-check vs `ISkillDefinitionRegistry`, Slice 4 + **`tierIndex`** 1..N gate; see [NEO-46 plan](../../plans/NEO-46-implementation-plan.md). **NEO-47 landed:** `PerkUnlockEngine`, `IPlayerPerkStateStore` + **`V004`**, level-up hook in skill XP grants; see [NEO-47 plan](../../plans/NEO-47-implementation-plan.md). **NEO-49 landed:** comment-only **`perk_unlock`** telemetry hook site in [`PerkUnlockEngine.TryUnlockPerks`](../../../server/NeonSprawl.Server/Game/Mastery/PerkUnlockEngine.cs) ([NEO-49 plan](../../plans/NEO-49-implementation-plan.md), [`NEO-49` manual QA](../../manual-qa/NEO-49.md)); [server README — Perk unlock telemetry (NEO-49)](../../../server/README.md#perk-unlock-engine-and-telemetry-hooks-neo-47-neo-49). **NEO-48 landed:** **`GET`/`POST /game/players/{id}/perk-state`** — `PerkStateApi` + DTOs in `Game/Mastery/` ([NEO-48](../../plans/NEO-48-implementation-plan.md), [`NEO-48` manual QA](../../manual-qa/NEO-48.md)); [server README — Perk state (NEO-48)](../../../server/README.md#perk-state-neo-48); Bruno `bruno/neon-sprawl-server/perk-state/`. | [NEO-45](../../plans/NEO-45-implementation-plan.md), [NEO-46](../../plans/NEO-46-implementation-plan.md), [NEO-47](../../plans/NEO-47-implementation-plan.md), [NEO-48](../../plans/NEO-48-implementation-plan.md), [NEO-49](../../plans/NEO-49-implementation-plan.md), [E2M3-pre-production-backlog](../../plans/E2M3-pre-production-backlog.md), [E2_M3](E2_M3_MasteryAndPerkUnlocks.md) |
| E2.M2 | In Progress | **NEO-37 landed:** versioned **`GET /game/players/{id}/skill-progression`** ([NEO-37](../../plans/NEO-37-implementation-plan.md)) — read model for every registered skill; known-player gate via `IPositionStateStore`; [server README — Skill progression snapshot (NEO-37)](../../../server/README.md#skill-progression-snapshot-neo-37); manual QA [`NEO-37`](../../manual-qa/NEO-37.md). **NEO-38 landed:** **`POST`** same path — grant apply, persistence (`IPlayerSkillProgressionStore`, `V003` migration), structured denies + **`levelUps`** ([NEO-38](../../plans/NEO-38-implementation-plan.md)); manual QA [`NEO-38`](../../manual-qa/NEO-38.md); Bruno `bruno/neon-sprawl-server/skill-progression/`; [server README — Skill progression grant (NEO-38)](../../../server/README.md#skill-progression-grant-neo-38). **NEO-39 landed:** data-driven `LevelCurve` content + schema + CI validation (`*_level_curve.json`) with fail-fast startup schema checks; progression GET/POST level resolution now uses `ISkillLevelCurve` content-backed thresholds ([NEO-39](../../plans/NEO-39-implementation-plan.md)); manual QA [`NEO-39`](../../manual-qa/NEO-39.md). **NEO-40 landed:** comment-only telemetry hook sites in [`SkillProgressionSnapshotApi.cs`](../../../server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotApi.cs) on **`POST …/skill-progression`** for future **`xp_grant`** / **`level_up`** ([NEO-40](../../plans/NEO-40-implementation-plan.md), [`NEO-40` manual QA](../../manual-qa/NEO-40.md)). **NEO-41 landed:** gather prototype — **`POST …/interact`** with **`kind: resource_node`** grants **`salvage`** + **`activity`** (10 XP) via [`SkillProgressionGrantOperations`](../../../server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantOperations.cs) ([NEO-41](../../plans/NEO-41-implementation-plan.md), [`NEO-41` manual QA](../../manual-qa/NEO-41.md)); [server README — Interaction](../../../server/README.md#interaction-neo-9). **NEO-42 landed (prep):** **`RefineActivitySkillXpGrant`** / **`RefineSkillXpConstants`** + deny-registry factory + tests — same NEO-38 grant stack; **E3.M2** must invoke on craft/refine success ([NEO-42](../../plans/NEO-42-implementation-plan.md), [`NEO-42` manual QA](../../manual-qa/NEO-42.md)); [server README — Craft / refine hook (NEO-42)](../../../server/README.md#craft--refine-hook--skill-xp-neo-42). **NEO-43 landed (prep):** **`MissionRewardSkillXpGrant`** / **`MissionRewardSkillXpConstants`** + deny-registry factory + tests — same NEO-38 grant stack with fixed **`sourceKind: mission_reward`**; **E7.M2** must invoke from quest hand-in ([NEO-43](../../plans/NEO-43-implementation-plan.md), [`NEO-43` manual QA](../../manual-qa/NEO-43.md)); [server README — Mission / quest reward (NEO-43)](../../../server/README.md#mission--quest-reward--skill-xp-neo-43). **Slice 3 still open:** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44). See [epic_02 — E2.M2 + Slice 3](../epics/epic_02_skills_and_progression.md). | [NEO-37](../../plans/NEO-37-implementation-plan.md), [NEO-38](../../plans/NEO-38-implementation-plan.md), [NEO-39](../../plans/NEO-39-implementation-plan.md), [NEO-40](../../plans/NEO-40-implementation-plan.md), [NEO-41](../../plans/NEO-41-implementation-plan.md), [NEO-42](../../plans/NEO-42-implementation-plan.md), [NEO-43](../../plans/NEO-43-implementation-plan.md), [E2_M2](E2_M2_XpAwardAndLevelEngine.md); label **`E2.M2`** on NEO-37NEO-41, NEO-42NEO-44 |
| E3.M1 | In Progress | **NEO-41 landed (prototype):** `POST …/interact` success on **`resource_node`** (`prototype_resource_node_alpha`) applies **`salvage`** skill XP (**`sourceKind: activity`**, 10 XP) via shared NEO-38 grant operations. **Still planned:** `GatherResult`, yields, inventory per [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md). | [NEO-41](../../plans/NEO-41-implementation-plan.md), [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md); `server/NeonSprawl.Server/Game/Interaction/`, `Game/Skills/` |
| E3.M3 | In Progress | **NEO-50 landed:** frozen prototype six-item catalog in [`content/items/prototype_items.json`](../../../content/items/prototype_items.json); [`item-def.schema.json`](../../../content/schemas/item-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py). **NEO-51 landed:** fail-fast server load of `content/items/*_items.json` at startup — `server/NeonSprawl.Server/Game/Items/` ([NEO-51](../../plans/NEO-51-implementation-plan.md)); [server README — Item catalog](../../../server/README.md#item-catalog-contentitems-neo-51). **Still planned:** `IItemDefinitionRegistry` (NEO-52+), inventory store, HTTP. | [NEO-50](../../plans/NEO-50-implementation-plan.md), [NEO-51](../../plans/NEO-51-implementation-plan.md), [E3M3-prototype-backlog](../../plans/E3M3-prototype-backlog.md), [E3_M3](E3_M3_ItemizationAndInventorySchema.md) |
| E3.M3 | In Progress | **NEO-50 landed:** frozen prototype six-item catalog in [`content/items/prototype_items.json`](../../../content/items/prototype_items.json); [`item-def.schema.json`](../../../content/schemas/item-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py). **NEO-51 landed:** fail-fast server load of `content/items/*_items.json` at startup — `server/NeonSprawl.Server/Game/Items/` ([NEO-51](../../plans/NEO-51-implementation-plan.md)); [server README — Item catalog](../../../server/README.md#item-catalog-contentitems-neo-51). **NEO-52 landed:** injectable **`IItemDefinitionRegistry`** + lookup tests ([NEO-52](../../plans/NEO-52-implementation-plan.md)). **Still planned:** inventory store, HTTP. | [NEO-50](../../plans/NEO-50-implementation-plan.md), [NEO-51](../../plans/NEO-51-implementation-plan.md), [NEO-52](../../plans/NEO-52-implementation-plan.md), [E3M3-prototype-backlog](../../plans/E3M3-prototype-backlog.md), [E3_M3](E3_M3_ItemizationAndInventorySchema.md) |
---

View File

@ -39,10 +39,10 @@
## Acceptance criteria checklist
- [ ] DI resolves `IItemDefinitionRegistry` at host startup (test via `InMemoryWebApplicationFactory`).
- [ ] Unit tests (AAA): lookup for known prototype `itemId` (e.g. `scrap_metal_bulk`, `prototype_armor_shell`) with expected metadata.
- [ ] Unit tests (AAA): unknown `itemId` returns false / absent without throwing.
- [ ] `GetDefinitionsInIdOrder` returns all loaded rows ordered by `id` (ordinal).
- [x] DI resolves `IItemDefinitionRegistry` at host startup (test via `InMemoryWebApplicationFactory`).
- [x] Unit tests (AAA): lookup for known prototype `itemId` (e.g. `scrap_metal_bulk`, `prototype_armor_shell`) with expected metadata.
- [x] Unit tests (AAA): unknown `itemId` returns false / absent without throwing.
- [x] `GetDefinitionsInIdOrder` returns all loaded rows ordered by `id` (ordinal).
## Technical approach

View File

@ -0,0 +1,195 @@
using System.IO;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Items;
public class ItemDefinitionRegistryTests
{
private static ItemDefinitionRegistry CreateRegistryFromRows(IReadOnlyDictionary<string, ItemDefRow> byId)
{
var catalog = new ItemDefinitionCatalog("/tmp/catalog", byId, catalogJsonFileCount: 1);
return new ItemDefinitionRegistry(catalog);
}
[Fact]
public void TryGetDefinition_ShouldReturnTrueAndExpectedMetadata_WhenIdExists()
{
// Arrange
var rows = new Dictionary<string, ItemDefRow>(StringComparer.Ordinal)
{
["scrap_metal_bulk"] = new ItemDefRow(
"scrap_metal_bulk",
"Scrap Metal (Bulk)",
"material",
StackMax: 999,
InventorySlotKind: "bag",
Rarity: null,
BindPolicy: null,
DurabilityMax: null),
};
var registry = CreateRegistryFromRows(rows);
// Act
var found = registry.TryGetDefinition("scrap_metal_bulk", out var def);
// Assert
Assert.True(found);
Assert.NotNull(def);
Assert.Equal("material", def.PrototypeRole);
Assert.Equal("Scrap Metal (Bulk)", def.DisplayName);
Assert.Equal(999, def.StackMax);
Assert.Equal("bag", def.InventorySlotKind);
}
[Fact]
public void TryGetDefinition_ShouldReturnFalse_WhenItemIdIsNull()
{
// Arrange
var rows = new Dictionary<string, ItemDefRow>(StringComparer.Ordinal)
{
["scrap_metal_bulk"] = new ItemDefRow(
"scrap_metal_bulk",
"Scrap Metal (Bulk)",
"material",
StackMax: 999,
InventorySlotKind: "bag",
Rarity: null,
BindPolicy: null,
DurabilityMax: null),
};
var registry = CreateRegistryFromRows(rows);
// Act
var found = registry.TryGetDefinition(null, out var def);
// Assert
Assert.False(found);
Assert.Null(def);
}
[Fact]
public void TryGetDefinition_ShouldReturnFalse_WhenIdUnknown()
{
// Arrange
var rows = new Dictionary<string, ItemDefRow>(StringComparer.Ordinal)
{
["scrap_metal_bulk"] = new ItemDefRow(
"scrap_metal_bulk",
"Scrap Metal (Bulk)",
"material",
StackMax: 999,
InventorySlotKind: "bag",
Rarity: null,
BindPolicy: null,
DurabilityMax: null),
};
var registry = CreateRegistryFromRows(rows);
// Act
var found = registry.TryGetDefinition("not_a_real_item", out var def);
// Assert
Assert.False(found);
Assert.Null(def);
}
[Fact]
public void GetDefinitionsInIdOrder_ShouldListAllRowsOrderedById_WhenMultipleItems()
{
// Arrange
var rows = new Dictionary<string, ItemDefRow>(StringComparer.Ordinal)
{
["survey_drone_kit"] = new ItemDefRow(
"survey_drone_kit",
"Survey Drone Kit",
"utility",
StackMax: 1,
InventorySlotKind: "bag",
Rarity: null,
BindPolicy: null,
DurabilityMax: null),
["scrap_metal_bulk"] = new ItemDefRow(
"scrap_metal_bulk",
"Scrap Metal (Bulk)",
"material",
StackMax: 999,
InventorySlotKind: "bag",
Rarity: null,
BindPolicy: null,
DurabilityMax: null),
["prototype_armor_shell"] = new ItemDefRow(
"prototype_armor_shell",
"Prototype Armor Shell",
"equip_stub",
StackMax: 1,
InventorySlotKind: "equipment",
Rarity: null,
BindPolicy: null,
DurabilityMax: null),
};
var registry = CreateRegistryFromRows(rows);
// Act
var list = registry.GetDefinitionsInIdOrder();
// Assert
Assert.Equal(3, list.Count);
Assert.Equal("prototype_armor_shell", list[0].Id);
Assert.Equal("scrap_metal_bulk", list[1].Id);
Assert.Equal("survey_drone_kit", list[2].Id);
}
[Fact]
public void TryGetDefinition_ShouldMatchLoaderCatalog_WhenUsingPrototypeFixture()
{
// Arrange
var root = Directory.CreateTempSubdirectory("neon-sprawl-item-registry-loader-");
try
{
var itemsDir = Path.Combine(root.FullName, "content", "items");
var schemaDir = Path.Combine(root.FullName, "content", "schemas");
Directory.CreateDirectory(itemsDir);
Directory.CreateDirectory(schemaDir);
var schemaPath = Path.Combine(schemaDir, "item-def.schema.json");
File.Copy(ItemCatalogTestPaths.DiscoverRepoItemDefSchemaPath(), schemaPath, overwrite: true);
File.Copy(
Path.Combine(ItemCatalogTestPaths.DiscoverRepoItemsDirectory(), "prototype_items.json"),
Path.Combine(itemsDir, "prototype_items.json"),
overwrite: true);
var loaded = ItemDefinitionCatalogLoader.Load(itemsDir, schemaPath, NullLogger.Instance);
var registry = new ItemDefinitionRegistry(loaded);
// Act
var ok = registry.TryGetDefinition("prototype_armor_shell", out var equipStub);
// Assert
Assert.True(ok);
Assert.NotNull(equipStub);
Assert.Equal("equip_stub", equipStub.PrototypeRole);
Assert.Equal("equipment", equipStub.InventorySlotKind);
Assert.Equal(1, equipStub.StackMax);
}
finally
{
try
{
Directory.Delete(root.FullName, recursive: true);
}
catch (IOException)
{
// Best-effort: transient lock or race on some hosts; temp dir is unique per run.
}
}
}
[Fact]
public async Task Host_ShouldResolveRegistryFromDi_WhenStartupSucceeds()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
using var client = factory.CreateClient();
_ = await client.GetAsync("/health");
// Act
var registry = factory.Services.GetRequiredService<IItemDefinitionRegistry>();
var found = registry.TryGetDefinition("scrap_metal_bulk", out var scrap);
// Assert
Assert.True(found);
Assert.NotNull(scrap);
Assert.Equal("material", scrap.PrototypeRole);
Assert.Equal(999, scrap.StackMax);
}
}

View File

@ -0,0 +1,20 @@
using System.Diagnostics.CodeAnalysis;
namespace NeonSprawl.Server.Game.Items;
/// <summary>
/// Read-only access to validated <see cref="ItemDefRow"/> entries loaded at startup (<see cref="ItemDefinitionCatalog"/>).
/// </summary>
/// <remarks>
/// <para><b>E3.M3 (inventory / craft / gather):</b> callers granting or validating items should depend on this interface
/// rather than <see cref="ItemDefinitionCatalog"/> so stack limits and slot kinds stay centralized.</para>
/// <para><b>NEO-53:</b> HTTP/read-model projections should depend on this interface rather than reaching into the catalog.</para>
/// </remarks>
public interface IItemDefinitionRegistry
{
/// <summary>Attempts to resolve an item by stable <c>id</c> (see <c>item-def.schema.json</c>). Unknown ids and <c>null</c> return <c>false</c> without throwing.</summary>
bool TryGetDefinition(string? itemId, [NotNullWhen(true)] out ItemDefRow? definition);
/// <summary>Every loaded definition, ordered by <see cref="ItemDefRow.Id"/> (ordinal).</summary>
IReadOnlyList<ItemDefRow> GetDefinitionsInIdOrder();
}

View File

@ -8,7 +8,7 @@ namespace NeonSprawl.Server.Game.Items;
/// <summary>DI registration for the fail-fast item catalog (NEO-51).</summary>
public static class ItemCatalogServiceCollectionExtensions
{
/// <summary>Binds <see cref="ContentPathsOptions"/> and registers <see cref="ItemDefinitionCatalog"/> as a singleton.</summary>
/// <summary>Binds <see cref="ContentPathsOptions"/> and registers <see cref="ItemDefinitionCatalog"/> and <see cref="IItemDefinitionRegistry"/> as singletons.</summary>
public static IServiceCollection AddItemDefinitionCatalog(this IServiceCollection services, IConfiguration configuration)
{
services.AddOptions<ContentPathsOptions>()
@ -30,6 +30,9 @@ public static class ItemCatalogServiceCollectionExtensions
return ItemDefinitionCatalogLoader.Load(itemsDir, schemaPath, logger);
});
services.AddSingleton<IItemDefinitionRegistry>(sp =>
new ItemDefinitionRegistry(sp.GetRequiredService<ItemDefinitionCatalog>()));
return services;
}
}

View File

@ -2,7 +2,7 @@ using System.Collections.ObjectModel;
namespace NeonSprawl.Server.Game.Items;
/// <summary>In-memory item catalog loaded at startup (NEO-51). Game code should prefer injectable item registry for lookups (NEO-52).</summary>
/// <summary>In-memory item catalog loaded at startup (NEO-51). Game code should prefer <see cref="IItemDefinitionRegistry"/> for lookups (NEO-52).</summary>
public sealed class ItemDefinitionCatalog(
string itemsDirectory,
IReadOnlyDictionary<string, ItemDefRow> byId,

View File

@ -0,0 +1,39 @@
using System.Diagnostics.CodeAnalysis;
namespace NeonSprawl.Server.Game.Items;
/// <summary>Adapter over <see cref="ItemDefinitionCatalog"/> (NEO-52).</summary>
public sealed class ItemDefinitionRegistry(ItemDefinitionCatalog catalog) : IItemDefinitionRegistry
{
/// <inheritdoc />
public bool TryGetDefinition(string? itemId, [NotNullWhen(true)] out ItemDefRow? definition)
{
if (itemId is null)
{
definition = null;
return false;
}
if (catalog.ById.TryGetValue(itemId, out var row))
{
definition = row;
return true;
}
definition = null;
return false;
}
/// <inheritdoc />
public IReadOnlyList<ItemDefRow> GetDefinitionsInIdOrder()
{
var ids = catalog.ById.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray();
var list = new List<ItemDefRow>(ids.Length);
foreach (var id in ids)
{
list.Add(catalog.ById[id]);
}
return list;
}
}

View File

@ -48,7 +48,7 @@ On startup the host loads every **`*_items.json`** under the items directory, va
**Docker / CI:** include **`content/items`** and **`content/schemas/item-def.schema.json`** in the mounted **`content/`** tree; set **`Content__ItemsDirectory`** when layout differs.
On success, **Information** logs include the resolved items directory path, distinct item count, and catalog file count. Game code should use **`IItemDefinitionRegistry`** for lookups after **NEO-52**; until then use **`ItemDefinitionCatalog.TryGetItem`**.
On success, **Information** logs include the resolved items directory path, distinct item count, and catalog file count. Game code should use **`IItemDefinitionRegistry`** for lookups (NEO-52).
## Mastery catalog (`content/mastery`, NEO-46)