Merge pull request #89 from ViPro-Technologies/NEO-54-player-inventory-store-stackslot-rules
NEO-54: Player inventory store + stack/slot rules enginepull/91/head
commit
658b627903
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen
|
|||
| E3.M4 | SinkAndDurabilityLifecycle | E3.M3, E8.M3 | DurabilityState, ItemSinkEvent, RepairCostRule | Pre-production | Planned |
|
||||
| E3.M5 | EconomyBalancePolicy | E3.M4, E9.M2 | EconomyPolicy, PriceBandRule, FaucetSinkRatio | Pre-production | Planned |
|
||||
|
||||
**E3.M3 note:** Epic 3 **Slice 1** backlog in Linear ([Epic 3 — Crafting, Gathering, and Itemization Economy](https://linear.app/neon-sprawl/project/epic-3-crafting-gathering-and-itemization-economy-65785ed05bc2)): [NEO-50](https://linear.app/neon-sprawl/issue/NEO-50) → [NEO-56](https://linear.app/neon-sprawl/issue/NEO-56); label **`E3.M3`**. See [E3M3-prototype-backlog.md](../../plans/E3M3-prototype-backlog.md), [E3_M3_ItemizationAndInventorySchema.md](E3_M3_ItemizationAndInventorySchema.md). **NEO-50** (content + CI), **NEO-51** (server fail-fast load), **NEO-52** (`IItemDefinitionRegistry` + DI), and **NEO-53** (`GET /game/world/item-definitions`) moved the register row to **In Progress**; later slices update the alignment table as they land.
|
||||
**E3.M3 note:** Epic 3 **Slice 1** backlog in Linear ([Epic 3 — Crafting, Gathering, and Itemization Economy](https://linear.app/neon-sprawl/project/epic-3-crafting-gathering-and-itemization-economy-65785ed05bc2)): [NEO-50](https://linear.app/neon-sprawl/issue/NEO-50) → [NEO-56](https://linear.app/neon-sprawl/issue/NEO-56); label **`E3.M3`**. See [E3M3-prototype-backlog.md](../../plans/E3M3-prototype-backlog.md), [E3_M3_ItemizationAndInventorySchema.md](E3_M3_ItemizationAndInventorySchema.md). **NEO-50** (content + CI), **NEO-51** (server fail-fast load), **NEO-52** (`IItemDefinitionRegistry` + DI), **NEO-53** (`GET /game/world/item-definitions`), and **NEO-54** (inventory store + stack/slot rules engine) moved the register row to **In Progress**; later slices update the alignment table as they land.
|
||||
|
||||
### Epic 4 — World Topology
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,143 @@
|
|||
# NEO-54 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-54 |
|
||||
| **Title** | E3.M3: Player inventory store + stack/slot rules engine |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-54/e3m3-player-inventory-store-stackslot-rules-engine |
|
||||
| **Module** | [E3.M3 — ItemizationAndInventorySchema](../decomposition/modules/E3_M3_ItemizationAndInventorySchema.md) · Epic 3 Slice 1 (E3M3-05) |
|
||||
| **Branch** | `NEO-54-player-inventory-store-stackslot-rules` |
|
||||
| **Precursor** | [NEO-52](https://linear.app/neon-sprawl/issue/NEO-52) — `IItemDefinitionRegistry` + DI (**Done** on `main`); [NEO-53](https://linear.app/neon-sprawl/issue/NEO-53) — read-only catalog HTTP (**Done** on `main`) |
|
||||
| **Pattern** | [NEO-38](https://linear.app/neon-sprawl/issue/NEO-38) — `IPlayerSkillProgressionStore` + `SkillProgressionGrantOperations` persistence policy |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **Slot capacity** | Fixed bag vs equipment slot counts? | **24 bag + 1 equipment** — enough for gather/craft QA; matches single `equip_stub` row in prototype catalog. | **User:** 24 bag + 1 equipment. |
|
||||
| **Add overflow** | Partial add vs all-or-nothing deny? | **All-or-nothing** — if the full requested quantity cannot be placed (stack merge + empty slots), deny with **`inventory_full`** and leave inventory unchanged (AC: no partial silent loss). | **User:** all-or-nothing. |
|
||||
| **Architecture** | Monolithic store vs split engine? | **Split:** **`IPlayerInventoryStore`** (persistence) + **`PlayerInventoryOperations`** (stack/slot rules, reason codes), mirroring NEO-38 store + grant operations. | **User:** split engine. |
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Server-authoritative per-player **`ItemInstance`** storage in fixed **`InventorySlot`** containers with stack limits from **`IItemDefinitionRegistry`**, structured deny **`reasonCode`** values, and dual persistence (in-memory + Postgres when configured).
|
||||
|
||||
**In scope (from Linear + [E3M3-05](E3M3-prototype-backlog.md#e3m3-05--player-inventory-store--stackslot-rules-engine)):**
|
||||
|
||||
- **`IPlayerInventoryStore`**, **`InMemoryPlayerInventoryStore`**, **`PostgresPlayerInventoryStore`**, **`PostgresPlayerInventoryBootstrap`**, **`PlayerInventoryServiceCollectionExtensions`**.
|
||||
- **`PlayerInventoryOperations`** (add/remove stack with catalog validation and slot assignment).
|
||||
- Domain types: **`InventoryContainerKind`**, **`InventorySlotState`**, **`PlayerInventorySnapshot`**, mutation outcome types with stable **`reasonCode`** strings.
|
||||
- Migration **`V005__player_inventory.sql`** (sparse occupied-slot rows; empty slots implied).
|
||||
- Unit tests (AAA) for engine rules; Postgres + in-memory **parity** integration tests (AAA).
|
||||
|
||||
**Out of scope (from Linear):**
|
||||
|
||||
- HTTP, Bruno, client HUD ([NEO-55](https://linear.app/neon-sprawl/issue/NEO-55)).
|
||||
- Gather/craft automatic grants (callers in E3.M1 / E3.M2).
|
||||
- Telemetry hook sites ([NEO-56](https://linear.app/neon-sprawl/issue/NEO-56)).
|
||||
- Optional schema fields (`rarity`, `bindPolicy`, `durabilityMax`) on instances.
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [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
|
||||
|
||||
1. **Containers and capacity (kickoff):**
|
||||
- **`bag`:** **24** fixed slots, indices **0–23**. Accepts defs with **`inventorySlotKind: bag`** only.
|
||||
- **`equipment`:** **1** fixed slot, index **0**. Accepts defs with **`inventorySlotKind: equipment`** only.
|
||||
- Routing is automatic from catalog metadata; callers pass **`itemId` + quantity** only (no manual slot placement in this story).
|
||||
|
||||
2. **Slot model:** Each slot is **`{ slotIndex, itemId?, quantity }`**. Empty slot ⇒ **`itemId`** omitted / null and **`quantity` 0**. Occupied slot ⇒ **`quantity`** in **1..stackMax** for that def. **`PlayerInventorySnapshot`** exposes **`BagSlots`** (length 24) and **`EquipmentSlots`** (length 1) for NEO-55 GET projection.
|
||||
|
||||
3. **Add algorithm (`TryAddStack`):**
|
||||
- Reject non-positive quantity → **`invalid_quantity`** (stable code; document alongside other denies).
|
||||
- **`IItemDefinitionRegistry.TryGetDefinition`** → on miss **`invalid_item`**.
|
||||
- Load snapshot; simulate on the def’s container:
|
||||
1. Merge into existing stacks of the same **`itemId`** up to **`stackMax`**.
|
||||
2. Place remainder in empty slots (one stack per slot, capped at **`stackMax`**).
|
||||
- If any quantity remains unplaced → **`inventory_full`**, **no write**.
|
||||
- Else atomically persist new snapshot → success outcome with updated snapshot.
|
||||
|
||||
4. **Remove algorithm (`TryRemoveStack`):**
|
||||
- Same quantity / catalog guards as add.
|
||||
- Sum quantity for **`itemId`** in the correct container; if total **< requested** → **`insufficient_quantity`**, no write.
|
||||
- Drain stacks (lowest **`slotIndex`** first or stable left-to-right order — pick one, document, test).
|
||||
- Persist; empty slots drop from Postgres rows.
|
||||
|
||||
5. **Persistence policy (NEO-38 / NEO-29 mirror):**
|
||||
- **`AddPlayerInventoryStore`** chooses **`PostgresPlayerInventoryStore`** when **`ConnectionStrings__NeonSprawl`** is set; else **`InMemoryPlayerInventoryStore`** seeding configured dev player (same pattern as skill progression / hotbar).
|
||||
- Postgres: **`player_id`** FK → **`player_position`**, transaction per mutation, **`PlayerExists`** gate before write.
|
||||
- DDL: **`V005__player_inventory.sql`** — **`(player_id, container_kind, slot_index)`** PK, **`item_id`**, **`quantity`**, **`updated_at`**; only non-empty slots stored.
|
||||
- Bootstrap: **`PostgresPlayerInventoryBootstrap.EnsureSchema`** loads embedded migration from **`db/migrations/`**.
|
||||
|
||||
6. **Store interface (minimal):**
|
||||
- **`TryGetSnapshot(string playerId, out PlayerInventorySnapshot snapshot)`** — missing player ⇒ false (engine maps to store-missing path if needed).
|
||||
- **`TryReplaceSnapshot(string playerId, PlayerInventorySnapshot snapshot)`** — atomic replace after engine validation (in-memory per-player lock; Postgres upsert/delete in one transaction).
|
||||
|
||||
7. **Reason codes (snake_case constants, shared by engine + tests):**
|
||||
- **`inventory_full`**, **`invalid_item`**, **`insufficient_quantity`**, **`invalid_quantity`**.
|
||||
- Document in code XML + brief **`server/README.md`** inventory subsection (persistence only; HTTP deferred to NEO-55).
|
||||
|
||||
8. **DI / tests:**
|
||||
- Register in **`Program.cs`** via **`AddPlayerInventoryStore`** after item catalog registration.
|
||||
- **`InMemoryWebApplicationFactory`**: swap in **`InMemoryPlayerInventoryStore`** (same removal pattern as other Postgres-backed stores).
|
||||
- Engine tests use factory-resolved **`IItemDefinitionRegistry`** + in-memory store — no HTTP.
|
||||
|
||||
9. **Docs (on land):** Update [E3_M3](E3_M3_ItemizationAndInventorySchema.md) **Related implementation slices** and [documentation_and_implementation_alignment.md](documentation_and_implementation_alignment.md) E3.M3 row.
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `server/NeonSprawl.Server/Game/Items/InventoryContainerKind.cs` | `Bag` / `Equipment` enum for container routing and persistence. |
|
||||
| `server/NeonSprawl.Server/Game/Items/InventorySlotState.cs` | One slot: index, optional `itemId`, quantity. |
|
||||
| `server/NeonSprawl.Server/Game/Items/PlayerInventorySnapshot.cs` | Fixed-size bag + equipment slot arrays for read/write. |
|
||||
| `server/NeonSprawl.Server/Game/Items/IPlayerInventoryStore.cs` | Persistence abstraction: get/replace snapshot per player. |
|
||||
| `server/NeonSprawl.Server/Game/Items/InMemoryPlayerInventoryStore.cs` | Thread-safe in-memory store; seeds dev player empty inventory. |
|
||||
| `server/NeonSprawl.Server/Game/Items/PostgresPlayerInventoryStore.cs` | Postgres sparse slot rows; transactional replace. |
|
||||
| `server/NeonSprawl.Server/Game/Items/PostgresPlayerInventoryBootstrap.cs` | One-time DDL ensure from `V005__player_inventory.sql`. |
|
||||
| `server/NeonSprawl.Server/Game/Items/PlayerInventoryServiceCollectionExtensions.cs` | Postgres vs in-memory registration from configuration. |
|
||||
| `server/NeonSprawl.Server/Game/Items/PlayerInventoryOperations.cs` | Add/remove stack rules, reason codes, catalog integration. |
|
||||
| `server/NeonSprawl.Server/Game/Items/PlayerInventoryMutationOutcome.cs` | Success/deny result types (`reasonCode`, snapshot). |
|
||||
| `server/NeonSprawl.Server/Game/Items/PlayerInventoryReasonCodes.cs` | Stable string constants for denies. |
|
||||
| `server/db/migrations/V005__player_inventory.sql` | Postgres table for occupied inventory slots. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Items/PlayerInventoryOperationsTests.cs` | AAA unit/integration: add merge, stack max, bag full, equipment single slot, remove, reason codes. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Items/InMemoryPlayerInventoryStoreTests.cs` | Store get/replace, dev player seed, normalization. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Items/PlayerInventoryPersistenceIntegrationTests.cs` | Postgres: mutate, new factory, snapshot parity (mirror skill progression persistence tests). |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/NeonSprawl.Server/Game/Items/ItemCatalogServiceCollectionExtensions.cs` | Chain **`AddPlayerInventoryStore`** after item catalog registration (avoids bare `Program.cs` change). |
|
||||
| `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | Force in-memory inventory store; strip Postgres inventory registration in tests. |
|
||||
| `server/README.md` | Inventory persistence subsection: slot counts, reason codes, migration note (HTTP in NEO-55). |
|
||||
| `docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md` | **Related implementation slices** — inventory store + engine bullet (NEO-54). |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E3.M3 row — note NEO-54 store/engine when landed. |
|
||||
|
||||
## Tests
|
||||
|
||||
| Test file | What it covers |
|
||||
|-----------|----------------|
|
||||
| `PlayerInventoryOperationsTests.cs` | **AAA** via in-memory host: add to empty bag; merge into partial stack respecting **`stackMax`** (e.g. **`field_stim_mk0`** max 20); fill 24 bag slots then **`inventory_full`** on further bag add; add **`prototype_armor_shell`** to equipment slot; second equipment add **`inventory_full`**; unknown id **`invalid_item`**; remove happy path; remove over amount **`insufficient_quantity`**; non-positive quantity **`invalid_quantity`**; snapshot unchanged on deny. |
|
||||
| `InMemoryPlayerInventoryStoreTests.cs` | **AAA:** dev player seeded empty; **`TryReplaceSnapshot`** round-trip; unknown player false. |
|
||||
| `PlayerInventoryPersistenceIntegrationTests.cs` | **AAA** `[RequirePostgresFact]`: add stacks via engine, new **`PostgresWebApplicationFactory`**, **`TryGetSnapshot`** matches; deny path does not persist partial state. |
|
||||
|
||||
No Bruno or manual QA doc — no HTTP surface in this story.
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|----------------------|--------|
|
||||
| **Remove drain order** | Drain **lowest slot index first** (deterministic, easy to test). | `adopted` |
|
||||
| **Cross-container same item id** | Prototype catalog has disjoint ids; engine scopes by container from def — no cross-container moves. | `adopted` |
|
||||
| **NEO-55 HTTP shape** | Snapshot types here should map cleanly to future GET DTOs (`bagSlots`, `equipmentSlots`). | `deferred` to NEO-55 |
|
||||
| **Concurrency** | Postgres transaction per mutation; in-memory per-player lock (NEO-38 precedent). | `adopted` |
|
||||
|
||||
None blocking.
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
# Code review — NEO-54 player inventory store + stack/slot rules
|
||||
|
||||
**Date:** 2026-05-23
|
||||
**Scope:** Branch `NEO-54-player-inventory-store-stackslot-rules` · commits `e5ffa1b`–`3724a54` vs `origin/main`
|
||||
**Base:** `origin/main`
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits**
|
||||
|
||||
## Summary
|
||||
|
||||
NEO-54 delivers the planned split between **`IPlayerInventoryStore`** (in-memory + Postgres sparse rows, `V005` migration) and **`PlayerInventoryOperations`** (catalog-routed add/remove, all-or-nothing adds, stable deny reason codes). The engine mirrors NEO-38’s store + operations pattern, chains DI from **`AddItemCatalog`**, and tests cover merge/stack max, bag full, equipment single slot, remove drain order, deny immutability, and Postgres persistence across a fresh factory. Docs (plan, E3.M3 slice, alignment table, README) match the implementation. Risk is moderate-low for merge: no HTTP yet, but persistence and rule correctness are well exercised; the main follow-ups are test hygiene (AAA), optional coverage gaps, and register footnote parity.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Document | Result |
|
||||
|----------|--------|
|
||||
| [`docs/plans/NEO-54-implementation-plan.md`](../plans/NEO-54-implementation-plan.md) | **Matches** — 24 bag + 1 equipment, split store/engine, reason codes, `V005`, Postgres/in-memory parity tests, DI via catalog extensions, docs/README updates; acceptance checklist complete. |
|
||||
| [`docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md`](../decomposition/modules/E3_M3_ItemizationAndInventorySchema.md) | **Matches** — NEO-54 bullet under **Related implementation slices**; frozen roster/stack limits align with tests. |
|
||||
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E3.M3 row notes NEO-54 landed; NEO-55 HTTP still planned. |
|
||||
| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | **Matches** — E3.M3 **In Progress**; footnote lists NEO-50–NEO-54. |
|
||||
| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **Matches** — server-authoritative inventory mutations; no client-trusted placement in this slice. |
|
||||
| [`server/README.md`](../../server/README.md) | **Matches** — Player inventory store section: containers, reason codes, persistence note, NEO-55 deferral. |
|
||||
|
||||
Register/tracking: E3.M3 alignment row is updated appropriately; module **Status** remains **In Progress** until NEO-55+.
|
||||
|
||||
## Blocking issues
|
||||
|
||||
None.
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**AAA in Postgres deny test** — In `PlayerInventoryPersistenceIntegrationTests.TryAddStack_WhenDenied_ShouldNotPersistPartialState`, move `Assert.Equal(PlayerInventorySnapshot.BagSlotCount, OccupiedBagSlotCount(afterDeny!))` from the first **Act** block into **Assert** (or a dedicated assert phase after both act steps), per [csharp-style](../../.cursor/rules/csharp-style.md#unit-and-integration-tests-arrange-act-assert).~~ **Done.** — removed in-Act assertion; slot count verified only in **Assert** via fresh factory read-back.
|
||||
|
||||
2. ~~**`StoreMissing` coverage (optional)** — `PlayerInventoryMutationKind.StoreMissing` is documented on `PlayerInventoryMutationOutcome` but untested. A small operations test for an unknown `playerId` (in-memory store without that bucket) would lock the contract before NEO-55 HTTP maps it.~~ **Done.** — `TryAddStack_ForUnknownPlayer_ShouldReturnStoreMissing` in `PlayerInventoryOperationsTests.cs`.
|
||||
|
||||
3. ~~**Multi-slot add (optional)** — Add a test that places quantity **> `stackMax`** across **multiple new** empty slots (e.g. 41 × `field_stim_mk0` on empty bag → three occupied slots). Merge and bag-full paths are covered; this would explicitly guard the `FillEmptySlots` loop beyond single-slot adds.~~ **Done.** — `TryAddStack_WhenQuantityExceedsStackMax_ShouldSpanMultipleEmptySlots` asserts 20+20+1 across three slots.
|
||||
|
||||
4. ~~**Dependency register footnote (optional)** — Extend the **E3.M3 note** in [`module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) to mention **NEO-54** alongside NEO-50–NEO-53 (same pattern as NEO-52/NEO-53 reviews).~~ **Done.**
|
||||
|
||||
## Nits
|
||||
|
||||
- ~~Nit: **`PlayerInventoryMutationOutcome.cs`** — enum and record parameters use **2-space** indent; neighboring outcome types (e.g. `SkillProgressionGrantApplyOutcome.cs`) use **4-space**. Format-only tidy.~~ **Done.**
|
||||
|
||||
- Nit: **`PostgresPlayerInventoryStore.TryReplaceSnapshot`** — delete-all + insert per mutation is correct for snapshot replace; concurrent writers are last-write-wins (same class of risk as other NEO-29-style stores). Fine for prototype; NEO-55 HTTP may want documenting or serializing per player if load tests show races.
|
||||
|
||||
- Nit: **`ContainerFromSlotKind`** — any non-`equipment` catalog value routes to **bag**. Safe for the frozen six-item catalog; if new `inventorySlotKind` values appear, consider an explicit deny rather than silent bag routing.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd server
|
||||
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj --filter "FullyQualifiedName~PlayerInventory"
|
||||
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj
|
||||
```
|
||||
|
||||
With Postgres configured (`ConnectionStrings__NeonSprawl`), confirm `[RequirePostgresFact]` persistence tests run (not skipped). No Bruno/manual QA for this story (no HTTP).
|
||||
|
|
@ -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,334 @@
|
|||
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!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryAddStack_WhenQuantityExceedsStackMax_ShouldSpanMultipleEmptySlots()
|
||||
{
|
||||
// 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",
|
||||
"field_stim_mk0",
|
||||
quantity: 41,
|
||||
registry,
|
||||
store);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(PlayerInventoryMutationKind.Applied, outcome.Kind);
|
||||
Assert.Equal(41, CountItem(outcome.Snapshot!, "field_stim_mk0"));
|
||||
Assert.Equal(3, OccupiedBagSlotCount(outcome.Snapshot!));
|
||||
Assert.Equal(20, outcome.Snapshot!.BagSlots[0].Quantity);
|
||||
Assert.Equal(20, outcome.Snapshot.BagSlots[1].Quantity);
|
||||
Assert.Equal(1, outcome.Snapshot.BagSlots[2].Quantity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryAddStack_ForUnknownPlayer_ShouldReturnStoreMissing()
|
||||
{
|
||||
// Arrange
|
||||
using var factory = new InMemoryWebApplicationFactory();
|
||||
var registry = factory.Services.GetRequiredService<IItemDefinitionRegistry>();
|
||||
var store = factory.Services.GetRequiredService<IPlayerInventoryStore>();
|
||||
|
||||
// Act
|
||||
var outcome = PlayerInventoryOperations.TryAddStack(
|
||||
"unknown-player-xyz",
|
||||
"scrap_metal_bulk",
|
||||
quantity: 1,
|
||||
registry,
|
||||
store);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(PlayerInventoryMutationKind.StoreMissing, outcome.Kind);
|
||||
Assert.Null(outcome.ReasonCode);
|
||||
Assert.Null(outcome.Snapshot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryAddStack_ForUnknownPlayer_WithInvalidQuantity_ShouldDenyWithReasonCode()
|
||||
{
|
||||
// Arrange
|
||||
using var factory = new InMemoryWebApplicationFactory();
|
||||
var registry = factory.Services.GetRequiredService<IItemDefinitionRegistry>();
|
||||
var store = factory.Services.GetRequiredService<IPlayerInventoryStore>();
|
||||
|
||||
// Act
|
||||
var outcome = PlayerInventoryOperations.TryAddStack(
|
||||
"unknown-player-xyz",
|
||||
"scrap_metal_bulk",
|
||||
quantity: 0,
|
||||
registry,
|
||||
store);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(PlayerInventoryMutationKind.Denied, outcome.Kind);
|
||||
Assert.Equal(PlayerInventoryReasonCodes.InvalidQuantity, outcome.ReasonCode);
|
||||
Assert.Null(outcome.Snapshot);
|
||||
}
|
||||
|
||||
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,159 @@
|
|||
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);
|
||||
}
|
||||
|
||||
// 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.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<Progra
|
|||
var masteryDir = MasteryCatalogPathResolution.TryDiscoverMasteryDirectory(AppContext.BaseDirectory)
|
||||
?? throw new InvalidOperationException(
|
||||
"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:MasteryDirectory", masteryDir);
|
||||
builder.UseSetting("Content:ItemsDirectory", itemsDir);
|
||||
|
||||
builder.ConfigureAppConfiguration((_, config) =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
|
|||
if (d.ServiceType == typeof(IPositionStateStore) ||
|
||||
d.ServiceType == typeof(IPlayerHotbarLoadoutStore) ||
|
||||
d.ServiceType == typeof(IPlayerSkillProgressionStore) ||
|
||||
d.ServiceType == typeof(IPlayerInventoryStore) ||
|
||||
d.ServiceType == typeof(IPlayerPerkStateStore) ||
|
||||
d.ServiceType == typeof(TimeProvider) ||
|
||||
d.ServiceType == typeof(IPlayerAbilityCooldownStore) ||
|
||||
|
|
@ -65,6 +66,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
|
|||
services.AddSingleton<IPositionStateStore, InMemoryPositionStateStore>();
|
||||
services.AddSingleton<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>();
|
||||
services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>();
|
||||
services.AddSingleton<IPlayerInventoryStore, InMemoryPlayerInventoryStore>();
|
||||
services.AddSingleton<IPlayerPerkStateStore, InMemoryPlayerPerkStateStore>();
|
||||
services.AddSingleton<IPlayerAbilityCooldownStore, InMemoryPlayerAbilityCooldownStore>();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
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);
|
||||
|
||||
/// <summary>
|
||||
/// Atomically reads the current snapshot and optionally persists a replacement under per-player lock (in-memory)
|
||||
/// or within one database transaction (Postgres).
|
||||
/// </summary>
|
||||
bool TryMutateSnapshot(
|
||||
string playerId,
|
||||
Func<PlayerInventorySnapshot, PlayerInventoryMutationWrite> mutator,
|
||||
out PlayerInventorySnapshot result);
|
||||
}
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
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)
|
||||
{
|
||||
snapshot = PlayerInventorySnapshot.Empty();
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (playerLocks.GetOrAdd(key, _ => new object()))
|
||||
{
|
||||
if (!byPlayer.TryGetValue(key, out var current))
|
||||
{
|
||||
snapshot = PlayerInventorySnapshot.Empty();
|
||||
return false;
|
||||
}
|
||||
|
||||
snapshot = Clone(current);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryReplaceSnapshot(string playerId, PlayerInventorySnapshot snapshot)
|
||||
{
|
||||
var key = NormalizePlayerId(playerId);
|
||||
if (key.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (playerLocks.GetOrAdd(key, _ => new object()))
|
||||
{
|
||||
if (!byPlayer.ContainsKey(key))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
byPlayer[key] = Clone(snapshot);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryMutateSnapshot(
|
||||
string playerId,
|
||||
Func<PlayerInventorySnapshot, PlayerInventoryMutationWrite> mutator,
|
||||
out PlayerInventorySnapshot result)
|
||||
{
|
||||
var key = NormalizePlayerId(playerId);
|
||||
if (key.Length == 0)
|
||||
{
|
||||
result = PlayerInventorySnapshot.Empty();
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (playerLocks.GetOrAdd(key, _ => new object()))
|
||||
{
|
||||
if (!byPlayer.TryGetValue(key, out var current))
|
||||
{
|
||||
result = PlayerInventorySnapshot.Empty();
|
||||
return false;
|
||||
}
|
||||
|
||||
var write = mutator(Clone(current));
|
||||
result = Clone(write.Value);
|
||||
if (write.Write)
|
||||
{
|
||||
byPlayer[key] = Clone(write.Value);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static PlayerInventorySnapshot Clone(PlayerInventorySnapshot source) =>
|
||||
new()
|
||||
{
|
||||
BagSlots = PlayerInventorySnapshot.CloneSlots(source.BagSlots),
|
||||
EquipmentSlots = PlayerInventorySnapshot.CloneSlots(source.EquipmentSlots),
|
||||
};
|
||||
|
||||
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 =>
|
||||
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,5 @@
|
|||
namespace NeonSprawl.Server.Game.Items;
|
||||
|
||||
/// <summary>Result of an atomic inventory transform (NEO-54).</summary>
|
||||
/// <param name="Write"><c>true</c> to persist <see cref="Value"/>; <c>false</c> to leave storage unchanged.</param>
|
||||
public readonly record struct PlayerInventoryMutationWrite(bool Write, PlayerInventorySnapshot Value);
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
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);
|
||||
}
|
||||
|
||||
string? denyReason = null;
|
||||
if (!store.TryMutateSnapshot(
|
||||
playerId,
|
||||
before =>
|
||||
{
|
||||
var container = PlayerInventorySnapshot.ContainerFromSlotKind(def.InventorySlotKind);
|
||||
var slots = PlayerInventorySnapshot.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)
|
||||
{
|
||||
denyReason = PlayerInventoryReasonCodes.InventoryFull;
|
||||
return new PlayerInventoryMutationWrite(Write: false, before);
|
||||
}
|
||||
|
||||
return new PlayerInventoryMutationWrite(Write: true, before.WithSlots(container, slots));
|
||||
},
|
||||
out var result))
|
||||
{
|
||||
return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.StoreMissing, null, null);
|
||||
}
|
||||
|
||||
if (denyReason is not null)
|
||||
{
|
||||
return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.Denied, denyReason, result);
|
||||
}
|
||||
|
||||
return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.Applied, null, result);
|
||||
}
|
||||
|
||||
/// <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);
|
||||
}
|
||||
|
||||
string? denyReason = null;
|
||||
if (!store.TryMutateSnapshot(
|
||||
playerId,
|
||||
before =>
|
||||
{
|
||||
var container = PlayerInventorySnapshot.ContainerFromSlotKind(def.InventorySlotKind);
|
||||
var slots = PlayerInventorySnapshot.CloneSlots(before.GetSlots(container));
|
||||
var available = CountQuantity(slots, def.Id);
|
||||
if (available < quantity)
|
||||
{
|
||||
denyReason = PlayerInventoryReasonCodes.InsufficientQuantity;
|
||||
return new PlayerInventoryMutationWrite(Write: false, 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;
|
||||
}
|
||||
|
||||
return new PlayerInventoryMutationWrite(Write: true, before.WithSlots(container, slots));
|
||||
},
|
||||
out var result))
|
||||
{
|
||||
return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.StoreMissing, null, null);
|
||||
}
|
||||
|
||||
if (denyReason is not null)
|
||||
{
|
||||
return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.Denied, denyReason, result);
|
||||
}
|
||||
|
||||
return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.Applied, null, result);
|
||||
}
|
||||
|
||||
private static PlayerInventoryMutationOutcome Deny(string playerId, IPlayerInventoryStore store, string reasonCode)
|
||||
{
|
||||
if (store.TryGetSnapshot(playerId, out var snapshot))
|
||||
{
|
||||
return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.Denied, reasonCode, snapshot);
|
||||
}
|
||||
|
||||
return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.Denied, reasonCode, null);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,64 @@
|
|||
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 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;
|
||||
}
|
||||
|
||||
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,191 @@
|
|||
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;
|
||||
}
|
||||
|
||||
ReplaceSnapshotRows(conn, norm, snapshot, tx);
|
||||
tx.Commit();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryMutateSnapshot(
|
||||
string playerId,
|
||||
Func<PlayerInventorySnapshot, PlayerInventoryMutationWrite> mutator,
|
||||
out PlayerInventorySnapshot result)
|
||||
{
|
||||
var norm = NormalizePlayerId(playerId);
|
||||
if (norm.Length == 0)
|
||||
{
|
||||
result = PlayerInventorySnapshot.Empty();
|
||||
return false;
|
||||
}
|
||||
|
||||
PostgresPlayerInventoryBootstrap.EnsureSchema(dataSource);
|
||||
using var conn = dataSource.OpenConnection();
|
||||
using var tx = conn.BeginTransaction();
|
||||
if (!PlayerExists(conn, norm, tx))
|
||||
{
|
||||
tx.Rollback();
|
||||
result = PlayerInventorySnapshot.Empty();
|
||||
return false;
|
||||
}
|
||||
|
||||
var current = ReadSnapshot(conn, norm, tx);
|
||||
var write = mutator(current);
|
||||
result = write.Value;
|
||||
if (write.Write)
|
||||
{
|
||||
ReplaceSnapshotRows(conn, norm, write.Value, tx);
|
||||
}
|
||||
|
||||
tx.Commit();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void ReplaceSnapshotRows(
|
||||
Npgsql.NpgsqlConnection conn,
|
||||
string playerIdNormalized,
|
||||
PlayerInventorySnapshot snapshot,
|
||||
Npgsql.NpgsqlTransaction tx)
|
||||
{
|
||||
using (var del = new Npgsql.NpgsqlCommand(
|
||||
"DELETE FROM player_inventory WHERE player_id = @pid;",
|
||||
conn,
|
||||
tx))
|
||||
{
|
||||
del.Parameters.AddWithValue("pid", playerIdNormalized);
|
||||
del.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
WriteOccupiedSlots(conn, playerIdNormalized, snapshot.BagSlots, InventoryContainerKind.Bag, tx);
|
||||
WriteOccupiedSlots(conn, playerIdNormalized, snapshot.EquipmentSlots, InventoryContainerKind.Equipment, tx);
|
||||
}
|
||||
|
||||
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,
|
||||
Npgsql.NpgsqlTransaction? tx = null)
|
||||
{
|
||||
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,
|
||||
tx);
|
||||
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"
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
|
|
|||
|
|
@ -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