diff --git a/bruno/neon-sprawl-server/bruno.json b/bruno/neon-sprawl-server/bruno.json index 9535019..177dd65 100644 --- a/bruno/neon-sprawl-server/bruno.json +++ b/bruno/neon-sprawl-server/bruno.json @@ -1,5 +1,11 @@ { "version": "1", "name": "Neon Sprawl Server", - "type": "collection" + "type": "collection", + "scripts": { + "additionalContextRoots": ["./scripts"], + "filesystemAccess": { + "allow": true + } + } } diff --git a/bruno/neon-sprawl-server/inventory/Get inventory.bru b/bruno/neon-sprawl-server/inventory/Get inventory.bru new file mode 100644 index 0000000..4919224 --- /dev/null +++ b/bruno/neon-sprawl-server/inventory/Get inventory.bru @@ -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]); + }); +} diff --git a/bruno/neon-sprawl-server/inventory/Post inventory add deny invalid item.bru b/bruno/neon-sprawl-server/inventory/Post inventory add deny invalid item.bru new file mode 100644 index 0000000..62c95c4 --- /dev/null +++ b/bruno/neon-sprawl-server/inventory/Post inventory add deny invalid item.bru @@ -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); + }); +} diff --git a/bruno/neon-sprawl-server/inventory/Post inventory add.bru b/bruno/neon-sprawl-server/inventory/Post inventory add.bru new file mode 100644 index 0000000..506cbdd --- /dev/null +++ b/bruno/neon-sprawl-server/inventory/Post inventory add.bru @@ -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); + }); +} diff --git a/bruno/neon-sprawl-server/inventory/Post inventory remove.bru b/bruno/neon-sprawl-server/inventory/Post inventory remove.bru new file mode 100644 index 0000000..a331356 --- /dev/null +++ b/bruno/neon-sprawl-server/inventory/Post inventory remove.bru @@ -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); + }); +} diff --git a/bruno/neon-sprawl-server/inventory/folder.bru b/bruno/neon-sprawl-server/inventory/folder.bru new file mode 100644 index 0000000..612f395 --- /dev/null +++ b/bruno/neon-sprawl-server/inventory/folder.bru @@ -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). +} diff --git a/bruno/neon-sprawl-server/scripts/inventory-api-helper.js b/bruno/neon-sprawl-server/scripts/inventory-api-helper.js new file mode 100644 index 0000000..ecf4dec --- /dev/null +++ b/bruno/neon-sprawl-server/scripts/inventory-api-helper.js @@ -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, +}; diff --git a/docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md b/docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md index f1bdd31..942bc0e 100644 --- a/docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md +++ b/docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md @@ -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). diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index 7fee86f..5d147ec 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -54,7 +54,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not | E2.M3 | In Progress | **NEO-45 landed:** prototype **`salvage`** mastery catalog + CI gates (see [NEO-45 plan](../../plans/NEO-45-implementation-plan.md)). **NEO-46 landed:** fail-fast server load under `server/NeonSprawl.Server/Game/Mastery/` — `MasteryCatalogLoader`, `IMasteryCatalogRegistry`, cross-check vs `ISkillDefinitionRegistry`, Slice 4 + **`tierIndex`** 1..N gate; see [NEO-46 plan](../../plans/NEO-46-implementation-plan.md). **NEO-47 landed:** `PerkUnlockEngine`, `IPlayerPerkStateStore` + **`V004`**, level-up hook in skill XP grants; see [NEO-47 plan](../../plans/NEO-47-implementation-plan.md). **NEO-49 landed:** comment-only **`perk_unlock`** telemetry hook site in [`PerkUnlockEngine.TryUnlockPerks`](../../../server/NeonSprawl.Server/Game/Mastery/PerkUnlockEngine.cs) ([NEO-49 plan](../../plans/NEO-49-implementation-plan.md), [`NEO-49` manual QA](../../manual-qa/NEO-49.md)); [server README — Perk unlock telemetry (NEO-49)](../../../server/README.md#perk-unlock-engine-and-telemetry-hooks-neo-47-neo-49). **NEO-48 landed:** **`GET`/`POST /game/players/{id}/perk-state`** — `PerkStateApi` + DTOs in `Game/Mastery/` ([NEO-48](../../plans/NEO-48-implementation-plan.md), [`NEO-48` manual QA](../../manual-qa/NEO-48.md)); [server README — Perk state (NEO-48)](../../../server/README.md#perk-state-neo-48); Bruno `bruno/neon-sprawl-server/perk-state/`. | [NEO-45](../../plans/NEO-45-implementation-plan.md), [NEO-46](../../plans/NEO-46-implementation-plan.md), [NEO-47](../../plans/NEO-47-implementation-plan.md), [NEO-48](../../plans/NEO-48-implementation-plan.md), [NEO-49](../../plans/NEO-49-implementation-plan.md), [E2M3-pre-production-backlog](../../plans/E2M3-pre-production-backlog.md), [E2_M3](E2_M3_MasteryAndPerkUnlocks.md) | | E2.M2 | In Progress | **NEO-37 landed:** versioned **`GET /game/players/{id}/skill-progression`** ([NEO-37](../../plans/NEO-37-implementation-plan.md)) — read model for every registered skill; known-player gate via `IPositionStateStore`; [server README — Skill progression snapshot (NEO-37)](../../../server/README.md#skill-progression-snapshot-neo-37); manual QA [`NEO-37`](../../manual-qa/NEO-37.md). **NEO-38 landed:** **`POST`** same path — grant apply, persistence (`IPlayerSkillProgressionStore`, `V003` migration), structured denies + **`levelUps`** ([NEO-38](../../plans/NEO-38-implementation-plan.md)); manual QA [`NEO-38`](../../manual-qa/NEO-38.md); Bruno `bruno/neon-sprawl-server/skill-progression/`; [server README — Skill progression grant (NEO-38)](../../../server/README.md#skill-progression-grant-neo-38). **NEO-39 landed:** data-driven `LevelCurve` content + schema + CI validation (`*_level_curve.json`) with fail-fast startup schema checks; progression GET/POST level resolution now uses `ISkillLevelCurve` content-backed thresholds ([NEO-39](../../plans/NEO-39-implementation-plan.md)); manual QA [`NEO-39`](../../manual-qa/NEO-39.md). **NEO-40 landed:** comment-only telemetry hook sites in [`SkillProgressionSnapshotApi.cs`](../../../server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotApi.cs) on **`POST …/skill-progression`** for future **`xp_grant`** / **`level_up`** ([NEO-40](../../plans/NEO-40-implementation-plan.md), [`NEO-40` manual QA](../../manual-qa/NEO-40.md)). **NEO-41 landed:** gather prototype — **`POST …/interact`** with **`kind: resource_node`** grants **`salvage`** + **`activity`** (10 XP) via [`SkillProgressionGrantOperations`](../../../server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantOperations.cs) ([NEO-41](../../plans/NEO-41-implementation-plan.md), [`NEO-41` manual QA](../../manual-qa/NEO-41.md)); [server README — Interaction](../../../server/README.md#interaction-neo-9). **NEO-42 landed (prep):** **`RefineActivitySkillXpGrant`** / **`RefineSkillXpConstants`** + deny-registry factory + tests — same NEO-38 grant stack; **E3.M2** must invoke on craft/refine success ([NEO-42](../../plans/NEO-42-implementation-plan.md), [`NEO-42` manual QA](../../manual-qa/NEO-42.md)); [server README — Craft / refine hook (NEO-42)](../../../server/README.md#craft--refine-hook--skill-xp-neo-42). **NEO-43 landed (prep):** **`MissionRewardSkillXpGrant`** / **`MissionRewardSkillXpConstants`** + deny-registry factory + tests — same NEO-38 grant stack with fixed **`sourceKind: mission_reward`**; **E7.M2** must invoke from quest hand-in ([NEO-43](../../plans/NEO-43-implementation-plan.md), [`NEO-43` manual QA](../../manual-qa/NEO-43.md)); [server README — Mission / quest reward (NEO-43)](../../../server/README.md#mission--quest-reward--skill-xp-neo-43). **Slice 3 still open:** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44). See [epic_02 — E2.M2 + Slice 3](../epics/epic_02_skills_and_progression.md). | [NEO-37](../../plans/NEO-37-implementation-plan.md), [NEO-38](../../plans/NEO-38-implementation-plan.md), [NEO-39](../../plans/NEO-39-implementation-plan.md), [NEO-40](../../plans/NEO-40-implementation-plan.md), [NEO-41](../../plans/NEO-41-implementation-plan.md), [NEO-42](../../plans/NEO-42-implementation-plan.md), [NEO-43](../../plans/NEO-43-implementation-plan.md), [E2_M2](E2_M2_XpAwardAndLevelEngine.md); label **`E2.M2`** on NEO-37–NEO-41, NEO-42–NEO-44 | | E3.M1 | In Progress | **NEO-41 landed (prototype):** `POST …/interact` success on **`resource_node`** (`prototype_resource_node_alpha`) applies **`salvage`** skill XP (**`sourceKind: activity`**, 10 XP) via shared NEO-38 grant operations. **Still planned:** `GatherResult`, yields, inventory per [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md). | [NEO-41](../../plans/NEO-41-implementation-plan.md), [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md); `server/NeonSprawl.Server/Game/Interaction/`, `Game/Skills/` | -| E3.M3 | In Progress | **NEO-50 landed:** frozen prototype six-item catalog in [`content/items/prototype_items.json`](../../../content/items/prototype_items.json); [`item-def.schema.json`](../../../content/schemas/item-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py). **NEO-51 landed:** fail-fast server load of `content/items/*_items.json` at startup — `server/NeonSprawl.Server/Game/Items/` ([NEO-51](../../plans/NEO-51-implementation-plan.md)); [server README — Item catalog](../../../server/README.md#item-catalog-contentitems-neo-51). **NEO-52 landed:** injectable **`IItemDefinitionRegistry`** + lookup tests ([NEO-52](../../plans/NEO-52-implementation-plan.md)). **NEO-53 landed:** **`GET /game/world/item-definitions`** — `ItemDefinitionsWorldApi` + DTOs in `Game/Items/` ([NEO-53](../../plans/NEO-53-implementation-plan.md), [`NEO-53` manual QA](../../manual-qa/NEO-53.md)); [server README — Item definitions (NEO-53)](../../../server/README.md#item-definitions-neo-53); Bruno `bruno/neon-sprawl-server/item-definitions/`. **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) | --- diff --git a/docs/decomposition/modules/module_dependency_register.md b/docs/decomposition/modules/module_dependency_register.md index a6b1fe1..6b59763 100644 --- a/docs/decomposition/modules/module_dependency_register.md +++ b/docs/decomposition/modules/module_dependency_register.md @@ -48,7 +48,7 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen | E3.M4 | SinkAndDurabilityLifecycle | E3.M3, E8.M3 | DurabilityState, ItemSinkEvent, RepairCostRule | Pre-production | Planned | | E3.M5 | EconomyBalancePolicy | E3.M4, E9.M2 | EconomyPolicy, PriceBandRule, FaucetSinkRatio | Pre-production | Planned | -**E3.M3 note:** Epic 3 **Slice 1** backlog in Linear ([Epic 3 — Crafting, Gathering, and Itemization Economy](https://linear.app/neon-sprawl/project/epic-3-crafting-gathering-and-itemization-economy-65785ed05bc2)): [NEO-50](https://linear.app/neon-sprawl/issue/NEO-50) → [NEO-56](https://linear.app/neon-sprawl/issue/NEO-56); label **`E3.M3`**. See [E3M3-prototype-backlog.md](../../plans/E3M3-prototype-backlog.md), [E3_M3_ItemizationAndInventorySchema.md](E3_M3_ItemizationAndInventorySchema.md). **NEO-50** (content + CI), **NEO-51** (server fail-fast load), **NEO-52** (`IItemDefinitionRegistry` + DI), **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 diff --git a/docs/plans/NEO-55-implementation-plan.md b/docs/plans/NEO-55-implementation-plan.md new file mode 100644 index 0000000..f356d21 --- /dev/null +++ b/docs/plans/NEO-55-implementation-plan.md @@ -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. diff --git a/docs/reviews/2026-05-23-NEO-55.md b/docs/reviews/2026-05-23-NEO-55.md new file mode 100644 index 0000000..b94942f --- /dev/null +++ b/docs/reviews/2026-05-23-NEO-55.md @@ -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-50–NEO-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-50–NEO-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`). diff --git a/server/NeonSprawl.Server.Tests/Game/Items/PlayerInventoryApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Items/PlayerInventoryApiTests.cs new file mode 100644 index 0000000..814eb60 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Items/PlayerInventoryApiTests.cs @@ -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; + +/// Regression tests for GET/POST …/inventory (NEO-55). +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(); + 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(); + 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(); + Assert.NotNull(addBody); + Assert.True(addBody!.Applied); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + 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(); + 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(); + 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(); + Assert.NotNull(body); + Assert.False(body!.Applied); + Assert.Equal(PlayerInventoryReasonCodes.InsufficientQuantity, body.ReasonCode); + } +} diff --git a/server/NeonSprawl.Server/Game/Items/PlayerInventoryApi.cs b/server/NeonSprawl.Server/Game/Items/PlayerInventoryApi.cs new file mode 100644 index 0000000..a8135a7 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Items/PlayerInventoryApi.cs @@ -0,0 +1,134 @@ +using NeonSprawl.Server.Game.PositionState; + +namespace NeonSprawl.Server.Game.Items; + +/// +/// Maps GET/POST /game/players/{{id}}/inventory — read snapshot and add/remove mutations (NEO-55). +/// +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 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; + } +} diff --git a/server/NeonSprawl.Server/Game/Items/PlayerInventoryDtos.cs b/server/NeonSprawl.Server/Game/Items/PlayerInventoryDtos.cs new file mode 100644 index 0000000..ddd75db --- /dev/null +++ b/server/NeonSprawl.Server/Game/Items/PlayerInventoryDtos.cs @@ -0,0 +1,76 @@ +using System.Text.Json.Serialization; + +namespace NeonSprawl.Server.Game.Items; + +/// JSON body for GET /game/players/{{id}}/inventory (NEO-55). +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; } + + /// Fixed bag slots 0..23 in ascending order. + [JsonPropertyName("bagSlots")] + public required IReadOnlyList BagSlots { get; init; } + + /// Fixed equipment slot index 0. + [JsonPropertyName("equipmentSlots")] + public required IReadOnlyList EquipmentSlots { get; init; } +} + +/// POST body for inventory add/remove (NEO-55). +public sealed class PlayerInventoryMutationRequest +{ + public const int CurrentSchemaVersion = 1; + + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } + + /// add or remove (ordinal ignore-case). + [JsonPropertyName("mutationKind")] + public required string MutationKind { get; init; } + + [JsonPropertyName("itemId")] + public required string ItemId { get; init; } + + [JsonPropertyName("quantity")] + public int Quantity { get; init; } +} + +/// POST response for inventory mutations — applied or structured deny (NEO-55). +public sealed class PlayerInventoryMutationResponse +{ + public const int CurrentSchemaVersion = 1; + + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = CurrentSchemaVersion; + + [JsonPropertyName("applied")] + public bool Applied { get; init; } + + /// null on success; stable snake_case deny codes from on deny. + [JsonPropertyName("reasonCode")] + public string? ReasonCode { get; init; } + + [JsonPropertyName("inventory")] + public required PlayerInventorySnapshotResponse Inventory { get; init; } +} + +/// Wire shape for one inventory slot in GET/POST responses. +public sealed class InventorySlotStateJson +{ + [JsonPropertyName("slotIndex")] + public int SlotIndex { get; init; } + + /// Omitted when the slot is empty. + [JsonPropertyName("itemId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ItemId { get; init; } + + [JsonPropertyName("quantity")] + public int Quantity { get; init; } +} diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index 55c9d79..77cda31 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -38,6 +38,7 @@ app.MapInteractionApi(); app.MapInteractablesWorldApi(); app.MapSkillDefinitionsWorldApi(); app.MapItemDefinitionsWorldApi(); +app.MapPlayerInventoryApi(); app.MapSkillProgressionSnapshotApi(); app.MapPerkStateApi(); if (app.Environment.IsDevelopment() || diff --git a/server/README.md b/server/README.md index 12944f2..d4cf729 100644 --- a/server/README.md +++ b/server/README.md @@ -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 0–23) | `bag` | | **Equipment** | **1** (index 0) | `equipment` | -**Mutations:** **`TryAddStack`** and **`TryRemoveStack`** route items by catalog metadata, merge stacks up to **`ItemDef.stackMax`**, and use **all-or-nothing** adds (no partial silent loss). Stable deny **`reasonCode`** values: +**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)