Compare commits

...

17 Commits

Author SHA1 Message Date
VinPropane 9c35ad1e08
Merge pull request #90 from ViPro-Technologies/NEO-55-player-inventory-getpost-bruno
NEO-55: Player inventory GET/POST HTTP + Bruno
2026-05-23 23:08:25 -04:00
VinPropane b8ee4b55ee NEO-55: fix Bruno local module resolution for inventory helper
Move helper to scripts/ and whitelist via bruno.json
additionalContextRoots so pre-request require() works in Bruno.
2026-05-23 22:59:58 -04:00
VinPropane 1cbe6302b5 NEO-55: make inventory Bruno tests order-independent
Shared inventory-api-helper seeds stock and records baselines in
pre-request scripts; assertions use exact deltas instead of cumulative
at.least checks. Harden C# remove round-trip add assertion.
2026-05-23 22:57:53 -04:00
VinPropane 00ada50b95 NEO-55: fix Bruno GET inventory test for persistent dev player state
Assert slot shape rules instead of requiring an all-empty grid when
dev-local-1 already holds items from prior collection runs.
2026-05-23 22:53:50 -04:00
VinPropane fddbb95a45 NEO-55: address code review suggestions for inventory HTTP
Add remove round-trip and invalid_quantity API tests, Bruno remove request,
dependency register NEO-55 footnote, and strike-through review follow-ups.
2026-05-23 22:51:59 -04:00
VinPropane d127de7bbb NEO-55: add code review for inventory GET/POST HTTP and Bruno. 2026-05-23 22:49:31 -04:00
VinPropane 69327f50b4 NEO-55: player inventory GET/POST HTTP, Bruno, and docs
Versioned snapshot read and mutationKind add/remove POST backed by
PlayerInventoryOperations; AAA API tests; Bruno happy path + deny case.
2026-05-23 22:45:27 -04:00
VinPropane db3b80d242 NEO-55: add implementation plan for inventory GET/POST + Bruno
Kickoff clarifications: single POST with mutationKind, full fixed slot
arrays, applied response field; no manual QA doc per user choice.
2026-05-23 22:41:05 -04:00
VinPropane 658b627903
Merge pull request #89 from ViPro-Technologies/NEO-54-player-inventory-store-stackslot-rules
NEO-54: Player inventory store + stack/slot rules engine
2026-05-23 22:35:42 -04:00
VinPropane 126d979939 NEO-54: dedupe slot cloning on PlayerInventorySnapshot 2026-05-23 19:54:27 -04:00
VinPropane 3f664261b7 NEO-54: preserve validation reason codes when player store missing
Deny helper returns Denied + reasonCode even without a snapshot bucket,
so invalid_quantity/invalid_item are stable before NEO-55 HTTP mapping.
2026-05-23 19:44:07 -04:00
VinPropane dafe3cb92f NEO-54: fix in-memory inventory races with atomic TryMutateSnapshot
Re-read snapshot under per-player lock; route add/remove through
TryMutateSnapshot so read-modify-write is serialized for in-memory
and wrapped in one transaction for Postgres.
2026-05-23 19:42:50 -04:00
VinPropane ac3900c194 NEO-54: address code review suggestions for inventory tests and docs 2026-05-23 19:31:26 -04:00
VinPropane 362608ed5f NEO-54: add code review for inventory store and rules engine. 2026-05-23 19:29:37 -04:00
VinPropane 3724a54f07 NEO-54: note inventory DI chained from item catalog registration 2026-05-23 19:27:06 -04:00
VinPropane 01e391b03a NEO-54: player inventory store, stack/slot rules engine, and tests 2026-05-23 19:26:57 -04:00
VinPropane e5ffa1b951 NEO-54: add implementation plan for inventory store and rules engine 2026-05-23 19:20:01 -04:00
38 changed files with 2419 additions and 4 deletions

View File

@ -1,5 +1,11 @@
{
"version": "1",
"name": "Neon Sprawl Server",
"type": "collection"
"type": "collection",
"scripts": {
"additionalContextRoots": ["./scripts"],
"filesystemAccess": {
"allow": true
}
}
}

View File

@ -0,0 +1,49 @@
meta {
name: GET inventory
type: http
seq: 1
}
docs {
NEO-55: per-player inventory read model; join with GET /game/world/item-definitions for stackMax and slot kinds.
Order-independent: validates fixed slot arrays and slot JSON shape only (not empty-grid assumptions).
}
get {
url: {{baseUrl}}/game/players/{{playerId}}/inventory
body: none
auth: none
}
tests {
test("returns 200 JSON with schema v1 and fixed slot arrays", function () {
expect(res.getStatus()).to.equal(200);
expect(res.getHeader("content-type")).to.contain("application/json");
const body = res.getBody();
expect(body.schemaVersion).to.equal(1);
expect(body.playerId).to.equal(bru.getEnvVar("playerId") || bru.getVar("playerId"));
expect(body.bagSlots).to.be.an("array");
expect(body.bagSlots.length).to.equal(24);
expect(body.equipmentSlots).to.be.an("array");
expect(body.equipmentSlots.length).to.equal(1);
});
test("empty slots omit itemId; occupied slots include itemId and positive quantity", function () {
const body = res.getBody();
const assertSlotShape = (slot) => {
expect(slot.slotIndex).to.be.a("number");
expect(slot.quantity).to.be.a("number");
expect(slot.quantity).to.be.at.least(0);
if (slot.quantity === 0) {
expect(slot.itemId).to.equal(undefined);
} else {
expect(slot.itemId).to.be.a("string");
expect(slot.itemId.length).to.be.at.least(1);
}
};
for (const slot of body.bagSlots) {
assertSlotShape(slot);
}
assertSlotShape(body.equipmentSlots[0]);
});
}

View File

