NEO-54: player inventory store, stack/slot rules engine, and tests

pull/89/head
VinPropane 2026-05-23 19:26:57 -04:00
parent e5ffa1b951
commit 01e391b03a
22 changed files with 1144 additions and 6 deletions

View File

@ -68,6 +68,8 @@ Epic 3 **Slice 1** — MVP inventory; `item_created`, transfer failures.
**Item definitions HTTP (NEO-53):** **`GET /game/world/item-definitions`** — versioned read-only projection (`schemaVersion` **1**, **`items`**) backed by **`IItemDefinitionRegistry`**; Bruno `bruno/neon-sprawl-server/item-definitions/`. Plan: [NEO-53 implementation plan](../../plans/NEO-53-implementation-plan.md). **Item definitions HTTP (NEO-53):** **`GET /game/world/item-definitions`** — versioned read-only projection (`schemaVersion` **1**, **`items`**) backed by **`IItemDefinitionRegistry`**; Bruno `bruno/neon-sprawl-server/item-definitions/`. Plan: [NEO-53 implementation plan](../../plans/NEO-53-implementation-plan.md).
**Player inventory store (NEO-54):** **`IPlayerInventoryStore`** + **`PlayerInventoryOperations`** in `server/NeonSprawl.Server/Game/Items/` — fixed **24 bag + 1 equipment** slots, stack limits from catalog, stable deny reason codes; in-memory + Postgres (`V005__player_inventory.sql`). HTTP deferred to NEO-55. Plan: [NEO-54 implementation plan](../../plans/NEO-54-implementation-plan.md).
**Linear backlog (decomposed):** [E3M3-prototype-backlog.md](../../plans/E3M3-prototype-backlog.md) — **E3M3-01** [NEO-50](https://linear.app/neon-sprawl/issue/NEO-50) (content + CI) through **E3M3-07** [NEO-56](https://linear.app/neon-sprawl/issue/NEO-56) (telemetry hooks). **Linear backlog (decomposed):** [E3M3-prototype-backlog.md](../../plans/E3M3-prototype-backlog.md) — **E3M3-01** [NEO-50](https://linear.app/neon-sprawl/issue/NEO-50) (content + CI) through **E3M3-07** [NEO-56](https://linear.app/neon-sprawl/issue/NEO-56) (telemetry hooks).
## Source anchors ## Source anchors

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.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 | | 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.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). **NEO-52 landed:** injectable **`IItemDefinitionRegistry`** + lookup tests ([NEO-52](../../plans/NEO-52-implementation-plan.md)). **NEO-53 landed:** **`GET /game/world/item-definitions`** — `ItemDefinitionsWorldApi` + DTOs in `Game/Items/` ([NEO-53](../../plans/NEO-53-implementation-plan.md), [`NEO-53` manual QA](../../manual-qa/NEO-53.md)); [server README — Item definitions (NEO-53)](../../../server/README.md#item-definitions-neo-53); Bruno `bruno/neon-sprawl-server/item-definitions/`. **Still planned:** inventory store, per-player 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), [NEO-53](../../plans/NEO-53-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)). **NEO-53 landed:** **`GET /game/world/item-definitions`** — `ItemDefinitionsWorldApi` + DTOs in `Game/Items/` ([NEO-53](../../plans/NEO-53-implementation-plan.md), [`NEO-53` manual QA](../../manual-qa/NEO-53.md)); [server README — Item definitions (NEO-53)](../../../server/README.md#item-definitions-neo-53); Bruno `bruno/neon-sprawl-server/item-definitions/`. **NEO-54 landed:** **`IPlayerInventoryStore`** + **`PlayerInventoryOperations`** — 24 bag + 1 equipment slots, stack rules, `V005` migration ([NEO-54](../../plans/NEO-54-implementation-plan.md)); [server README — Player inventory store (NEO-54)](../../../server/README.md#player-inventory-store-neo-54). **Still planned:** per-player inventory HTTP (NEO-55). | [NEO-50](../../plans/NEO-50-implementation-plan.md), [NEO-51](../../plans/NEO-51-implementation-plan.md), [NEO-52](../../plans/NEO-52-implementation-plan.md), [NEO-53](../../plans/NEO-53-implementation-plan.md), [NEO-54](../../plans/NEO-54-implementation-plan.md), [E3M3-prototype-backlog](../../plans/E3M3-prototype-backlog.md), [E3_M3](E3_M3_ItemizationAndInventorySchema.md) |
--- ---

View File

@ -41,10 +41,10 @@
## Acceptance criteria checklist ## Acceptance criteria checklist
- [ ] Add/remove respects **`ItemDef.stackMax`** from catalog. - [x] Add/remove respects **`ItemDef.stackMax`** from catalog.
- [ ] Full bag (or equipment slot occupied when `stackMax` 1) returns stable **`inventory_full`** without partial silent loss. - [x] Full bag (or equipment slot occupied when `stackMax` 1) returns stable **`inventory_full`** without partial silent loss.
- [ ] Unknown item → **`invalid_item`**; remove over amount → **`insufficient_quantity`**. - [x] Unknown item → **`invalid_item`**; remove over amount → **`insufficient_quantity`**.
- [ ] Postgres + in-memory parity tests (AAA). - [x] Postgres + in-memory parity tests (AAA).
## Technical approach ## Technical approach

View File

@ -0,0 +1,81 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Items;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Items;
public sealed class InMemoryPlayerInventoryStoreTests
{
[Fact]
public void TryGetSnapshot_ForSeededDevPlayer_ShouldReturnEmptyInventory()
{
// Arrange
using var factory = new InMemoryWebApplicationFactory();
var store = factory.Services.GetRequiredService<IPlayerInventoryStore>();
// Act
var found = store.TryGetSnapshot("dev-local-1", out var snapshot);
// Assert
Assert.True(found);
Assert.NotNull(snapshot);
Assert.Equal(PlayerInventorySnapshot.BagSlotCount, snapshot!.BagSlots.Length);
Assert.Single(snapshot.EquipmentSlots);
Assert.All(snapshot.BagSlots, static s => Assert.True(s.IsEmpty));
Assert.All(snapshot.EquipmentSlots, static s => Assert.True(s.IsEmpty));
}
[Fact]
public void TryReplaceSnapshot_ShouldRoundTripOccupiedSlots()
{
// Arrange
using var factory = new InMemoryWebApplicationFactory();
var store = factory.Services.GetRequiredService<IPlayerInventoryStore>();
store.TryGetSnapshot("dev-local-1", out var before);
var updated = before!.WithSlots(
InventoryContainerKind.Bag,
CloneWithOccupied(before.BagSlots, slotIndex: 3, "scrap_metal_bulk", quantity: 42));
// Act
var replaced = store.TryReplaceSnapshot("dev-local-1", updated);
var readBack = store.TryGetSnapshot("dev-local-1", out var after);
// Assert
Assert.True(replaced);
Assert.True(readBack);
Assert.Equal("scrap_metal_bulk", after!.BagSlots[3].ItemId);
Assert.Equal(42, after.BagSlots[3].Quantity);
}
[Fact]
public void TryGetSnapshot_ForUnknownPlayer_ShouldReturnFalse()
{
// Arrange
using var factory = new InMemoryWebApplicationFactory();
var store = factory.Services.GetRequiredService<IPlayerInventoryStore>();
// Act
var found = store.TryGetSnapshot("unknown-player-xyz", out _);
// Assert
Assert.False(found);
}
private static InventorySlotState[] CloneWithOccupied(
InventorySlotState[] source,
int slotIndex,
string itemId,
int quantity)
{
var copy = new InventorySlotState[source.Length];
for (var i = 0; i < source.Length; i++)
{
var s = source[i];
copy[i] = i == slotIndex
? new InventorySlotState(slotIndex, itemId, quantity)
: new InventorySlotState(s.SlotIndex, s.ItemId, s.Quantity);
}
return copy;
}
}

View File

@ -0,0 +1,265 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Items;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Items;
public sealed class PlayerInventoryOperationsTests
{
[Fact]
public void TryAddStack_ToEmptyBag_ShouldPlaceInFirstSlot()
{
// Arrange
using var factory = new InMemoryWebApplicationFactory();
var registry = factory.Services.GetRequiredService<IItemDefinitionRegistry>();
var store = factory.Services.GetRequiredService<IPlayerInventoryStore>();
// Act
var outcome = PlayerInventoryOperations.TryAddStack(
"dev-local-1",
"scrap_metal_bulk",
quantity: 10,
registry,
store);
// Assert
Assert.Equal(PlayerInventoryMutationKind.Applied, outcome.Kind);
Assert.NotNull(outcome.Snapshot);
Assert.Equal("scrap_metal_bulk", outcome.Snapshot!.BagSlots[0].ItemId);
Assert.Equal(10, outcome.Snapshot.BagSlots[0].Quantity);
}
[Fact]
public void TryAddStack_ShouldMergeIntoPartialStackRespectingStackMax()
{
// Arrange
using var factory = new InMemoryWebApplicationFactory();
var registry = factory.Services.GetRequiredService<IItemDefinitionRegistry>();
var store = factory.Services.GetRequiredService<IPlayerInventoryStore>();
_ = PlayerInventoryOperations.TryAddStack("dev-local-1", "field_stim_mk0", 15, registry, store);
// Act
var outcome = PlayerInventoryOperations.TryAddStack("dev-local-1", "field_stim_mk0", 5, registry, store);
// Assert
Assert.Equal(PlayerInventoryMutationKind.Applied, outcome.Kind);
Assert.Equal(20, CountItem(outcome.Snapshot!, "field_stim_mk0"));
Assert.Equal(1, OccupiedBagSlotCount(outcome.Snapshot!));
}
[Fact]
public void TryAddStack_WhenBagFull_ShouldDenyWithInventoryFull()
{
// Arrange
using var factory = new InMemoryWebApplicationFactory();
var registry = factory.Services.GetRequiredService<IItemDefinitionRegistry>();
var store = factory.Services.GetRequiredService<IPlayerInventoryStore>();
for (var i = 0; i < PlayerInventorySnapshot.BagSlotCount; i++)
{
var add = PlayerInventoryOperations.TryAddStack(
"dev-local-1",
"survey_drone_kit",
quantity: 1,
registry,
store);
Assert.Equal(PlayerInventoryMutationKind.Applied, add.Kind);
}
store.TryGetSnapshot("dev-local-1", out var before);
// Act
var outcome = PlayerInventoryOperations.TryAddStack(
"dev-local-1",
"scrap_metal_bulk",
quantity: 1,
registry,
store);
// Assert
Assert.Equal(PlayerInventoryMutationKind.Denied, outcome.Kind);
Assert.Equal(PlayerInventoryReasonCodes.InventoryFull, outcome.ReasonCode);
store.TryGetSnapshot("dev-local-1", out var after);
Assert.Equal(TotalBagQuantity(before!), TotalBagQuantity(after!));
}
[Fact]
public void TryAddStack_ToEquipment_ShouldUseEquipmentContainer()
{
// Arrange
using var factory = new InMemoryWebApplicationFactory();
var registry = factory.Services.GetRequiredService<IItemDefinitionRegistry>();
var store = factory.Services.GetRequiredService<IPlayerInventoryStore>();
// Act
var outcome = PlayerInventoryOperations.TryAddStack(
"dev-local-1",
"prototype_armor_shell",
quantity: 1,
registry,
store);
// Assert
Assert.Equal(PlayerInventoryMutationKind.Applied, outcome.Kind);
Assert.Equal("prototype_armor_shell", outcome.Snapshot!.EquipmentSlots[0].ItemId);
Assert.Equal(1, outcome.Snapshot.EquipmentSlots[0].Quantity);
Assert.All(outcome.Snapshot.BagSlots, static s => Assert.True(s.IsEmpty));
}
[Fact]
public void TryAddStack_WhenEquipmentOccupied_ShouldDenyWithInventoryFull()
{
// Arrange
using var factory = new InMemoryWebApplicationFactory();
var registry = factory.Services.GetRequiredService<IItemDefinitionRegistry>();
var store = factory.Services.GetRequiredService<IPlayerInventoryStore>();
_ = PlayerInventoryOperations.TryAddStack(
"dev-local-1",
"prototype_armor_shell",
quantity: 1,
registry,
store);
// Act
var outcome = PlayerInventoryOperations.TryAddStack(
"dev-local-1",
"prototype_armor_shell",
quantity: 1,
registry,
store);
// Assert
Assert.Equal(PlayerInventoryMutationKind.Denied, outcome.Kind);
Assert.Equal(PlayerInventoryReasonCodes.InventoryFull, outcome.ReasonCode);
}
[Fact]
public void TryAddStack_ForUnknownItem_ShouldDenyWithInvalidItem()
{
// Arrange
using var factory = new InMemoryWebApplicationFactory();
var registry = factory.Services.GetRequiredService<IItemDefinitionRegistry>();
var store = factory.Services.GetRequiredService<IPlayerInventoryStore>();
// Act
var outcome = PlayerInventoryOperations.TryAddStack(
"dev-local-1",
"nonexistent_item_id",
quantity: 1,
registry,
store);
// Assert
Assert.Equal(PlayerInventoryMutationKind.Denied, outcome.Kind);
Assert.Equal(PlayerInventoryReasonCodes.InvalidItem, outcome.ReasonCode);
}
[Fact]
public void TryAddStack_ForNonPositiveQuantity_ShouldDenyWithInvalidQuantity()
{
// Arrange
using var factory = new InMemoryWebApplicationFactory();
var registry = factory.Services.GetRequiredService<IItemDefinitionRegistry>();
var store = factory.Services.GetRequiredService<IPlayerInventoryStore>();
// Act
var outcome = PlayerInventoryOperations.TryAddStack(
"dev-local-1",
"scrap_metal_bulk",
quantity: 0,
registry,
store);
// Assert
Assert.Equal(PlayerInventoryMutationKind.Denied, outcome.Kind);
Assert.Equal(PlayerInventoryReasonCodes.InvalidQuantity, outcome.ReasonCode);
}
[Fact]
public void TryRemoveStack_ShouldDrainLowestSlotIndexFirst()
{
// Arrange
using var factory = new InMemoryWebApplicationFactory();
var registry = factory.Services.GetRequiredService<IItemDefinitionRegistry>();
var store = factory.Services.GetRequiredService<IPlayerInventoryStore>();
_ = PlayerInventoryOperations.TryAddStack("dev-local-1", "field_stim_mk0", 20, registry, store);
_ = PlayerInventoryOperations.TryAddStack("dev-local-1", "field_stim_mk0", 5, registry, store);
// Act
var outcome = PlayerInventoryOperations.TryRemoveStack(
"dev-local-1",
"field_stim_mk0",
quantity: 20,
registry,
store);
// Assert
Assert.Equal(PlayerInventoryMutationKind.Applied, outcome.Kind);
Assert.True(outcome.Snapshot!.BagSlots[0].IsEmpty);
Assert.Equal("field_stim_mk0", outcome.Snapshot.BagSlots[1].ItemId);
Assert.Equal(5, outcome.Snapshot.BagSlots[1].Quantity);
}
[Fact]
public void TryRemoveStack_WhenInsufficientQuantity_ShouldDenyWithoutMutation()
{
// Arrange
using var factory = new InMemoryWebApplicationFactory();
var registry = factory.Services.GetRequiredService<IItemDefinitionRegistry>();
var store = factory.Services.GetRequiredService<IPlayerInventoryStore>();
_ = PlayerInventoryOperations.TryAddStack("dev-local-1", "scrap_metal_bulk", 5, registry, store);
store.TryGetSnapshot("dev-local-1", out var before);
// Act
var outcome = PlayerInventoryOperations.TryRemoveStack(
"dev-local-1",
"scrap_metal_bulk",
quantity: 6,
registry,
store);
// Assert
Assert.Equal(PlayerInventoryMutationKind.Denied, outcome.Kind);
Assert.Equal(PlayerInventoryReasonCodes.InsufficientQuantity, outcome.ReasonCode);
store.TryGetSnapshot("dev-local-1", out var after);
Assert.Equal(TotalBagQuantity(before!), TotalBagQuantity(after!));
}
private static int CountItem(PlayerInventorySnapshot snapshot, string itemId)
{
var total = 0;
foreach (var slot in snapshot.BagSlots)
{
if (!slot.IsEmpty && slot.ItemId == itemId)
{
total += slot.Quantity;
}
}
return total;
}
private static int OccupiedBagSlotCount(PlayerInventorySnapshot snapshot)
{
var count = 0;
foreach (var slot in snapshot.BagSlots)
{
if (!slot.IsEmpty)
{
count++;
}
}
return count;
}
private static int TotalBagQuantity(PlayerInventorySnapshot snapshot)
{
var total = 0;
foreach (var slot in snapshot.BagSlots)
{
total += slot.Quantity;
}
return total;
}
}

View File

@ -0,0 +1,161 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Tests.Game.PositionState;
using Npgsql;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Items;
[Collection("Postgres integration")]
public sealed class PlayerInventoryPersistenceIntegrationTests(PostgresIntegrationHarness harness)
{
private PostgresWebApplicationFactory Factory => harness.Factory;
[RequirePostgresFact]
public async Task TryAddStackAcrossNewFactory_ShouldPersistSnapshot()
{
// Arrange
await ResetInventoryTableAsync();
PlayerInventoryMutationOutcome addOutcome;
// Act — write through first host
using (var firstScope = Factory.Services.CreateScope())
{
var registry = firstScope.ServiceProvider.GetRequiredService<IItemDefinitionRegistry>();
var store = firstScope.ServiceProvider.GetRequiredService<IPlayerInventoryStore>();
addOutcome = PlayerInventoryOperations.TryAddStack(
"dev-local-1",
"scrap_metal_bulk",
quantity: 25,
registry,
store);
}
// Act — read back through a fresh host
await using var secondFactory = new PostgresWebApplicationFactory();
using var secondScope = secondFactory.Services.CreateScope();
var readStore = secondScope.ServiceProvider.GetRequiredService<IPlayerInventoryStore>();
var readBack = readStore.TryGetSnapshot("dev-local-1", out var snapshot);
// Assert
Assert.Equal(PlayerInventoryMutationKind.Applied, addOutcome.Kind);
Assert.True(readBack);
Assert.NotNull(snapshot);
Assert.Equal("scrap_metal_bulk", snapshot!.BagSlots[0].ItemId);
Assert.Equal(25, snapshot.BagSlots[0].Quantity);
}
[RequirePostgresFact]
public async Task TryAddStack_WhenDenied_ShouldNotPersistPartialState()
{
// Arrange
await ResetInventoryTableAsync();
using (var seedScope = Factory.Services.CreateScope())
{
var registry = seedScope.ServiceProvider.GetRequiredService<IItemDefinitionRegistry>();
var store = seedScope.ServiceProvider.GetRequiredService<IPlayerInventoryStore>();
for (var i = 0; i < PlayerInventorySnapshot.BagSlotCount; i++)
{
_ = PlayerInventoryOperations.TryAddStack(
"dev-local-1",
"survey_drone_kit",
quantity: 1,
registry,
store);
}
}
// Act
PlayerInventoryMutationOutcome denyOutcome;
using (var scope = Factory.Services.CreateScope())
{
var registry = scope.ServiceProvider.GetRequiredService<IItemDefinitionRegistry>();
var store = scope.ServiceProvider.GetRequiredService<IPlayerInventoryStore>();
denyOutcome = PlayerInventoryOperations.TryAddStack(
"dev-local-1",
"scrap_metal_bulk",
quantity: 1,
registry,
store);
store.TryGetSnapshot("dev-local-1", out var afterDeny);
Assert.Equal(PlayerInventorySnapshot.BagSlotCount, OccupiedBagSlotCount(afterDeny!));
}
// Act — verify on fresh host
await using var secondFactory = new PostgresWebApplicationFactory();
using var secondScope = secondFactory.Services.CreateScope();
var readStore = secondScope.ServiceProvider.GetRequiredService<IPlayerInventoryStore>();
readStore.TryGetSnapshot("dev-local-1", out var persisted);
// Assert
Assert.Equal(PlayerInventoryMutationKind.Denied, denyOutcome.Kind);
Assert.Equal(PlayerInventoryReasonCodes.InventoryFull, denyOutcome.ReasonCode);
Assert.Equal(PlayerInventorySnapshot.BagSlotCount, OccupiedBagSlotCount(persisted!));
}
private async Task ResetInventoryTableAsync()
{
var cs = Environment.GetEnvironmentVariable("ConnectionStrings__NeonSprawl");
if (string.IsNullOrWhiteSpace(cs))
{
throw new InvalidOperationException("ConnectionStrings__NeonSprawl is not set.");
}
_ = Factory.Services;
var options = Factory.Services.GetRequiredService<IOptions<GamePositionOptions>>().Value;
var positionDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V001__player_position.sql");
var inventoryDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V005__player_inventory.sql");
if (!File.Exists(positionDdlPath))
{
throw new FileNotFoundException($"Test DDL not found at '{positionDdlPath}'.", positionDdlPath);
}
if (!File.Exists(inventoryDdlPath))
{
throw new FileNotFoundException($"Test DDL not found at '{inventoryDdlPath}'.", inventoryDdlPath);
}
var positionDdl = await File.ReadAllTextAsync(positionDdlPath);
var inventoryDdl = await File.ReadAllTextAsync(inventoryDdlPath);
await using var conn = new NpgsqlConnection(cs);
await conn.OpenAsync();
await using (var applyPosition = new NpgsqlCommand(positionDdl, conn))
{
await applyPosition.ExecuteNonQueryAsync();
}
await using (var truncate = new NpgsqlCommand("TRUNCATE player_position CASCADE;", conn))
{
await truncate.ExecuteNonQueryAsync();
}
PostgresPositionBootstrap.SeedDevPlayer(conn, options);
await using (var applyInventory = new NpgsqlCommand(inventoryDdl, conn))
{
await applyInventory.ExecuteNonQueryAsync();
}
await using (var truncateInventory = new NpgsqlCommand("TRUNCATE player_inventory;", conn))
{
await truncateInventory.ExecuteNonQueryAsync();
}
}
private static int OccupiedBagSlotCount(PlayerInventorySnapshot snapshot)
{
var count = 0;
foreach (var slot in snapshot.BagSlots)
{
if (!slot.IsEmpty)
{
count++;
}
}
return count;
}
}

View File

@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Game.Skills;
@ -27,8 +28,12 @@ public sealed class PostgresWebApplicationFactory : WebApplicationFactory<Progra
var masteryDir = MasteryCatalogPathResolution.TryDiscoverMasteryDirectory(AppContext.BaseDirectory) var masteryDir = MasteryCatalogPathResolution.TryDiscoverMasteryDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
"Could not discover repo content/mastery from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); "Could not discover repo content/mastery from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
var itemsDir = ItemCatalogPathResolution.TryDiscoverItemsDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException(
"Could not discover repo content/items from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
builder.UseSetting("Content:SkillsDirectory", skillsDir); builder.UseSetting("Content:SkillsDirectory", skillsDir);
builder.UseSetting("Content:MasteryDirectory", masteryDir); builder.UseSetting("Content:MasteryDirectory", masteryDir);
builder.UseSetting("Content:ItemsDirectory", itemsDir);
builder.ConfigureAppConfiguration((_, config) => builder.ConfigureAppConfiguration((_, config) =>
{ {

View File

@ -47,6 +47,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
if (d.ServiceType == typeof(IPositionStateStore) || if (d.ServiceType == typeof(IPositionStateStore) ||
d.ServiceType == typeof(IPlayerHotbarLoadoutStore) || d.ServiceType == typeof(IPlayerHotbarLoadoutStore) ||
d.ServiceType == typeof(IPlayerSkillProgressionStore) || d.ServiceType == typeof(IPlayerSkillProgressionStore) ||
d.ServiceType == typeof(IPlayerInventoryStore) ||
d.ServiceType == typeof(IPlayerPerkStateStore) || d.ServiceType == typeof(IPlayerPerkStateStore) ||
d.ServiceType == typeof(TimeProvider) || d.ServiceType == typeof(TimeProvider) ||
d.ServiceType == typeof(IPlayerAbilityCooldownStore) || d.ServiceType == typeof(IPlayerAbilityCooldownStore) ||
@ -65,6 +66,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
services.AddSingleton<IPositionStateStore, InMemoryPositionStateStore>(); services.AddSingleton<IPositionStateStore, InMemoryPositionStateStore>();
services.AddSingleton<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>(); services.AddSingleton<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>();
services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>(); services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>();
services.AddSingleton<IPlayerInventoryStore, InMemoryPlayerInventoryStore>();
services.AddSingleton<IPlayerPerkStateStore, InMemoryPlayerPerkStateStore>(); services.AddSingleton<IPlayerPerkStateStore, InMemoryPlayerPerkStateStore>();
services.AddSingleton<IPlayerAbilityCooldownStore, InMemoryPlayerAbilityCooldownStore>(); services.AddSingleton<IPlayerAbilityCooldownStore, InMemoryPlayerAbilityCooldownStore>();
}); });

View File

@ -0,0 +1,11 @@
namespace NeonSprawl.Server.Game.Items;
/// <summary>Persisted per-player inventory slot state (NEO-54).</summary>
public interface IPlayerInventoryStore
{
/// <summary>Returns the current snapshot for a known player bucket; false when the player is not in the store.</summary>
bool TryGetSnapshot(string playerId, out PlayerInventorySnapshot snapshot);
/// <summary>Atomically replaces the full snapshot for a known player; false when the player is not in the store.</summary>
bool TryReplaceSnapshot(string playerId, PlayerInventorySnapshot snapshot);
}

View File

@ -0,0 +1,84 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.PositionState;
namespace NeonSprawl.Server.Game.Items;
/// <summary>Thread-safe in-memory inventory; seeds configured dev player with an empty snapshot (NEO-54).</summary>
public sealed class InMemoryPlayerInventoryStore(IOptions<GamePositionOptions> options) : IPlayerInventoryStore
{
private readonly ConcurrentDictionary<string, PlayerInventorySnapshot> byPlayer = CreateInitialMap(options.Value);
private readonly ConcurrentDictionary<string, object> playerLocks = new(StringComparer.OrdinalIgnoreCase);
private static ConcurrentDictionary<string, PlayerInventorySnapshot> CreateInitialMap(GamePositionOptions o)
{
var id = NormalizePlayerId(o.DevPlayerId);
var map = new ConcurrentDictionary<string, PlayerInventorySnapshot>(StringComparer.OrdinalIgnoreCase);
map[id] = PlayerInventorySnapshot.Empty();
return map;
}
/// <inheritdoc />
public bool TryGetSnapshot(string playerId, out PlayerInventorySnapshot snapshot)
{
var key = NormalizePlayerId(playerId);
if (key.Length == 0 || !byPlayer.TryGetValue(key, out var existing))
{
snapshot = PlayerInventorySnapshot.Empty();
return false;
}
lock (playerLocks.GetOrAdd(key, _ => new object()))
{
snapshot = Clone(existing);
return true;
}
}
/// <inheritdoc />
public bool TryReplaceSnapshot(string playerId, PlayerInventorySnapshot snapshot)
{
var key = NormalizePlayerId(playerId);
if (key.Length == 0 || !byPlayer.ContainsKey(key))
{
return false;
}
lock (playerLocks.GetOrAdd(key, _ => new object()))
{
byPlayer[key] = Clone(snapshot);
return true;
}
}
private static PlayerInventorySnapshot Clone(PlayerInventorySnapshot source) =>
new()
{
BagSlots = CloneSlots(source.BagSlots),
EquipmentSlots = CloneSlots(source.EquipmentSlots),
};
private static InventorySlotState[] CloneSlots(InventorySlotState[] slots)
{
var copy = new InventorySlotState[slots.Length];
for (var i = 0; i < slots.Length; i++)
{
var s = slots[i];
copy[i] = new InventorySlotState(s.SlotIndex, s.ItemId, s.Quantity);
}
return copy;
}
private static string NormalizePlayerId(string? playerId)
{
var t = playerId?.Trim();
if (string.IsNullOrEmpty(t))
{
return string.Empty;
}
return t.ToLowerInvariant();
}
}

View File

@ -0,0 +1,8 @@
namespace NeonSprawl.Server.Game.Items;
/// <summary>Player inventory container (NEO-54); maps to <c>ItemDef.inventorySlotKind</c>.</summary>
public enum InventoryContainerKind
{
Bag,
Equipment,
}

View File

@ -0,0 +1,7 @@
namespace NeonSprawl.Server.Game.Items;
/// <summary>One fixed slot in a player inventory container (NEO-54).</summary>
public sealed record InventorySlotState(int SlotIndex, string? ItemId, int Quantity)
{
public bool IsEmpty => string.IsNullOrEmpty(ItemId) || Quantity <= 0;
}

View File

@ -33,6 +33,6 @@ public static class ItemCatalogServiceCollectionExtensions
services.AddSingleton<IItemDefinitionRegistry>(sp => services.AddSingleton<IItemDefinitionRegistry>(sp =>
new ItemDefinitionRegistry(sp.GetRequiredService<ItemDefinitionCatalog>())); new ItemDefinitionRegistry(sp.GetRequiredService<ItemDefinitionCatalog>()));
return services; return services.AddPlayerInventoryStore(configuration);
} }
} }

