NEO-55: player inventory GET/POST HTTP, Bruno, and docs
Versioned snapshot read and mutationKind add/remove POST backed by PlayerInventoryOperations; AAA API tests; Bruno happy path + deny case.pull/90/head
parent
db3b80d242
commit
69327f50b4
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
meta {
|
||||
name: inventory
|
||||
}
|
||||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/// <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_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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -38,6 +38,7 @@ app.MapInteractionApi();
|
|||
app.MapInteractablesWorldApi();
|
||||
app.MapSkillDefinitionsWorldApi();
|
||||
app.MapItemDefinitionsWorldApi();
|
||||
app.MapPlayerInventoryApi();
|
||||
app.MapSkillProgressionSnapshotApi();
|
||||
app.MapPerkStateApi();
|
||||
if (app.Environment.IsDevelopment() ||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue