Merge pull request #90 from ViPro-Technologies/NEO-55-player-inventory-getpost-bruno

NEO-55: Player inventory GET/POST HTTP + Bruno
pull/91/head
VinPropane 2026-05-23 23:08:25 -04:00 committed by GitHub
commit 9c35ad1e08
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 916 additions and 8 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,7 +68,9 @@ Epic 3 **Slice 1** — MVP inventory; `item_created`, transfer failures.
**Item definitions HTTP (NEO-53):** **`GET /game/world/item-definitions`** — versioned read-only projection (`schemaVersion` **1**, **`items`**) backed by **`IItemDefinitionRegistry`**; Bruno `bruno/neon-sprawl-server/item-definitions/`. Plan: [NEO-53 implementation plan](../../plans/NEO-53-implementation-plan.md).
**Player inventory store (NEO-54):** **`IPlayerInventoryStore`** + **`PlayerInventoryOperations`** in `server/NeonSprawl.Server/Game/Items/` — fixed **24 bag + 1 equipment** slots, stack limits from catalog, stable deny reason codes; in-memory + Postgres (`V005__player_inventory.sql`). HTTP deferred to NEO-55. Plan: [NEO-54 implementation plan](../../plans/NEO-54-implementation-plan.md).
**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).

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/`. **NEO-54 landed:** **`IPlayerInventoryStore`** + **`PlayerInventoryOperations`** — 24 bag + 1 equipment slots, stack rules, `V005` migration ([NEO-54](../../plans/NEO-54-implementation-plan.md)); [server README — Player inventory store (NEO-54)](../../../server/README.md#player-inventory-store-neo-54). **Still planned:** per-player inventory HTTP (NEO-55). | [NEO-50](../../plans/NEO-50-implementation-plan.md), [NEO-51](../../plans/NEO-51-implementation-plan.md), [NEO-52](../../plans/NEO-52-implementation-plan.md), [NEO-53](../../plans/NEO-53-implementation-plan.md), [NEO-54](../../plans/NEO-54-implementation-plan.md), [E3M3-prototype-backlog](../../plans/E3M3-prototype-backlog.md), [E3_M3](E3_M3_ItemizationAndInventorySchema.md) |
| 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), **NEO-53** (`GET /game/world/item-definitions`), and **NEO-54** (inventory store + stack/slot rules engine) moved the register row to **In Progress**; later slices update the alignment table as they land.
**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,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,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,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,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

@ -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,16 +58,27 @@ On success, **Information** logs include the resolved items directory path, dist
curl -sS -i "http://localhost:5253/game/world/item-definitions"
```
## Player inventory store (NEO-54)
## Player inventory (NEO-54 store, NEO-55 HTTP)
Server-authoritative per-player inventory lives in **`Game/Items/`** as **`IPlayerInventoryStore`** + **`PlayerInventoryOperations`**. There is **no HTTP surface in NEO-54****`GET` / `POST` inventory** lands in [NEO-55](../../docs/plans/NEO-55-implementation-plan.md).
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` |
**Mutations:** **`TryAddStack`** and **`TryRemoveStack`** route items by catalog metadata, merge stacks up to **`ItemDef.stackMax`**, and use **all-or-nothing** adds (no partial silent loss). Stable deny **`reasonCode`** values:
**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 |
|------|---------|
@ -76,7 +87,7 @@ Server-authoritative per-player inventory lives in **`Game/Items/`** as **`IPlay
| **`insufficient_quantity`** | Remove requested more than held |
| **`invalid_quantity`** | Non-positive quantity |
**Persistence:** same **[NEO-29-style](#position-persistence-neo-8) split** as skill progression — **`player_inventory`** in PostgreSQL when **`ConnectionStrings:NeonSprawl`** is set (DDL [`V005__player_inventory.sql`](../db/migrations/V005__player_inventory.sql), sparse occupied-slot rows), otherwise in-memory fallback seeding the configured dev player. Plan: [NEO-54 implementation plan](../../docs/plans/NEO-54-implementation-plan.md).
**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)