View File

@ -0,0 +1,21 @@
namespace NeonSprawl.Server.Game.Items;
/// <summary>Result of <see cref="PlayerInventoryOperations.TryAddStack"/> or <see cref="PlayerInventoryOperations.TryRemoveStack"/> (NEO-54).</summary>
public enum PlayerInventoryMutationKind
{
/// <summary>Validation or rules deny — inventory unchanged.</summary>
Denied,
/// <summary><see cref="IPlayerInventoryStore"/> could not read or write the player bucket.</summary>
StoreMissing,
/// <summary>Mutation applied and persisted.</summary>
Applied,
}
/// <param name="ReasonCode">Populated when <see cref="Kind"/> is <see cref="PlayerInventoryMutationKind.Denied"/>.</param>
/// <param name="Snapshot">Authoritative snapshot after apply, or unchanged snapshot on deny; null when store missing.</param>
public readonly record struct PlayerInventoryMutationOutcome(
PlayerInventoryMutationKind Kind,
string? ReasonCode,
PlayerInventorySnapshot? Snapshot);

View File

@ -0,0 +1,193 @@
namespace NeonSprawl.Server.Game.Items;
/// <summary>Server-authoritative inventory add/remove rules (NEO-54).</summary>
public static class PlayerInventoryOperations
{
/// <summary>
/// Adds <paramref name="quantity"/> of <paramref name="itemId"/> into the catalog-routed container.
/// All-or-nothing: denies with <see cref="PlayerInventoryReasonCodes.InventoryFull"/> when the full amount cannot be placed.
/// </summary>
public static PlayerInventoryMutationOutcome TryAddStack(
string playerId,
string itemId,
int quantity,
IItemDefinitionRegistry registry,
IPlayerInventoryStore store)
{
if (quantity <= 0)
{
return Deny(playerId, store, PlayerInventoryReasonCodes.InvalidQuantity);
}
var lookup = itemId.Trim();
if (lookup.Length == 0 || !registry.TryGetDefinition(lookup, out var def))
{
return Deny(playerId, store, PlayerInventoryReasonCodes.InvalidItem);
}
if (!store.TryGetSnapshot(playerId, out var before))
{
return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.StoreMissing, null, null);
}
var container = PlayerInventorySnapshot.ContainerFromSlotKind(def.InventorySlotKind);
var slots = CloneSlots(before.GetSlots(container));
var remaining = quantity;
remaining = MergeIntoExistingStacks(slots, def.Id, remaining, def.StackMax);
remaining = FillEmptySlots(slots, def.Id, remaining, def.StackMax);
if (remaining > 0)
{
return new PlayerInventoryMutationOutcome(
PlayerInventoryMutationKind.Denied,
PlayerInventoryReasonCodes.InventoryFull,
before);
}
var after = before.WithSlots(container, slots);
if (!store.TryReplaceSnapshot(playerId, after))
{
return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.StoreMissing, null, null);
}
return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.Applied, null, after);
}
/// <summary>Removes <paramref name="quantity"/> from stacks of <paramref name="itemId"/> (lowest slot index first).</summary>
public static PlayerInventoryMutationOutcome TryRemoveStack(
string playerId,
string itemId,
int quantity,
IItemDefinitionRegistry registry,
IPlayerInventoryStore store)
{
if (quantity <= 0)
{
return Deny(playerId, store, PlayerInventoryReasonCodes.InvalidQuantity);
}
var lookup = itemId.Trim();
if (lookup.Length == 0 || !registry.TryGetDefinition(lookup, out var def))
{
return Deny(playerId, store, PlayerInventoryReasonCodes.InvalidItem);
}
if (!store.TryGetSnapshot(playerId, out var before))
{
return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.StoreMissing, null, null);
}
var container = PlayerInventorySnapshot.ContainerFromSlotKind(def.InventorySlotKind);
var slots = CloneSlots(before.GetSlots(container));
var available = CountQuantity(slots, def.Id);
if (available < quantity)
{
return new PlayerInventoryMutationOutcome(
PlayerInventoryMutationKind.Denied,
PlayerInventoryReasonCodes.InsufficientQuantity,
before);
}
var remaining = quantity;
for (var i = 0; i < slots.Length && remaining > 0; i++)
{
var slot = slots[i];
if (slot.IsEmpty || !string.Equals(slot.ItemId, def.Id, StringComparison.Ordinal))
{
continue;
}
var take = Math.Min(remaining, slot.Quantity);
var left = slot.Quantity - take;
slots[i] = left <= 0
? new InventorySlotState(slot.SlotIndex, null, 0)
: new InventorySlotState(slot.SlotIndex, def.Id, left);
remaining -= take;
}
var after = before.WithSlots(container, slots);
if (!store.TryReplaceSnapshot(playerId, after))
{
return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.StoreMissing, null, null);
}
return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.Applied, null, after);
}
private static PlayerInventoryMutationOutcome Deny(string playerId, IPlayerInventoryStore store, string reasonCode)
{
if (!store.TryGetSnapshot(playerId, out var snapshot))
{
return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.StoreMissing, null, null);
}
return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.Denied, reasonCode, snapshot);
}
private static int MergeIntoExistingStacks(InventorySlotState[] slots, string itemId, int remaining, int stackMax)
{
for (var i = 0; i < slots.Length && remaining > 0; i++)
{
var slot = slots[i];
if (slot.IsEmpty || !string.Equals(slot.ItemId, itemId, StringComparison.Ordinal))
{
continue;
}
var space = stackMax - slot.Quantity;
if (space <= 0)
{
continue;
}
var add = Math.Min(space, remaining);
slots[i] = new InventorySlotState(slot.SlotIndex, itemId, slot.Quantity + add);
remaining -= add;
}
return remaining;
}
private static int FillEmptySlots(InventorySlotState[] slots, string itemId, int remaining, int stackMax)
{
for (var i = 0; i < slots.Length && remaining > 0; i++)
{
if (!slots[i].IsEmpty)
{
continue;
}
var add = Math.Min(stackMax, remaining);
slots[i] = new InventorySlotState(i, itemId, add);
remaining -= add;
}
return remaining;
}
private static int CountQuantity(InventorySlotState[] slots, string itemId)
{
var total = 0;
foreach (var slot in slots)
{
if (!slot.IsEmpty && string.Equals(slot.ItemId, itemId, StringComparison.Ordinal))
{
total += slot.Quantity;
}
}
return total;
}
private static InventorySlotState[] CloneSlots(InventorySlotState[] slots)
{
var copy = new InventorySlotState[slots.Length];
for (var i = 0; i < slots.Length; i++)
{
var s = slots[i];
copy[i] = new InventorySlotState(s.SlotIndex, s.ItemId, s.Quantity);
}
return copy;
}
}