@ -0,0 +1,47 @@
meta {
name: POST inventory add deny invalid item
type: http
seq: 3
}
docs {
NEO-55: unknown itemId denies with invalid_item. Pre-request snapshots scrap_metal_bulk total to prove inventory unchanged on deny.
}
script:pre-request {
const { getInventory, sumItemQuantity } = require("./scripts/inventory-api-helper.js");
const inventory = await getInventory(bru);
bru.setVar("scrapBeforeDeny", sumItemQuantity(inventory, "scrap_metal_bulk"));
}
post {
url: {{baseUrl}}/game/players/{{playerId}}/inventory
body: json
auth: none
}
body:json {
{
"schemaVersion": 1,
"mutationKind": "add",
"itemId": "not-a-prototype-item",
"quantity": 1
}
}
tests {
test("denies with invalid_item and leaves scrap_metal_bulk unchanged", function () {
expect(res.getStatus()).to.equal(200);
const body = res.getBody();
expect(body.schemaVersion).to.equal(1);
expect(body.applied).to.equal(false);
expect(body.reasonCode).to.equal("invalid_item");
expect(body.inventory).to.be.an("object");
expect(body.inventory.bagSlots.length).to.equal(24);
const before = bru.getVar("scrapBeforeDeny");
const after = [...body.inventory.bagSlots, ...body.inventory.equipmentSlots]
.filter((slot) => slot.itemId === "scrap_metal_bulk")
.reduce((total, slot) => total + slot.quantity, 0);
expect(after).to.equal(before);
});
}

View File

@ -0,0 +1,49 @@
meta {
name: POST inventory add
type: http
seq: 2
}
docs {
NEO-55: add stack via mutationKind add. Pre-request records scrap_metal_bulk baseline so assertions are order-independent.
}
script:pre-request {
const { getInventory, sumItemQuantity } = require("./scripts/inventory-api-helper.js");
const inventory = await getInventory(bru);
bru.setVar("scrapBeforeAdd", sumItemQuantity(inventory, "scrap_metal_bulk"));
bru.setVar("addQuantity", 5);
}
post {
url: {{baseUrl}}/game/players/{{playerId}}/inventory
body: json
auth: none
}
body:json {
{
"schemaVersion": 1,
"mutationKind": "add",
"itemId": "scrap_metal_bulk",
"quantity": 5
}
}
tests {
test("add returns 200 with applied true and increases scrap by requested quantity", function () {
expect(res.getStatus()).to.equal(200);
const body = res.getBody();
expect(body.schemaVersion).to.equal(1);
expect(body.applied).to.equal(true);
expect(body.reasonCode).to.equal(null);
expect(body.inventory).to.be.an("object");
expect(body.inventory.playerId).to.equal(bru.getEnvVar("playerId") || bru.getVar("playerId"));
const before = bru.getVar("scrapBeforeAdd");
const addQuantity = bru.getVar("addQuantity");
const after = [...body.inventory.bagSlots, ...body.inventory.equipmentSlots]
.filter((slot) => slot.itemId === "scrap_metal_bulk")
.reduce((total, slot) => total + slot.quantity, 0);
expect(after).to.equal(before + addQuantity);
});
}

View File

@ -0,0 +1,49 @@
meta {
name: POST inventory remove
type: http
seq: 4
}
docs {
NEO-55: remove stack via mutationKind remove. Pre-request seeds scrap_metal_bulk stock when needed so this request is order-independent.
}
script:pre-request {
const { ensureItemQuantity } = require("./scripts/inventory-api-helper.js");
const removeQuantity = 2;
const before = await ensureItemQuantity(bru, "scrap_metal_bulk", removeQuantity);
bru.setVar("scrapBeforeRemove", before);
bru.setVar("removeQuantity", removeQuantity);
}
post {
url: {{baseUrl}}/game/players/{{playerId}}/inventory
body: json
auth: none
}
body:json {
{
"schemaVersion": 1,
"mutationKind": "remove",
"itemId": "scrap_metal_bulk",
"quantity": 2
}
}
tests {
test("remove returns 200 with applied true and decreases scrap by requested quantity", function () {
expect(res.getStatus()).to.equal(200);
const body = res.getBody();
expect(body.schemaVersion).to.equal(1);
expect(body.applied).to.equal(true);
expect(body.reasonCode).to.equal(null);
expect(body.inventory).to.be.an("object");
const before = bru.getVar("scrapBeforeRemove");
const removeQuantity = bru.getVar("removeQuantity");
const after = [...body.inventory.bagSlots, ...body.inventory.equipmentSlots]
.filter((slot) => slot.itemId === "scrap_metal_bulk")
.reduce((total, slot) => total + slot.quantity, 0);
expect(after).to.equal(before - removeQuantity);
});
}

View File

@ -0,0 +1,7 @@
meta {
name: inventory
}
docs {
NEO-55 inventory HTTP. Requests are order-independent: pre-request scripts record baselines or seed stock via scripts/inventory-api-helper.js (see bruno.json additionalContextRoots).
}

View File

@ -0,0 +1,65 @@
const axios = require("axios");
function resolveConfig(bru) {
return {
baseUrl: bru.getEnvVar("baseUrl") || bru.getVar("baseUrl"),
playerId: bru.getEnvVar("playerId") || bru.getVar("playerId"),
jsonHeaders: { headers: { "Content-Type": "application/json" } },
};
}
function inventoryUrl(baseUrl, playerId) {
return `${baseUrl}/game/players/${playerId}/inventory`;
}
function sumItemQuantity(inventory, itemId) {
const slots = [...inventory.bagSlots, ...inventory.equipmentSlots];
return slots
.filter((slot) => slot.itemId === itemId)
.reduce((total, slot) => total + slot.quantity, 0);
}
async function getInventory(bru) {
const { baseUrl, playerId } = resolveConfig(bru);
const response = await axios.get(inventoryUrl(baseUrl, playerId));
if (response.status !== 200) {
throw new Error(`GET inventory failed: ${response.status}`);
}
return response.data;
}
async function postMutation(bru, body) {
const { baseUrl, playerId, jsonHeaders } = resolveConfig(bru);
return axios.post(inventoryUrl(baseUrl, playerId), body, jsonHeaders);
}
async function ensureItemQuantity(bru, itemId, minQuantity) {
const inventory = await getInventory(bru);
const current = sumItemQuantity(inventory, itemId);
if (current >= minQuantity) {
return current;
}
const response = await postMutation(bru, {
schemaVersion: 1,
mutationKind: "add",
itemId,
quantity: minQuantity - current,
});
if (response.status !== 200 || response.data?.applied !== true) {
throw new Error(
`seed add ${itemId} failed: ${response.status} ${JSON.stringify(response.data)}`,
);
}
const after = await getInventory(bru);
return sumItemQuantity(after, itemId);
}
module.exports = {
sumItemQuantity,
getInventory,
postMutation,
ensureItemQuantity,
};

