diff --git a/docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md b/docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md index 87d7637..f1bdd31 100644 --- a/docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md +++ b/docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md @@ -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). +**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). ## Source anchors diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index 97dae89..7fee86f 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). **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) | --- diff --git a/docs/plans/NEO-54-implementation-plan.md b/docs/plans/NEO-54-implementation-plan.md index eafe6b1..4b96a84 100644 --- a/docs/plans/NEO-54-implementation-plan.md +++ b/docs/plans/NEO-54-implementation-plan.md @@ -41,10 +41,10 @@ ## Acceptance criteria checklist -- [ ] Add/remove respects **`ItemDef.stackMax`** from catalog. -- [ ] 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`**. -- [ ] Postgres + in-memory parity tests (AAA). +- [x] Add/remove respects **`ItemDef.stackMax`** from catalog. +- [x] Full bag (or equipment slot occupied when `stackMax` 1) returns stable **`inventory_full`** without partial silent loss. +- [x] Unknown item → **`invalid_item`**; remove over amount → **`insufficient_quantity`**. +- [x] Postgres + in-memory parity tests (AAA). ## Technical approach diff --git a/server/NeonSprawl.Server.Tests/Game/Items/InMemoryPlayerInventoryStoreTests.cs b/server/NeonSprawl.Server.Tests/Game/Items/InMemoryPlayerInventoryStoreTests.cs new file mode 100644 index 0000000..b2b62f2 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Items/InMemoryPlayerInventoryStoreTests.cs @@ -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(); + + // 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(); + 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(); + + // 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; + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Items/PlayerInventoryOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Items/PlayerInventoryOperationsTests.cs new file mode 100644 index 0000000..9e7994f --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Items/PlayerInventoryOperationsTests.cs @@ -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(); + var store = factory.Services.GetRequiredService(); + + // 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(); + var store = factory.Services.GetRequiredService(); + _ = 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(); + var store = factory.Services.GetRequiredService(); + 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(); + var store = factory.Services.GetRequiredService(); + + // 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(); + var store = factory.Services.GetRequiredService(); + _ = 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(); + var store = factory.Services.GetRequiredService(); + + // 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(); + var store = factory.Services.GetRequiredService(); + + // 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(); + var store = factory.Services.GetRequiredService(); + _ = 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(); + var store = factory.Services.GetRequiredService(); + _ = 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; + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Items/PlayerInventoryPersistenceIntegrationTests.cs b/server/NeonSprawl.Server.Tests/Game/Items/PlayerInventoryPersistenceIntegrationTests.cs new file mode 100644 index 0000000..627f3c7 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Items/PlayerInventoryPersistenceIntegrationTests.cs @@ -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(); + var store = firstScope.ServiceProvider.GetRequiredService(); + 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(); + 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(); + var store = seedScope.ServiceProvider.GetRequiredService(); + 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(); + var store = scope.ServiceProvider.GetRequiredService(); + 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(); + 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>().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; + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresWebApplicationFactory.cs index 818f6d8..462261d 100644 --- a/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresWebApplicationFactory.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.Configuration; +using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Skills; @@ -27,8 +28,12 @@ public sealed class PostgresWebApplicationFactory : WebApplicationFactory { diff --git a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs index bf7f11f..51ccbfe 100644 --- a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs @@ -47,6 +47,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); }); diff --git a/server/NeonSprawl.Server/Game/Items/IPlayerInventoryStore.cs b/server/NeonSprawl.Server/Game/Items/IPlayerInventoryStore.cs new file mode 100644 index 0000000..3057fed --- /dev/null +++ b/server/NeonSprawl.Server/Game/Items/IPlayerInventoryStore.cs @@ -0,0 +1,11 @@ +namespace NeonSprawl.Server.Game.Items; + +/// Persisted per-player inventory slot state (NEO-54). +public interface IPlayerInventoryStore +{ + /// Returns the current snapshot for a known player bucket; false when the player is not in the store. + bool TryGetSnapshot(string playerId, out PlayerInventorySnapshot snapshot); + + /// Atomically replaces the full snapshot for a known player; false when the player is not in the store. + bool TryReplaceSnapshot(string playerId, PlayerInventorySnapshot snapshot); +} diff --git a/server/NeonSprawl.Server/Game/Items/InMemoryPlayerInventoryStore.cs b/server/NeonSprawl.Server/Game/Items/InMemoryPlayerInventoryStore.cs new file mode 100644 index 0000000..38e587b --- /dev/null +++ b/server/NeonSprawl.Server/Game/Items/InMemoryPlayerInventoryStore.cs @@ -0,0 +1,84 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Options; +using NeonSprawl.Server.Game.PositionState; + +namespace NeonSprawl.Server.Game.Items; + +/// Thread-safe in-memory inventory; seeds configured dev player with an empty snapshot (NEO-54). +public sealed class InMemoryPlayerInventoryStore(IOptions options) : IPlayerInventoryStore +{ + private readonly ConcurrentDictionary byPlayer = CreateInitialMap(options.Value); + + private readonly ConcurrentDictionary playerLocks = new(StringComparer.OrdinalIgnoreCase); + + private static ConcurrentDictionary CreateInitialMap(GamePositionOptions o) + { + var id = NormalizePlayerId(o.DevPlayerId); + var map = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + map[id] = PlayerInventorySnapshot.Empty(); + return map; + } + + /// + 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; + } + } + + /// + 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(); + } +} diff --git a/server/NeonSprawl.Server/Game/Items/InventoryContainerKind.cs b/server/NeonSprawl.Server/Game/Items/InventoryContainerKind.cs new file mode 100644 index 0000000..2f7c27e --- /dev/null +++ b/server/NeonSprawl.Server/Game/Items/InventoryContainerKind.cs @@ -0,0 +1,8 @@ +namespace NeonSprawl.Server.Game.Items; + +/// Player inventory container (NEO-54); maps to ItemDef.inventorySlotKind. +public enum InventoryContainerKind +{ + Bag, + Equipment, +} diff --git a/server/NeonSprawl.Server/Game/Items/InventorySlotState.cs b/server/NeonSprawl.Server/Game/Items/InventorySlotState.cs new file mode 100644 index 0000000..8fc643a --- /dev/null +++ b/server/NeonSprawl.Server/Game/Items/InventorySlotState.cs @@ -0,0 +1,7 @@ +namespace NeonSprawl.Server.Game.Items; + +/// One fixed slot in a player inventory container (NEO-54). +public sealed record InventorySlotState(int SlotIndex, string? ItemId, int Quantity) +{ + public bool IsEmpty => string.IsNullOrEmpty(ItemId) || Quantity <= 0; +} diff --git a/server/NeonSprawl.Server/Game/Items/ItemCatalogServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Items/ItemCatalogServiceCollectionExtensions.cs index 2b8aab5..3e27a27 100644 --- a/server/NeonSprawl.Server/Game/Items/ItemCatalogServiceCollectionExtensions.cs +++ b/server/NeonSprawl.Server/Game/Items/ItemCatalogServiceCollectionExtensions.cs @@ -33,6 +33,6 @@ public static class ItemCatalogServiceCollectionExtensions services.AddSingleton(sp => new ItemDefinitionRegistry(sp.GetRequiredService())); - return services; + return services.AddPlayerInventoryStore(configuration); } } diff --git a/server/NeonSprawl.Server/Game/Items/PlayerInventoryMutationOutcome.cs b/server/NeonSprawl.Server/Game/Items/PlayerInventoryMutationOutcome.cs new file mode 100644 index 0000000..c60b80d --- /dev/null +++ b/server/NeonSprawl.Server/Game/Items/PlayerInventoryMutationOutcome.cs @@ -0,0 +1,21 @@ +namespace NeonSprawl.Server.Game.Items; + +/// Result of or (NEO-54). +public enum PlayerInventoryMutationKind +{ + /// Validation or rules deny — inventory unchanged. + Denied, + + /// could not read or write the player bucket. + StoreMissing, + + /// Mutation applied and persisted. + Applied, +} + +/// Populated when is . +/// Authoritative snapshot after apply, or unchanged snapshot on deny; null when store missing. +public readonly record struct PlayerInventoryMutationOutcome( + PlayerInventoryMutationKind Kind, + string? ReasonCode, + PlayerInventorySnapshot? Snapshot); diff --git a/server/NeonSprawl.Server/Game/Items/PlayerInventoryOperations.cs b/server/NeonSprawl.Server/Game/Items/PlayerInventoryOperations.cs new file mode 100644 index 0000000..33c5982 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Items/PlayerInventoryOperations.cs @@ -0,0 +1,193 @@ +namespace NeonSprawl.Server.Game.Items; + +/// Server-authoritative inventory add/remove rules (NEO-54). +public static class PlayerInventoryOperations +{ + /// + /// Adds of into the catalog-routed container. + /// All-or-nothing: denies with when the full amount cannot be placed. + /// + 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); + } + + /// Removes from stacks of (lowest slot index first). + 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; + } +} diff --git a/server/NeonSprawl.Server/Game/Items/PlayerInventoryReasonCodes.cs b/server/NeonSprawl.Server/Game/Items/PlayerInventoryReasonCodes.cs new file mode 100644 index 0000000..99a2974 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Items/PlayerInventoryReasonCodes.cs @@ -0,0 +1,10 @@ +namespace NeonSprawl.Server.Game.Items; + +/// Stable deny reason codes for inventory mutations (NEO-54). +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"; +} diff --git a/server/NeonSprawl.Server/Game/Items/PlayerInventoryServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Items/PlayerInventoryServiceCollectionExtensions.cs new file mode 100644 index 0000000..24a565c --- /dev/null +++ b/server/NeonSprawl.Server/Game/Items/PlayerInventoryServiceCollectionExtensions.cs @@ -0,0 +1,22 @@ +using NeonSprawl.Server.Game.PositionState; + +namespace NeonSprawl.Server.Game.Items; + +/// Registers inventory persistence: PostgreSQL when configured, otherwise in-memory fallback (NEO-54). +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(); + } + else + { + services.AddSingleton(); + } + + return services; + } +} diff --git a/server/NeonSprawl.Server/Game/Items/PlayerInventorySnapshot.cs b/server/NeonSprawl.Server/Game/Items/PlayerInventorySnapshot.cs new file mode 100644 index 0000000..23a2858 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Items/PlayerInventorySnapshot.cs @@ -0,0 +1,52 @@ +namespace NeonSprawl.Server.Game.Items; + +/// Fixed-size bag + equipment slot arrays for one player (NEO-54). +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; +} diff --git a/server/NeonSprawl.Server/Game/Items/PostgresPlayerInventoryBootstrap.cs b/server/NeonSprawl.Server/Game/Items/PostgresPlayerInventoryBootstrap.cs new file mode 100644 index 0000000..9286068 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Items/PostgresPlayerInventoryBootstrap.cs @@ -0,0 +1,39 @@ +namespace NeonSprawl.Server.Game.Items; + +/// Applies NEO-54 inventory table DDL once per process. +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); + } + } +} diff --git a/server/NeonSprawl.Server/Game/Items/PostgresPlayerInventoryStore.cs b/server/NeonSprawl.Server/Game/Items/PostgresPlayerInventoryStore.cs new file mode 100644 index 0000000..13d9442 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Items/PostgresPlayerInventoryStore.cs @@ -0,0 +1,143 @@ +namespace NeonSprawl.Server.Game.Items; + +/// PostgreSQL-backed inventory keyed by normalized player id (NEO-54). +public sealed class PostgresPlayerInventoryStore(Npgsql.NpgsqlDataSource dataSource) : IPlayerInventoryStore +{ + /// + 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; + } + + /// + 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(); + } +} diff --git a/server/README.md b/server/README.md index b3b29ed..12944f2 100644 --- a/server/README.md +++ b/server/README.md @@ -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" ``` +## 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 0–23) | `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) 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. diff --git a/server/db/migrations/V005__player_inventory.sql b/server/db/migrations/V005__player_inventory.sql new file mode 100644 index 0000000..67d42fb --- /dev/null +++ b/server/db/migrations/V005__player_inventory.sql @@ -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.';