View File

@ -0,0 +1,10 @@
namespace NeonSprawl.Server.Game.Items;
/// <summary>Stable deny reason codes for inventory mutations (NEO-54).</summary>
public static class PlayerInventoryReasonCodes
{
public const string InventoryFull = "inventory_full";
public const string InvalidItem = "invalid_item";
public const string InsufficientQuantity = "insufficient_quantity";
public const string InvalidQuantity = "invalid_quantity";
}

View File

@ -0,0 +1,22 @@
using NeonSprawl.Server.Game.PositionState;
namespace NeonSprawl.Server.Game.Items;
/// <summary>Registers inventory persistence: PostgreSQL when configured, otherwise in-memory fallback (NEO-54).</summary>
public static class PlayerInventoryServiceCollectionExtensions
{
public static IServiceCollection AddPlayerInventoryStore(this IServiceCollection services, IConfiguration configuration)
{
var cs = configuration.GetConnectionString(PositionStateServiceCollectionExtensions.NeonSprawlConnectionStringName);
if (!string.IsNullOrWhiteSpace(cs))
{
services.AddSingleton<IPlayerInventoryStore, PostgresPlayerInventoryStore>();
}
else
{
services.AddSingleton<IPlayerInventoryStore, InMemoryPlayerInventoryStore>();
}
return services;
}
}

