NEO-54: player inventory store, stack/slot rules engine, and tests
parent
e5ffa1b951
commit
01e391b03a
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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-37–NEO-41, NEO-42–NEO-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-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.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) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) =>
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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>();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 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)
|
## 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.
|
||||||
|
|
|
||||||
|
|
@ -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.';
|
||||||
Loading…
Reference in New Issue