View File

@ -68,6 +68,10 @@ 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`). Plan: [NEO-54 implementation plan](../../plans/NEO-54-implementation-plan.md).
**Player inventory HTTP (NEO-55):** **`GET` / `POST /game/players/{id}/inventory`** — versioned snapshot + **`mutationKind`** add/remove backed by **`PlayerInventoryOperations`**; Bruno `bruno/neon-sprawl-server/inventory/`. Plan: [NEO-55 implementation plan](../../plans/NEO-55-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

View File

@ -54,7 +54,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not
| E2.M3 | In Progress | **NEO-45 landed:** prototype **`salvage`** mastery catalog + CI gates (see [NEO-45 plan](../../plans/NEO-45-implementation-plan.md)). **NEO-46 landed:** fail-fast server load under `server/NeonSprawl.Server/Game/Mastery/``MasteryCatalogLoader`, `IMasteryCatalogRegistry`, cross-check vs `ISkillDefinitionRegistry`, Slice 4 + **`tierIndex`** 1..N gate; see [NEO-46 plan](../../plans/NEO-46-implementation-plan.md). **NEO-47 landed:** `PerkUnlockEngine`, `IPlayerPerkStateStore` + **`V004`**, level-up hook in skill XP grants; see [NEO-47 plan](../../plans/NEO-47-implementation-plan.md). **NEO-49 landed:** comment-only **`perk_unlock`** telemetry hook site in [`PerkUnlockEngine.TryUnlockPerks`](../../../server/NeonSprawl.Server/Game/Mastery/PerkUnlockEngine.cs) ([NEO-49 plan](../../plans/NEO-49-implementation-plan.md), [`NEO-49` manual QA](../../manual-qa/NEO-49.md)); [server README — Perk unlock telemetry (NEO-49)](../../../server/README.md#perk-unlock-engine-and-telemetry-hooks-neo-47-neo-49). **NEO-48 landed:** **`GET`/`POST /game/players/{id}/perk-state`** — `PerkStateApi` + DTOs in `Game/Mastery/` ([NEO-48](../../plans/NEO-48-implementation-plan.md), [`NEO-48` manual QA](../../manual-qa/NEO-48.md)); [server README — Perk state (NEO-48)](../../../server/README.md#perk-state-neo-48); Bruno `bruno/neon-sprawl-server/perk-state/`. | [NEO-45](../../plans/NEO-45-implementation-plan.md), [NEO-46](../../plans/NEO-46-implementation-plan.md), [NEO-47](../../plans/NEO-47-implementation-plan.md), [NEO-48](../../plans/NEO-48-implementation-plan.md), [NEO-49](../../plans/NEO-49-implementation-plan.md), [E2M3-pre-production-backlog](../../plans/E2M3-pre-production-backlog.md), [E2_M3](E2_M3_MasteryAndPerkUnlocks.md) |
| E2.M2 | In Progress | **NEO-37 landed:** versioned **`GET /game/players/{id}/skill-progression`** ([NEO-37](../../plans/NEO-37-implementation-plan.md)) — read model for every registered skill; known-player gate via `IPositionStateStore`; [server README — Skill progression snapshot (NEO-37)](../../../server/README.md#skill-progression-snapshot-neo-37); manual QA [`NEO-37`](../../manual-qa/NEO-37.md). **NEO-38 landed:** **`POST`** same path — grant apply, persistence (`IPlayerSkillProgressionStore`, `V003` migration), structured denies + **`levelUps`** ([NEO-38](../../plans/NEO-38-implementation-plan.md)); manual QA [`NEO-38`](../../manual-qa/NEO-38.md); Bruno `bruno/neon-sprawl-server/skill-progression/`; [server README — Skill progression grant (NEO-38)](../../../server/README.md#skill-progression-grant-neo-38). **NEO-39 landed:** data-driven `LevelCurve` content + schema + CI validation (`*_level_curve.json`) with fail-fast startup schema checks; progression GET/POST level resolution now uses `ISkillLevelCurve` content-backed thresholds ([NEO-39](../../plans/NEO-39-implementation-plan.md)); manual QA [`NEO-39`](../../manual-qa/NEO-39.md). **NEO-40 landed:** comment-only telemetry hook sites in [`SkillProgressionSnapshotApi.cs`](../../../server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotApi.cs) on **`POST …/skill-progression`** for future **`xp_grant`** / **`level_up`** ([NEO-40](../../plans/NEO-40-implementation-plan.md), [`NEO-40` manual QA](../../manual-qa/NEO-40.md)). **NEO-41 landed:** gather prototype — **`POST …/interact`** with **`kind: resource_node`** grants **`salvage`** + **`activity`** (10 XP) via [`SkillProgressionGrantOperations`](../../../server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantOperations.cs) ([NEO-41](../../plans/NEO-41-implementation-plan.md), [`NEO-41` manual QA](../../manual-qa/NEO-41.md)); [server README — Interaction](../../../server/README.md#interaction-neo-9). **NEO-42 landed (prep):** **`RefineActivitySkillXpGrant`** / **`RefineSkillXpConstants`** + deny-registry factory + tests — same NEO-38 grant stack; **E3.M2** must invoke on craft/refine success ([NEO-42](../../plans/NEO-42-implementation-plan.md), [`NEO-42` manual QA](../../manual-qa/NEO-42.md)); [server README — Craft / refine hook (NEO-42)](../../../server/README.md#craft--refine-hook--skill-xp-neo-42). **NEO-43 landed (prep):** **`MissionRewardSkillXpGrant`** / **`MissionRewardSkillXpConstants`** + deny-registry factory + tests — same NEO-38 grant stack with fixed **`sourceKind: mission_reward`**; **E7.M2** must invoke from quest hand-in ([NEO-43](../../plans/NEO-43-implementation-plan.md), [`NEO-43` manual QA](../../manual-qa/NEO-43.md)); [server README — Mission / quest reward (NEO-43)](../../../server/README.md#mission--quest-reward--skill-xp-neo-43). **Slice 3 still open:** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44). See [epic_02 — E2.M2 + Slice 3](../epics/epic_02_skills_and_progression.md). | [NEO-37](../../plans/NEO-37-implementation-plan.md), [NEO-38](../../plans/NEO-38-implementation-plan.md), [NEO-39](../../plans/NEO-39-implementation-plan.md), [NEO-40](../../plans/NEO-40-implementation-plan.md), [NEO-41](../../plans/NEO-41-implementation-plan.md), [NEO-42](../../plans/NEO-42-implementation-plan.md), [NEO-43](../../plans/NEO-43-implementation-plan.md), [E2_M2](E2_M2_XpAwardAndLevelEngine.md); label **`E2.M2`** on NEO-37NEO-41, NEO-42NEO-44 |
| E3.M1 | In Progress | **NEO-41 landed (prototype):** `POST …/interact` success on **`resource_node`** (`prototype_resource_node_alpha`) applies **`salvage`** skill XP (**`sourceKind: activity`**, 10 XP) via shared NEO-38 grant operations. **Still planned:** `GatherResult`, yields, inventory per [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md). | [NEO-41](../../plans/NEO-41-implementation-plan.md), [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md); `server/NeonSprawl.Server/Game/Interaction/`, `Game/Skills/` |
| E3.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)). **NEO-55 landed:** **`GET`/`POST /game/players/{id}/inventory`** — `PlayerInventoryApi` + DTOs in `Game/Items/` ([NEO-55](../../plans/NEO-55-implementation-plan.md)); [server README — Player inventory (NEO-54 store, NEO-55 HTTP)](../../../server/README.md#player-inventory-neo-54-store-neo-55-http); Bruno `bruno/neon-sprawl-server/inventory/`. **Still planned:** telemetry hook sites (NEO-56). | [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), [NEO-55](../../plans/NEO-55-implementation-plan.md), [E3M3-prototype-backlog](../../plans/E3M3-prototype-backlog.md), [E3_M3](E3_M3_ItemizationAndInventorySchema.md) |
---

View File

@ -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`), **NEO-54** (inventory store + stack/slot rules engine), and **NEO-55** (`GET`/`POST /game/players/{id}/inventory`) moved the register row to **In Progress**; later slices update the alignment table as they land.
### Epic 4 — World Topology