View File

@ -0,0 +1,52 @@
namespace NeonSprawl.Server.Game.Items;
/// <summary>Fixed-size bag + equipment slot arrays for one player (NEO-54).</summary>
public sealed class PlayerInventorySnapshot
{
public const int BagSlotCount = 24;
public const int EquipmentSlotCount = 1;
public required InventorySlotState[] BagSlots { get; init; }
public required InventorySlotState[] EquipmentSlots { get; init; }
public static PlayerInventorySnapshot Empty()
{
return new PlayerInventorySnapshot
{
BagSlots = CreateEmptySlots(BagSlotCount),
EquipmentSlots = CreateEmptySlots(EquipmentSlotCount),
};
}
public static InventorySlotState[] CreateEmptySlots(int count)
{
var slots = new InventorySlotState[count];
for (var i = 0; i < count; i++)
{
slots[i] = new InventorySlotState(i, null, 0);
}
return slots;
}
public InventorySlotState[] GetSlots(InventoryContainerKind container) =>
container == InventoryContainerKind.Equipment ? EquipmentSlots : BagSlots;
public PlayerInventorySnapshot WithSlots(InventoryContainerKind container, InventorySlotState[] slots) =>
container == InventoryContainerKind.Equipment
? new PlayerInventorySnapshot { BagSlots = BagSlots, EquipmentSlots = slots }
: new PlayerInventorySnapshot { BagSlots = slots, EquipmentSlots = EquipmentSlots };
public static InventoryContainerKind ContainerFromSlotKind(string inventorySlotKind) =>
string.Equals(inventorySlotKind, "equipment", StringComparison.OrdinalIgnoreCase)
? InventoryContainerKind.Equipment
: InventoryContainerKind.Bag;
public static string ContainerKindToPersistence(InventoryContainerKind container) =>
container == InventoryContainerKind.Equipment ? "equipment" : "bag";
public static InventoryContainerKind ContainerKindFromPersistence(string value) =>
string.Equals(value, "equipment", StringComparison.OrdinalIgnoreCase)
? InventoryContainerKind.Equipment
: InventoryContainerKind.Bag;
}

