diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index 6477757..3e13f4f 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -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-37–NEO-41, NEO-42–NEO-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) | --- diff --git a/docs/plans/NEO-52-implementation-plan.md b/docs/plans/NEO-52-implementation-plan.md index 47b561a..7de2ed4 100644 --- a/docs/plans/NEO-52-implementation-plan.md +++ b/docs/plans/NEO-52-implementation-plan.md @@ -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 diff --git a/server/NeonSprawl.Server.Tests/Game/Items/ItemDefinitionRegistryTests.cs b/server/NeonSprawl.Server.Tests/Game/Items/ItemDefinitionRegistryTests.cs new file mode 100644 index 0000000..bd1d70a --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Items/ItemDefinitionRegistryTests.cs @@ -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 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(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(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(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(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(); + 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); + } +} diff --git a/server/NeonSprawl.Server/Game/Items/IItemDefinitionRegistry.cs b/server/NeonSprawl.Server/Game/Items/IItemDefinitionRegistry.cs new file mode 100644 index 0000000..a843612 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Items/IItemDefinitionRegistry.cs @@ -0,0 +1,20 @@ +using System.Diagnostics.CodeAnalysis; + +namespace NeonSprawl.Server.Game.Items; + +/// +/// Read-only access to validated entries loaded at startup (). +/// +/// +/// E3.M3 (inventory / craft / gather): callers granting or validating items should depend on this interface +/// rather than so stack limits and slot kinds stay centralized. +/// NEO-53: HTTP/read-model projections should depend on this interface rather than reaching into the catalog. +/// +public interface IItemDefinitionRegistry +{ + /// Attempts to resolve an item by stable id (see item-def.schema.json). Unknown ids and null return false without throwing. + bool TryGetDefinition(string? itemId, [NotNullWhen(true)] out ItemDefRow? definition); + + /// Every loaded definition, ordered by (ordinal). + IReadOnlyList GetDefinitionsInIdOrder(); +} diff --git a/server/NeonSprawl.Server/Game/Items/ItemCatalogServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Items/ItemCatalogServiceCollectionExtensions.cs index 3a0c731..2b8aab5 100644 --- a/server/NeonSprawl.Server/Game/Items/ItemCatalogServiceCollectionExtensions.cs +++ b/server/NeonSprawl.Server/Game/Items/ItemCatalogServiceCollectionExtensions.cs @@ -8,7 +8,7 @@ namespace NeonSprawl.Server.Game.Items; /// DI registration for the fail-fast item catalog (NEO-51). public static class ItemCatalogServiceCollectionExtensions { - /// Binds and registers as a singleton. + /// Binds and registers and as singletons. public static IServiceCollection AddItemDefinitionCatalog(this IServiceCollection services, IConfiguration configuration) { services.AddOptions() @@ -30,6 +30,9 @@ public static class ItemCatalogServiceCollectionExtensions return ItemDefinitionCatalogLoader.Load(itemsDir, schemaPath, logger); }); + services.AddSingleton(sp => + new ItemDefinitionRegistry(sp.GetRequiredService())); + return services; } } diff --git a/server/NeonSprawl.Server/Game/Items/ItemDefinitionCatalog.cs b/server/NeonSprawl.Server/Game/Items/ItemDefinitionCatalog.cs index 1ee21ea..d3fe3fb 100644 --- a/server/NeonSprawl.Server/Game/Items/ItemDefinitionCatalog.cs +++ b/server/NeonSprawl.Server/Game/Items/ItemDefinitionCatalog.cs @@ -2,7 +2,7 @@ using System.Collections.ObjectModel; namespace NeonSprawl.Server.Game.Items; -/// In-memory item catalog loaded at startup (NEO-51). Game code should prefer injectable item registry for lookups (NEO-52). +/// In-memory item catalog loaded at startup (NEO-51). Game code should prefer for lookups (NEO-52). public sealed class ItemDefinitionCatalog( string itemsDirectory, IReadOnlyDictionary byId, diff --git a/server/NeonSprawl.Server/Game/Items/ItemDefinitionRegistry.cs b/server/NeonSprawl.Server/Game/Items/ItemDefinitionRegistry.cs new file mode 100644 index 0000000..c6af36a --- /dev/null +++ b/server/NeonSprawl.Server/Game/Items/ItemDefinitionRegistry.cs @@ -0,0 +1,39 @@ +using System.Diagnostics.CodeAnalysis; + +namespace NeonSprawl.Server.Game.Items; + +/// Adapter over (NEO-52). +public sealed class ItemDefinitionRegistry(ItemDefinitionCatalog catalog) : IItemDefinitionRegistry +{ + /// + 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; + } + + /// + 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; + } +} diff --git a/server/README.md b/server/README.md index 78b7002..85112fc 100644 --- a/server/README.md +++ b/server/README.md @@ -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)