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..60e701e --- /dev/null +++ b/bruno/neon-sprawl-server/inventory/Get inventory.bru @@ -0,0 +1,41 @@ +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. + Fresh dev server seeds dev-local-1 with empty 24+1 slot grid. +} + +get { + url: {{baseUrl}}/game/players/dev-local-1/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("dev-local-1"); + 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 and use quantity 0", function () { + const body = res.getBody(); + for (const slot of body.bagSlots) { + expect(slot.slotIndex).to.be.a("number"); + expect(slot.quantity).to.equal(0); + expect(slot.itemId).to.equal(undefined); + } + expect(body.equipmentSlots[0].quantity).to.equal(0); + expect(body.equipmentSlots[0].itemId).to.equal(undefined); + }); +} 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..58961dd --- /dev/null +++ b/bruno/neon-sprawl-server/inventory/Post inventory add deny invalid item.bru @@ -0,0 +1,36 @@ +meta { + name: POST inventory add deny invalid item + type: http + seq: 3 +} + +docs { + NEO-55: unknown itemId denies with invalid_item and echoes unchanged inventory. +} + +post { + url: {{baseUrl}}/game/players/dev-local-1/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 echoes inventory", 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); + }); +} 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..1be951e --- /dev/null +++ b/bruno/neon-sprawl-server/inventory/Post inventory add.bru @@ -0,0 +1,41 @@ +meta { + name: POST inventory add + type: http + seq: 2 +} + +docs { + NEO-55: add stack via mutationKind add; see server README inventory HTTP section. +} + +post { + url: {{baseUrl}}/game/players/dev-local-1/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 updated inventory", 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("dev-local-1"); + const occupied = body.inventory.bagSlots.filter((s) => s.quantity > 0); + expect(occupied.length).to.be.at.least(1); + const scrap = occupied.find((s) => s.itemId === "scrap_metal_bulk"); + expect(scrap).to.be.an("object"); + expect(scrap.quantity).to.be.at.least(5); + }); +} diff --git a/bruno/neon-sprawl-server/inventory/folder.bru b/bruno/neon-sprawl-server/inventory/folder.bru new file mode 100644 index 0000000..12b2b71 --- /dev/null +++ b/bruno/neon-sprawl-server/inventory/folder.bru @@ -0,0 +1,3 @@ +meta { + name: inventory +} 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/plans/NEO-55-implementation-plan.md b/docs/plans/NEO-55-implementation-plan.md index e355fa9..f356d21 100644 --- a/docs/plans/NEO-55-implementation-plan.md +++ b/docs/plans/NEO-55-implementation-plan.md @@ -43,9 +43,9 @@ ## Acceptance criteria checklist -- [ ] GET returns instances + slots for known player (`dev-local-1` seeded empty inventory). -- [ ] POST add/remove matches E3M3-05 engine rules (`stackMax`, all-or-nothing add, stable **`reasonCode`** denies). -- [ ] Bruno exercises happy path + at least one deny (`inventory_full` or **`invalid_item`**). +- [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 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..218d1ea --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Items/PlayerInventoryApiTests.cs @@ -0,0 +1,182 @@ +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_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)