View File

@ -0,0 +1,39 @@
namespace NeonSprawl.Server.Game.Items;
/// <summary>Applies NEO-54 inventory table DDL once per process.</summary>
public static class PostgresPlayerInventoryBootstrap
{
private static readonly string DdlRelativePath = Path.Combine("db", "migrations", "V005__player_inventory.sql");
private static readonly object SchemaGate = new();
private static int _schemaReady;
public static void EnsureSchema(Npgsql.NpgsqlDataSource dataSource)
{
if (System.Threading.Volatile.Read(ref _schemaReady) != 0)
{
return;
}
lock (SchemaGate)
{
if (System.Threading.Volatile.Read(ref _schemaReady) != 0)
{
return;
}
var ddlPath = Path.Combine(AppContext.BaseDirectory, DdlRelativePath);
if (!File.Exists(ddlPath))
{
throw new FileNotFoundException($"NEO-54 DDL not found at '{ddlPath}'.", ddlPath);
}
var ddl = File.ReadAllText(ddlPath);
using var conn = dataSource.OpenConnection();
using var cmd = new Npgsql.NpgsqlCommand(ddl, conn);
cmd.ExecuteNonQuery();
System.Threading.Volatile.Write(ref _schemaReady, 1);
}
}
}

View File

@ -0,0 +1,143 @@
namespace NeonSprawl.Server.Game.Items;
/// <summary>PostgreSQL-backed inventory keyed by normalized player id (NEO-54).</summary>
public sealed class PostgresPlayerInventoryStore(Npgsql.NpgsqlDataSource dataSource) : IPlayerInventoryStore
{
/// <inheritdoc />
public bool TryGetSnapshot(string playerId, out PlayerInventorySnapshot snapshot)
{
var norm = NormalizePlayerId(playerId);
if (norm.Length == 0)
{
snapshot = PlayerInventorySnapshot.Empty();
return false;
}
PostgresPlayerInventoryBootstrap.EnsureSchema(dataSource);
using var conn = dataSource.OpenConnection();
if (!PlayerExists(conn, norm))
{
snapshot = PlayerInventorySnapshot.Empty();
return false;
}
snapshot = ReadSnapshot(conn, norm);
return true;
}
/// <inheritdoc />
public bool TryReplaceSnapshot(string playerId, PlayerInventorySnapshot snapshot)
{
var norm = NormalizePlayerId(playerId);
if (norm.Length == 0)
{
return false;
}
PostgresPlayerInventoryBootstrap.EnsureSchema(dataSource);
using var conn = dataSource.OpenConnection();
using var tx = conn.BeginTransaction();
if (!PlayerExists(conn, norm, tx))
{
tx.Rollback();
return false;
}
using (var del = new Npgsql.NpgsqlCommand(
"DELETE FROM player_inventory WHERE player_id = @pid;",
conn,
tx))
{
del.Parameters.AddWithValue("pid", norm);
del.ExecuteNonQuery();
}
WriteOccupiedSlots(conn, norm, snapshot.BagSlots, InventoryContainerKind.Bag, tx);
WriteOccupiedSlots(conn, norm, snapshot.EquipmentSlots, InventoryContainerKind.Equipment, tx);
tx.Commit();
return true;
}
private static void WriteOccupiedSlots(
Npgsql.NpgsqlConnection conn,
string playerIdNormalized,
InventorySlotState[] slots,
InventoryContainerKind container,
Npgsql.NpgsqlTransaction tx)
{
var kind = PlayerInventorySnapshot.ContainerKindToPersistence(container);
foreach (var slot in slots)
{
if (slot.IsEmpty)
{
continue;
}
using var cmd = new Npgsql.NpgsqlCommand(
"""
INSERT INTO player_inventory (player_id, container_kind, slot_index, item_id, quantity, updated_at)
VALUES (@pid, @kind, @slot, @item, @qty, now());
""",
conn,
tx);
cmd.Parameters.AddWithValue("pid", playerIdNormalized);
cmd.Parameters.AddWithValue("kind", kind);
cmd.Parameters.AddWithValue("slot", slot.SlotIndex);
cmd.Parameters.AddWithValue("item", slot.ItemId!);
cmd.Parameters.AddWithValue("qty", slot.Quantity);
cmd.ExecuteNonQuery();
}
}
private static PlayerInventorySnapshot ReadSnapshot(Npgsql.NpgsqlConnection conn, string playerIdNormalized)
{
var bag = PlayerInventorySnapshot.CreateEmptySlots(PlayerInventorySnapshot.BagSlotCount);
var equipment = PlayerInventorySnapshot.CreateEmptySlots(PlayerInventorySnapshot.EquipmentSlotCount);
using var cmd = new Npgsql.NpgsqlCommand(
"""
SELECT container_kind, slot_index, item_id, quantity
FROM player_inventory
WHERE player_id = @pid
ORDER BY container_kind, slot_index;
""",
conn);
cmd.Parameters.AddWithValue("pid", playerIdNormalized);
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
var container = PlayerInventorySnapshot.ContainerKindFromPersistence(reader.GetString(0));
var index = reader.GetInt32(1);
var itemId = reader.GetString(2);
var quantity = reader.GetInt32(3);
var target = container == InventoryContainerKind.Equipment ? equipment : bag;
if (index >= 0 && index < target.Length)
{
target[index] = new InventorySlotState(index, itemId, quantity);
}
}
return new PlayerInventorySnapshot { BagSlots = bag, EquipmentSlots = equipment };
}
private static bool PlayerExists(Npgsql.NpgsqlConnection conn, string playerIdNormalized, Npgsql.NpgsqlTransaction? tx = null)
{
using var cmd = new Npgsql.NpgsqlCommand(
"SELECT 1 FROM player_position WHERE player_id = @pid LIMIT 1;",
conn,
tx);
cmd.Parameters.AddWithValue("pid", playerIdNormalized);
return cmd.ExecuteScalar() is not null;
}
private static string NormalizePlayerId(string? playerId)
{
var t = playerId?.Trim();
if (string.IsNullOrEmpty(t))
{
return string.Empty;
}
return t.ToLowerInvariant();
}
}