View File

@ -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 **023**. 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 defs 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.

View File

@ -0,0 +1,122 @@
# NEO-55 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-55 |
| **Title** | E3.M3: Player inventory GET/POST + Bruno |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-55/e3m3-player-inventory-getpost-bruno |
| **Module** | [E3.M3 — ItemizationAndInventorySchema](../decomposition/modules/E3_M3_ItemizationAndInventorySchema.md) · Epic 3 Slice 1 (E3M3-06) |
| **Branch** | `NEO-55-player-inventory-getpost-bruno` |
| **Precursor** | [NEO-54](https://linear.app/neon-sprawl/issue/NEO-54) — `IPlayerInventoryStore` + `PlayerInventoryOperations` (**Done** on `main`) |
| **Pattern** | [NEO-37](https://linear.app/neon-sprawl/issue/NEO-37) / [NEO-38](https://linear.app/neon-sprawl/issue/NEO-38) — `GET`/`POST /game/players/{id}/skill-progression` snapshot + mutating apply with structured deny |
## Kickoff clarifications
| Topic | Question | Agent recommendation | Answer |
|--------|----------|----------------------|--------|
| **POST shape** | Single POST vs separate add/remove routes? | **Single POST** on same path as GET with **`mutationKind`** **`add`** \| **`remove`**, **`itemId`**, **`quantity`**, **`schemaVersion`** — mirrors NEO-37/38 one-route read/write pattern and maps 1:1 to **`PlayerInventoryOperations`**. | **User:** single POST + **`mutationKind`**. |
| **GET slots** | Full fixed arrays vs sparse occupied-only? | **Full fixed arrays****`bagSlots`** length **24**, **`equipmentSlots`** length **1**; empty slots **`{ slotIndex, quantity: 0 }`** with **`itemId`** omitted — matches NEO-54 snapshot model and supports client grid rendering. | **User:** full fixed arrays. |
| **POST success field** | **`applied`** vs **`granted`**? | **`applied`** — inventory mutations are add/remove, not XP grants; aligns with hotbar **`updated`** naming. | **User:** **`applied`**. |
| **Manual QA doc** | Add **`docs/manual-qa/NEO-55.md`**? | **Yes** — user-visible HTTP surface; same pattern as NEO-53. | **User:** **No** — Bruno + README only. |
## Goal, scope, and out-of-scope
**Goal:** Versioned **`GET /game/players/{id}/inventory`** snapshot and mutating **`POST`** for manual QA and future client, delegating rules to **`PlayerInventoryOperations`** (NEO-54).
**In scope (from Linear + [E3M3-06](E3M3-prototype-backlog.md#e3m3-06--player-inventory-http--bruno)):**
- **`GET /game/players/{id}/inventory`** — instances + fixed slot arrays for known player.
- **`POST /game/players/{id}/inventory`** — **`mutationKind`** **`add`** / **`remove`** with engine-backed deny reason codes.
- Known-player gate via **`IPositionStateStore`** (NEO-37 pattern).
- Bruno **`bruno/neon-sprawl-server/inventory/`** — happy path + at least one deny case.
- **`server/README.md`** inventory HTTP subsection.
- API integration tests (AAA).
**Out of scope (from Linear):**
- Godot HUD.
- Gather node depletion ([E3.M1](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md)).
- Telemetry hook sites ([NEO-56](https://linear.app/neon-sprawl/issue/NEO-56)).
- **`docs/manual-qa/NEO-55.md`** (kickoff decision — Bruno + README cover manual verification).
## Acceptance criteria checklist
- [x] GET returns instances + slots for known player (`dev-local-1` seeded empty inventory).
- [x] POST add/remove matches E3M3-05 engine rules (`stackMax`, all-or-nothing add, stable **`reasonCode`** denies).
- [x] Bruno exercises happy path + at least one deny (`inventory_full` or **`invalid_item`**).
## Technical approach
1. **Routes:** **`GET`** and **`POST`** **`/game/players/{id}/inventory`** — trim **`id`**; **404** when empty/unknown position (NEO-37 gate).
2. **GET response (`PlayerInventorySnapshotResponse`, `schemaVersion` **1**):**
- **`playerId`**, **`schemaVersion`**, **`bagSlots`** (length **24**), **`equipmentSlots`** (length **1**).
- Each slot row: **`slotIndex`**, optional **`itemId`** (omitted when empty), **`quantity`** (**0** when empty).
- Map from **`PlayerInventorySnapshot`** via internal **`BuildSnapshot(playerId, store)`** helper (mirror **`SkillProgressionSnapshotApi.BuildSnapshot`**).
3. **POST request (`PlayerInventoryMutationRequest`, `schemaVersion` **1**):**
- **`mutationKind`**: **`add`** \| **`remove`** (ordinal ignore-case).
- **`itemId`**, **`quantity`** (positive int for engine; non-positive handled by engine → **`invalid_quantity`** deny).
4. **POST response (`PlayerInventoryMutationResponse`, `schemaVersion` **1**):**
- **`applied`**: **`true`** on success, **`false`** on rule deny.
- **`reasonCode`**: **`null`** on success; engine codes on deny (`inventory_full`, `invalid_item`, `insufficient_quantity`, `invalid_quantity`).
- **`inventory`**: full snapshot echo (unchanged on deny, updated on success) — mirror NEO-38 progression echo.
5. **HTTP status mapping (NEO-38 precedent):**
- **400** — null body or **`schemaVersion`** mismatch; unknown **`mutationKind`** string.
- **404** — unknown player (position gate) or **`PlayerInventoryMutationKind.StoreMissing`**.
- **200** + JSON — rule **`Denied`** with **`applied: false`** + **`reasonCode`** + snapshot echo.
- **200** + JSON — **`Applied`** with **`applied: true`**, **`reasonCode: null`**, updated snapshot.
6. **Implementation files:** **`PlayerInventoryApi.cs`** + **`PlayerInventoryDtos.cs`** in **`Game/Items/`**; wire **`app.MapPlayerInventoryApi()`** from **`Program.cs`** after item catalog maps (inventory store already registered via **`AddItemDefinitionCatalog`** chain).
7. **Bruno (`bruno/neon-sprawl-server/inventory/`):**
- **`Get inventory.bru`** — 200, **`schemaVersion` 1**, **`bagSlots.length === 24`**, **`equipmentSlots.length === 1`**, all empty on fresh dev player.
- **`Post inventory add.bru`** — add **`scrap_metal_bulk`** qty **5**; assert **`applied` true**, slot shows item + quantity.
- **`Post inventory add deny invalid item.bru`** (or fill-bag deny) — assert **`applied` false** + stable **`reasonCode`**.
- **`folder.bru`** — match sibling folders.
8. **Docs (on land):** Expand **`server/README.md`** NEO-54 inventory section with GET/POST curl examples and reason codes. 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/PlayerInventoryDtos.cs` | Versioned GET snapshot + POST request/response DTOs (`schemaVersion` 1). |
| `server/NeonSprawl.Server/Game/Items/PlayerInventoryApi.cs` | `MapPlayerInventoryApi` — GET/POST routes, position gate, engine dispatch, JSON mapping. |
| `server/NeonSprawl.Server.Tests/Game/Items/PlayerInventoryApiTests.cs` | AAA HTTP integration: GET 404/200 empty grid; POST add happy; POST deny (`invalid_item`, `insufficient_quantity`); schema mismatch 400; unknown player 404. |
| `bruno/neon-sprawl-server/inventory/folder.bru` | Bruno folder metadata. |
| `bruno/neon-sprawl-server/inventory/Get inventory.bru` | GET happy path against `dev-local-1`. |
| `bruno/neon-sprawl-server/inventory/Post inventory add.bru` | POST add happy path (`scrap_metal_bulk`). |
| `bruno/neon-sprawl-server/inventory/Post inventory add deny invalid item.bru` | POST deny with `invalid_item`. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Program.cs` | Register **`MapPlayerInventoryApi()`** alongside other game APIs. |
| `server/README.md` | Document GET/POST inventory routes, request/response shapes, curl examples, reason codes; remove “no HTTP in NEO-54” deferral note. |
| `docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md` | **Related implementation slices** — inventory HTTP bullet (NEO-55). |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E3.M3 row — note NEO-55 GET/POST when landed. |
## Tests
| Test file | What it covers |
|-----------|----------------|
| `PlayerInventoryApiTests.cs` | **GET** unknown player → **404**. **GET** `dev-local-1`**200**, **`schemaVersion` 1**, **24** bag + **1** equipment slots, all empty. **POST add** `scrap_metal_bulk` qty **10****`applied` true**, quantity in bag slot(s), **`reasonCode` null**. **POST add** unknown item → **200**, **`applied` false**, **`invalid_item`**, inventory unchanged. **POST remove** without prior add → **`insufficient_quantity`**. **POST** bad **`schemaVersion`** → **400**. **POST** unknown **`mutationKind`** → **400**. **AAA** per [csharp-style](../../.cursor/rules/csharp-style.md). |
Bruno scripts complement automated tests for dev-server manual verification (no **`docs/manual-qa/NEO-55.md`** per kickoff).
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| **Unknown `mutationKind`** | **400 BadRequest** (client contract error), not engine deny — same class as schema mismatch. | `adopted` |
| **Empty slot JSON** | Omit **`itemId`** property when quantity 0 (System.Text.Json default for null optional). | `adopted` |
| **NEO-56 telemetry hooks** | No hook comments in this story — NEO-56 owns engine/POST hook placement. | `deferred` to NEO-56 |
| **Concurrent POSTs** | Last-write-wins at store layer (NEO-54 review note); acceptable for prototype QA. | `adopted` |
None blocking.

View File

@ -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-38s 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-50NEO-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-50NEO-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).

View File

@ -0,0 +1,56 @@
# Code review — NEO-55 player inventory GET/POST + Bruno
**Date:** 2026-05-23
**Scope:** Branch `NEO-55-player-inventory-getpost-bruno` · commit `69327f5` vs `origin/main`
**Base:** `origin/main`
## Verdict
**Approve with nits**
## Summary
NEO-55 adds versioned **`GET` / `POST /game/players/{id}/inventory`** with DTOs, position-gated reads, engine-backed add/remove mutations, and structured deny responses mirroring the NEO-37/38 skill-progression pattern. Implementation delegates all rules to **`PlayerInventoryOperations`** (NEO-54), maps HTTP status codes per plan (400 schema/`mutationKind`, 404 unknown player/store, 200 + `applied`/`reasonCode` for engine denies), and includes eight AAA integration tests plus three Bruno requests. Docs (plan, E3.M3 slice, alignment table, README) are updated and match the code. Risk is low for merge: thin HTTP adapter over a well-tested engine; remaining gaps are optional coverage and register footnote parity.
## Documentation checked
| Document | Result |
|----------|--------|
| [`docs/plans/NEO-55-implementation-plan.md`](../plans/NEO-55-implementation-plan.md) | **Matches** — single POST + `mutationKind`, fixed 24+1 slot arrays, `applied`/`reasonCode`/snapshot echo, status mapping, Bruno + README, no manual QA doc per kickoff; acceptance checklist complete. |
| [`docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md`](../decomposition/modules/E3_M3_ItemizationAndInventorySchema.md) | **Matches** — NEO-55 HTTP bullet under **Related implementation slices**. |
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E3.M3 row notes NEO-55 landed with README + Bruno pointers; NEO-56 telemetry still planned. |
| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | **Matches** — E3.M3 **In Progress**; footnote lists NEO-50NEO-55. |
| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **Matches** — inventory mutations remain server-authoritative; HTTP is read/apply surface only. |
| [`server/README.md`](../../server/README.md) | **Matches** — Player inventory section documents GET/POST shapes, curl examples, reason codes, persistence; NEO-54 deferral removed. |
Register/tracking: alignment table and dependency register footnote updated for NEO-55.
## Blocking issues
None.
## Suggestions
1. ~~**Dependency register footnote (optional)** — Extend the **E3.M3 note** in [`module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) to mention **NEO-55** (inventory HTTP) alongside NEO-50NEO-54, matching the alignment table and prior NEO-54 review pattern.~~ **Done.**
2. ~~**POST remove happy path (optional)** — Integration tests cover add success and remove deny (`insufficient_quantity`) but not add-then-remove success. A short round-trip test would lock the full POST contract for both `mutationKind` values.~~ **Done.**`PostInventory_ShouldApplyRemove_AfterAdd_WithUpdatedSnapshot` in `PlayerInventoryApiTests.cs`.
3. ~~**`invalid_quantity` HTTP smoke (optional)** — Engine returns `invalid_quantity` for non-positive quantity; no HTTP test asserts **200** + `applied: false` + that code. Low risk (engine tested in NEO-54) but would complete the documented reason-code surface at the API layer.~~ **Done.**`PostInventory_ShouldDenyNonPositiveQuantity_WithInvalidQuantity` in `PlayerInventoryApiTests.cs`.
## Nits
- ~~Nit: **`Post inventory add.bru`** uses `expect(scrap.quantity).to.be.at.least(5)` — correct for a cumulative dev server, but an exact `=== 5` assertion would fail if prior manual runs left stacks. Acceptable for prototype Bruno; document “fresh dev player” in folder docs if flakes appear.~~ **Done.**`folder.bru` docs note fresh-server baseline and cumulative stacks.
- Nit: Plan referenced a **`BuildSnapshot`** helper name (NEO-37 mirror); implementation uses **`MapSnapshot`** — behavior is equivalent, naming only.
- ~~Nit: No Bruno request for **`remove`** happy path — plan minimum (one deny) is met via `invalid_item`; a remove-after-add Bruno would complement manual QA.~~ **Done.**`Post inventory remove.bru` (seq 4).
## Verification
```bash
cd server
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj --filter "FullyQualifiedName~PlayerInventoryApiTests"
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj --filter "FullyQualifiedName~PlayerInventory"
```
Run Bruno folder `bruno/neon-sprawl-server/inventory/` against a local dev server (`dotnet run`, player `dev-local-1`).

View File

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

View File

@ -0,0 +1,234 @@
using System.Linq;
using System.Net;
using System.Net.Http.Json;
using NeonSprawl.Server.Game.Items;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Items;
/// <summary>Regression tests for <c>GET</c>/<c>POST …/inventory</c> (NEO-55).</summary>
public sealed class PlayerInventoryApiTests
{
private static PlayerInventoryMutationRequest Mutation(
string mutationKind,
string itemId,
int quantity,
int schemaVersion = PlayerInventoryMutationRequest.CurrentSchemaVersion) =>
new()
{
SchemaVersion = schemaVersion,
MutationKind = mutationKind,
ItemId = itemId,
Quantity = quantity,
};
[Fact]
public async Task GetInventory_ShouldReturnNotFound_WhenPlayerUnknown()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.GetAsync("/game/players/missing-player/inventory");
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task GetInventory_ShouldReturnSchemaV1_WithEmptyFixedSlotArrays()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.GetAsync("/game/players/dev-local-1/inventory");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<PlayerInventorySnapshotResponse>();
Assert.NotNull(body);
Assert.Equal(PlayerInventorySnapshotResponse.CurrentSchemaVersion, body!.SchemaVersion);
Assert.Equal("dev-local-1", body.PlayerId);
Assert.Equal(PlayerInventorySnapshot.BagSlotCount, body.BagSlots.Count);
Assert.Single(body.EquipmentSlots);
Assert.All(body.BagSlots, static slot =>
{
Assert.Null(slot.ItemId);
Assert.Equal(0, slot.Quantity);
});
Assert.All(body.EquipmentSlots, static slot =>
{
Assert.Null(slot.ItemId);
Assert.Equal(0, slot.Quantity);
});
Assert.Equal(Enumerable.Range(0, PlayerInventorySnapshot.BagSlotCount), body.BagSlots.Select(static s => s.SlotIndex));
Assert.Equal(0, body.EquipmentSlots[0].SlotIndex);
}
[Fact]
public async Task PostInventory_ShouldReturnBadRequest_WhenSchemaVersionMismatch()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var request = Mutation("add", "scrap_metal_bulk", quantity: 1, schemaVersion: 99);
// Act
var response = await client.PostAsJsonAsync("/game/players/dev-local-1/inventory", request);
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task PostInventory_ShouldReturnBadRequest_WhenMutationKindUnknown()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var request = Mutation("consume", "scrap_metal_bulk", quantity: 1);
// Act
var response = await client.PostAsJsonAsync("/game/players/dev-local-1/inventory", request);
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task PostInventory_ShouldReturnNotFound_WhenPlayerUnknown()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.PostAsJsonAsync(
"/game/players/missing-player/inventory",
Mutation("add", "scrap_metal_bulk", quantity: 1));
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task PostInventory_ShouldApplyAdd_WithUpdatedSnapshot()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/inventory",
Mutation("add", "scrap_metal_bulk", quantity: 10));
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<PlayerInventoryMutationResponse>();
Assert.NotNull(body);
Assert.True(body!.Applied);
Assert.Null(body.ReasonCode);
Assert.Equal("dev-local-1", body.Inventory.PlayerId);
var occupied = body.Inventory.BagSlots.Where(static s => s.Quantity > 0).ToList();
Assert.Single(occupied);
Assert.Equal("scrap_metal_bulk", occupied[0].ItemId);
Assert.Equal(10, occupied[0].Quantity);
}
[Fact]
public async Task PostInventory_ShouldApplyRemove_AfterAdd_WithUpdatedSnapshot()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var addResponse = await client.PostAsJsonAsync(
"/game/players/dev-local-1/inventory",
Mutation("add", "scrap_metal_bulk", quantity: 10));
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/inventory",
Mutation("remove", "scrap_metal_bulk", quantity: 4));
// Assert
Assert.Equal(HttpStatusCode.OK, addResponse.StatusCode);
var addBody = await addResponse.Content.ReadFromJsonAsync<PlayerInventoryMutationResponse>();
Assert.NotNull(addBody);
Assert.True(addBody!.Applied);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<PlayerInventoryMutationResponse>();
Assert.NotNull(body);
Assert.True(body!.Applied);
Assert.Null(body.ReasonCode);
var occupied = body.Inventory.BagSlots.Where(static s => s.Quantity > 0).ToList();
Assert.Single(occupied);
Assert.Equal("scrap_metal_bulk", occupied[0].ItemId);
Assert.Equal(6, occupied[0].Quantity);
}
[Fact]
public async Task PostInventory_ShouldDenyNonPositiveQuantity_WithInvalidQuantity()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/inventory",
Mutation("add", "scrap_metal_bulk", quantity: 0));
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<PlayerInventoryMutationResponse>();
Assert.NotNull(body);
Assert.False(body!.Applied);
Assert.Equal(PlayerInventoryReasonCodes.InvalidQuantity, body.ReasonCode);
Assert.All(body.Inventory.BagSlots, static slot => Assert.Equal(0, slot.Quantity));
}
[Fact]
public async Task PostInventory_ShouldDenyUnknownItem_WithInvalidItem_AndLeaveInventoryEmpty()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/inventory",
Mutation("add", "not-a-prototype-item", quantity: 1));
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<PlayerInventoryMutationResponse>();
Assert.NotNull(body);
Assert.False(body!.Applied);
Assert.Equal(PlayerInventoryReasonCodes.InvalidItem, body.ReasonCode);
Assert.All(body.Inventory.BagSlots, static slot => Assert.Equal(0, slot.Quantity));
}
[Fact]
public async Task PostInventory_ShouldDenyRemove_WithInsufficientQuantity()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/inventory",
Mutation("remove", "scrap_metal_bulk", quantity: 1));
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<PlayerInventoryMutationResponse>();
Assert.NotNull(body);
Assert.False(body!.Applied);
Assert.Equal(PlayerInventoryReasonCodes.InsufficientQuantity, body.ReasonCode);
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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) =>
{

View File

@ -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>();
});

View File

@ -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);
}

View File

@ -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();
}
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,134 @@
using NeonSprawl.Server.Game.PositionState;
namespace NeonSprawl.Server.Game.Items;
/// <summary>
/// Maps <c>GET</c>/<c>POST /game/players/{{id}}/inventory</c> — read snapshot and add/remove mutations (NEO-55).
/// </summary>
public static class PlayerInventoryApi
{
public static WebApplication MapPlayerInventoryApi(this WebApplication app)
{
app.MapGet(
"/game/players/{id}/inventory",
(string id, IPositionStateStore positions, IPlayerInventoryStore store) =>
{
var trimmedId = id.Trim();
if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _))
{
return Results.NotFound();
}
if (!store.TryGetSnapshot(trimmedId, out var snapshot))
{
return Results.NotFound();
}
return Results.Json(MapSnapshot(trimmedId, snapshot));
});
app.MapPost(
"/game/players/{id}/inventory",
(string id, PlayerInventoryMutationRequest? body, IPositionStateStore positions,
IItemDefinitionRegistry registry, IPlayerInventoryStore store) =>
{
if (body is null || body.SchemaVersion != PlayerInventoryMutationRequest.CurrentSchemaVersion)
{
return Results.BadRequest();
}
if (!TryParseMutationKind(body.MutationKind, out var isAdd))
{
return Results.BadRequest();
}
var trimmedId = id.Trim();
if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _))
{
return Results.NotFound();
}
var outcome = isAdd
? PlayerInventoryOperations.TryAddStack(trimmedId, body.ItemId, body.Quantity, registry, store)
: PlayerInventoryOperations.TryRemoveStack(trimmedId, body.ItemId, body.Quantity, registry, store);
return outcome.Kind switch
{
PlayerInventoryMutationKind.StoreMissing => Results.NotFound(),
PlayerInventoryMutationKind.Denied => Results.Json(
BuildMutationResponse(trimmedId, applied: false, outcome.ReasonCode, outcome.Snapshot, store)),
PlayerInventoryMutationKind.Applied => Results.Json(
BuildMutationResponse(trimmedId, applied: true, reasonCode: null, outcome.Snapshot, store)),
_ => throw new InvalidOperationException($"Unexpected inventory mutation outcome: {outcome.Kind}"),
};
});
return app;
}
internal static PlayerInventorySnapshotResponse MapSnapshot(string playerId, PlayerInventorySnapshot snapshot) =>
new()
{
PlayerId = playerId,
SchemaVersion = PlayerInventorySnapshotResponse.CurrentSchemaVersion,
BagSlots = MapSlots(snapshot.BagSlots),
EquipmentSlots = MapSlots(snapshot.EquipmentSlots),
};
private static IReadOnlyList<InventorySlotStateJson> MapSlots(InventorySlotState[] slots)
{
var rows = new InventorySlotStateJson[slots.Length];
for (var i = 0; i < slots.Length; i++)
{
var slot = slots[i];
rows[i] = slot.IsEmpty
? new InventorySlotStateJson { SlotIndex = slot.SlotIndex, Quantity = 0 }
: new InventorySlotStateJson
{
SlotIndex = slot.SlotIndex,
ItemId = slot.ItemId,
Quantity = slot.Quantity,
};
}
return rows;
}
private static PlayerInventoryMutationResponse BuildMutationResponse(
string playerId,
bool applied,
string? reasonCode,
PlayerInventorySnapshot? snapshot,
IPlayerInventoryStore store)
{
if (snapshot is null && !store.TryGetSnapshot(playerId, out snapshot))
{
snapshot = PlayerInventorySnapshot.Empty();
}
return new PlayerInventoryMutationResponse
{
Applied = applied,
ReasonCode = reasonCode,
Inventory = MapSnapshot(playerId, snapshot!),
};
}
private static bool TryParseMutationKind(string? raw, out bool isAdd)
{
if (string.Equals(raw, "add", StringComparison.OrdinalIgnoreCase))
{
isAdd = true;
return true;
}
if (string.Equals(raw, "remove", StringComparison.OrdinalIgnoreCase))
{
isAdd = false;
return true;
}
isAdd = false;
return false;
}
}

View File

@ -0,0 +1,76 @@
using System.Text.Json.Serialization;
namespace NeonSprawl.Server.Game.Items;
/// <summary>JSON body for <c>GET /game/players/{{id}}/inventory</c> (NEO-55).</summary>
public sealed class PlayerInventorySnapshotResponse
{
public const int CurrentSchemaVersion = 1;
[JsonPropertyName("schemaVersion")]
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
[JsonPropertyName("playerId")]
public required string PlayerId { get; init; }
/// <summary>Fixed bag slots 0..23 in ascending order.</summary>
[JsonPropertyName("bagSlots")]
public required IReadOnlyList<InventorySlotStateJson> BagSlots { get; init; }
/// <summary>Fixed equipment slot index 0.</summary>
[JsonPropertyName("equipmentSlots")]
public required IReadOnlyList<InventorySlotStateJson> EquipmentSlots { get; init; }
}
/// <summary>POST body for inventory add/remove (NEO-55).</summary>
public sealed class PlayerInventoryMutationRequest
{
public const int CurrentSchemaVersion = 1;
[JsonPropertyName("schemaVersion")]
public int SchemaVersion { get; init; }
/// <remarks><c>add</c> or <c>remove</c> (ordinal ignore-case).</remarks>
[JsonPropertyName("mutationKind")]
public required string MutationKind { get; init; }
[JsonPropertyName("itemId")]
public required string ItemId { get; init; }
[JsonPropertyName("quantity")]
public int Quantity { get; init; }
}
/// <summary>POST response for inventory mutations — applied or structured deny (NEO-55).</summary>
public sealed class PlayerInventoryMutationResponse
{
public const int CurrentSchemaVersion = 1;
[JsonPropertyName("schemaVersion")]
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
[JsonPropertyName("applied")]
public bool Applied { get; init; }
/// <remarks><c>null</c> on success; stable snake_case deny codes from <see cref="PlayerInventoryReasonCodes"/> on deny.</remarks>
[JsonPropertyName("reasonCode")]
public string? ReasonCode { get; init; }
[JsonPropertyName("inventory")]
public required PlayerInventorySnapshotResponse Inventory { get; init; }
}
/// <summary>Wire shape for one inventory slot in GET/POST responses.</summary>
public sealed class InventorySlotStateJson
{
[JsonPropertyName("slotIndex")]
public int SlotIndex { get; init; }
/// <summary>Omitted when the slot is empty.</summary>
[JsonPropertyName("itemId")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ItemId { get; init; }
[JsonPropertyName("quantity")]
public int Quantity { get; init; }
}

View File

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

View File

@ -0,0 +1,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);

View File

@ -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;
}
}

View File

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

View File

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

View File

@ -0,0 +1,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;
}

View File

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

View File

@ -0,0 +1,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();
}
}

View File

@ -38,6 +38,7 @@ app.MapInteractionApi();
app.MapInteractablesWorldApi();
app.MapSkillDefinitionsWorldApi();
app.MapItemDefinitionsWorldApi();
app.MapPlayerInventoryApi();
app.MapSkillProgressionSnapshotApi();
app.MapPerkStateApi();
if (app.Environment.IsDevelopment() ||

View File

@ -58,6 +58,37 @@ On success, **Information** logs include the resolved items directory path, dist
curl -sS -i "http://localhost:5253/game/world/item-definitions"
```
## Player inventory (NEO-54 store, NEO-55 HTTP)
Server-authoritative per-player inventory lives in **`Game/Items/`** as **`IPlayerInventoryStore`** + **`PlayerInventoryOperations`**. **NEO-55** exposes versioned **`GET` / `POST /game/players/{id}/inventory`** for manual QA and future client. Plan: [NEO-55 implementation plan](../../docs/plans/NEO-55-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/inventory/`.
| Container | Fixed slots | Accepts `inventorySlotKind` |
|-----------|-------------|-------------------------------|
| **Bag** | **24** (indices 023) | `bag` |
| **Equipment** | **1** (index 0) | `equipment` |
**GET** returns **`schemaVersion` 1**, **`playerId`**, **`bagSlots`** (length 24), **`equipmentSlots`** (length 1). Empty slots use **`quantity: 0`** with **`itemId`** omitted. **404** when the player is unknown (position gate) or missing from the inventory store.
**POST** body (**`schemaVersion` 1**): **`mutationKind`** **`add`** or **`remove`**, **`itemId`**, **`quantity`**. Response: **`applied`**, **`reasonCode`** (`null` on success), **`inventory`** snapshot echo. **400** on schema mismatch or unknown **`mutationKind`**; **404** on unknown player; **200** with **`applied: false`** + stable deny codes on rule failure.
```bash
curl -sS "http://localhost:5253/game/players/dev-local-1/inventory"
curl -sS -X POST "http://localhost:5253/game/players/dev-local-1/inventory" \
-H "Content-Type: application/json" \
-d '{"schemaVersion":1,"mutationKind":"add","itemId":"scrap_metal_bulk","quantity":5}'
```
**Engine rules (NEO-54):** **`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. Store/engine 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.

View File

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