View File

@ -58,6 +58,26 @@ On success, **Information** logs include the resolved items directory path, dist
curl -sS -i "http://localhost:5253/game/world/item-definitions" curl -sS -i "http://localhost:5253/game/world/item-definitions"
``` ```
## Player inventory store (NEO-54)
Server-authoritative per-player inventory lives in **`Game/Items/`** as **`IPlayerInventoryStore`** + **`PlayerInventoryOperations`**. There is **no HTTP surface in NEO-54****`GET` / `POST` inventory** lands in [NEO-55](../../docs/plans/NEO-55-implementation-plan.md).
| Container | Fixed slots | Accepts `inventorySlotKind` |
|-----------|-------------|-------------------------------|
| **Bag** | **24** (indices 023) | `bag` |
| **Equipment** | **1** (index 0) | `equipment` |
**Mutations:** **`TryAddStack`** and **`TryRemoveStack`** route items by catalog metadata, merge stacks up to **`ItemDef.stackMax`**, and use **all-or-nothing** adds (no partial silent loss). Stable deny **`reasonCode`** values:
| Code | Meaning |
|------|---------|
| **`inventory_full`** | Full requested quantity could not be placed |
| **`invalid_item`** | Unknown `itemId` (not in catalog) |
| **`insufficient_quantity`** | Remove requested more than held |
| **`invalid_quantity`** | Non-positive quantity |
**Persistence:** same **[NEO-29-style](#position-persistence-neo-8) split** as skill progression — **`player_inventory`** in PostgreSQL when **`ConnectionStrings:NeonSprawl`** is set (DDL [`V005__player_inventory.sql`](../db/migrations/V005__player_inventory.sql), sparse occupied-slot rows), otherwise in-memory fallback seeding the configured dev player. Plan: [NEO-54 implementation plan](../../docs/plans/NEO-54-implementation-plan.md).
## Mastery catalog (`content/mastery`, NEO-46) ## Mastery catalog (`content/mastery`, NEO-46)
After the skill catalog loads, the host loads every **`*_mastery.json`** under the mastery directory, validates each file against **`content/schemas/mastery-catalog.schema.json`**, cross-checks track **`skillId`** values against **`ISkillDefinitionRegistry`**, and enforces the same post-schema gates as **`scripts/validate_content.py`** (unknown perk references, duplicate perk ids, branch-set equality tier-to-tier, unreferenced perks, prototype **Slice 4** single **`salvage`** track). The server also requires **`tierIndex`** values per track to be **unique and sequential 1..N** (stricter than CI today). Invalid data **exits during startup**—no silent fallback. After the skill catalog loads, the host loads every **`*_mastery.json`** under the mastery directory, validates each file against **`content/schemas/mastery-catalog.schema.json`**, cross-checks track **`skillId`** values against **`ISkillDefinitionRegistry`**, and enforces the same post-schema gates as **`scripts/validate_content.py`** (unknown perk references, duplicate perk ids, branch-set equality tier-to-tier, unreferenced perks, prototype **Slice 4** single **`salvage`** track). The server also requires **`tierIndex`** values per track to be **unique and sequential 1..N** (stricter than CI today). Invalid data **exits during startup**—no silent fallback.

View File

@ -0,0 +1,12 @@
-- NEO-54: per-player inventory occupied slots (sparse; empty slots implied).
CREATE TABLE IF NOT EXISTS player_inventory (
player_id TEXT NOT NULL REFERENCES player_position(player_id) ON DELETE CASCADE,
container_kind TEXT NOT NULL CHECK (container_kind IN ('bag', 'equipment')),
slot_index INTEGER NOT NULL,
item_id TEXT NOT NULL,
quantity INTEGER NOT NULL CHECK (quantity > 0),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (player_id, container_kind, slot_index)
);
COMMENT ON TABLE player_inventory IS 'Persisted occupied inventory slots per player (NEO-54); bag 0-23, equipment 0.';