NEO-70: Add craft POST HTTP, Bruno spine, and docs.
Wire POST /game/players/{id}/craft to CraftOperations with CraftResponse DTOs,
integration tests, gather→refine→make Bruno folder, and module/README updates.
pull/104/head
parent
b148db8a61
commit
46393b8ca5
|
|
@ -0,0 +1,38 @@
|
||||||
|
meta {
|
||||||
|
name: POST craft deny insufficient materials
|
||||||
|
type: http
|
||||||
|
seq: 3
|
||||||
|
}
|
||||||
|
|
||||||
|
docs {
|
||||||
|
NEO-70: craft without seeded materials denies with insufficient_materials on dev-local-1 empty inventory.
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
url: {{baseUrl}}/game/players/{{playerId}}/craft
|
||||||
|
body: json
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
|
|
||||||
|
headers {
|
||||||
|
content-type: application/json
|
||||||
|
}
|
||||||
|
|
||||||
|
body:json {
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"recipeId": "refine_scrap_standard"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tests {
|
||||||
|
test("denies with insufficient_materials", function () {
|
||||||
|
expect(res.getStatus()).to.equal(200);
|
||||||
|
const body = res.getBody();
|
||||||
|
expect(body.schemaVersion).to.equal(1);
|
||||||
|
expect(body.success).to.equal(false);
|
||||||
|
expect(body.reasonCode).to.equal("insufficient_materials");
|
||||||
|
expect(body.inputsConsumed).to.be.an("array").that.is.empty;
|
||||||
|
expect(body.outputsGranted).to.be.an("array").that.is.empty;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
meta {
|
||||||
|
name: POST craft deny invalid quantity
|
||||||
|
type: http
|
||||||
|
seq: 4
|
||||||
|
}
|
||||||
|
|
||||||
|
docs {
|
||||||
|
NEO-70: non-positive quantity denies with invalid_quantity.
|
||||||
|
}
|
||||||
|
|
||||||
|
script:pre-request {
|
||||||
|
const { ensureItemQuantity } = require("./scripts/inventory-api-helper.js");
|
||||||
|
await ensureItemQuantity(bru, "scrap_metal_bulk", 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
url: {{baseUrl}}/game/players/{{playerId}}/craft
|
||||||
|
body: json
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
|
|
||||||
|
headers {
|
||||||
|
content-type: application/json
|
||||||
|
}
|
||||||
|
|
||||||
|
body:json {
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"recipeId": "refine_scrap_standard",
|
||||||
|
"quantity": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tests {
|
||||||
|
test("denies with invalid_quantity", function () {
|
||||||
|
expect(res.getStatus()).to.equal(200);
|
||||||
|
const body = res.getBody();
|
||||||
|
expect(body.schemaVersion).to.equal(1);
|
||||||
|
expect(body.success).to.equal(false);
|
||||||
|
expect(body.reasonCode).to.equal("invalid_quantity");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
meta {
|
||||||
|
name: POST craft deny inventory full
|
||||||
|
type: http
|
||||||
|
seq: 5
|
||||||
|
}
|
||||||
|
|
||||||
|
docs {
|
||||||
|
NEO-70: make_prototype_armor when equipment slot is occupied denies with inventory_full. Pre-request seeds materials and fills equipment via inventory POST (deny setup only — not part of spine).
|
||||||
|
}
|
||||||
|
|
||||||
|
script:pre-request {
|
||||||
|
const { ensureItemQuantity } = require("./scripts/inventory-api-helper.js");
|
||||||
|
|
||||||
|
await ensureItemQuantity(bru, "refined_plate_stock", 8);
|
||||||
|
await ensureItemQuantity(bru, "scrap_metal_bulk", 5);
|
||||||
|
await ensureItemQuantity(bru, "prototype_armor_shell", 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
url: {{baseUrl}}/game/players/{{playerId}}/craft
|
||||||
|
body: json
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
|
|
||||||
|
headers {
|
||||||
|
content-type: application/json
|
||||||
|
}
|
||||||
|
|
||||||
|
body:json {
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"recipeId": "make_prototype_armor"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tests {
|
||||||
|
test("denies with inventory_full when equipment slot occupied", function () {
|
||||||
|
expect(res.getStatus()).to.equal(200);
|
||||||
|
const body = res.getBody();
|
||||||
|
expect(body.schemaVersion).to.equal(1);
|
||||||
|
expect(body.success).to.equal(false);
|
||||||
|
expect(body.reasonCode).to.equal("inventory_full");
|
||||||
|
expect(body.inputsConsumed).to.be.an("array").that.is.empty;
|
||||||
|
expect(body.outputsGranted).to.be.an("array").that.is.empty;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
meta {
|
||||||
|
name: POST craft deny unknown recipe
|
||||||
|
type: http
|
||||||
|
seq: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
docs {
|
||||||
|
NEO-70: unknown recipeId denies with unknown_recipe.
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
url: {{baseUrl}}/game/players/{{playerId}}/craft
|
||||||
|
body: json
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
|
|
||||||
|
headers {
|
||||||
|
content-type: application/json
|
||||||
|
}
|
||||||
|
|
||||||
|
body:json {
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"recipeId": "not_a_real_recipe"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tests {
|
||||||
|
test("denies with unknown_recipe", function () {
|
||||||
|
expect(res.getStatus()).to.equal(200);
|
||||||
|
const body = res.getBody();
|
||||||
|
expect(body.schemaVersion).to.equal(1);
|
||||||
|
expect(body.success).to.equal(false);
|
||||||
|
expect(body.reasonCode).to.equal("unknown_recipe");
|
||||||
|
expect(body.inputsConsumed).to.be.an("array").that.is.empty;
|
||||||
|
expect(body.outputsGranted).to.be.an("array").that.is.empty;
|
||||||
|
expect(body.xpGrantSummary).to.equal(null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
meta {
|
||||||
|
name: POST craft gather refine make spine
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
docs {
|
||||||
|
NEO-70 prototype spine: gather scrap from four prototype resource nodes (delta 5 + gamma 3 + beta 2 + alpha 1 = 11 scrap), craft refine_scrap_efficient (10→2 refined_plate_stock), then make_field_stim_mk0. No inventory POST shortcuts.
|
||||||
|
}
|
||||||
|
|
||||||
|
script:pre-request {
|
||||||
|
const axios = require("axios");
|
||||||
|
const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl");
|
||||||
|
const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId");
|
||||||
|
const jsonHeaders = { headers: { "Content-Type": "application/json" } };
|
||||||
|
|
||||||
|
async function moveNear(x, z) {
|
||||||
|
await axios.post(
|
||||||
|
`${baseUrl}/game/players/${playerId}/move`,
|
||||||
|
{ schemaVersion: 1, target: { x, y: 0.9, z } },
|
||||||
|
jsonHeaders,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gather(interactableId) {
|
||||||
|
const response = await axios.post(
|
||||||
|
`${baseUrl}/game/players/${playerId}/interact`,
|
||||||
|
{ schemaVersion: 1, interactableId },
|
||||||
|
jsonHeaders,
|
||||||
|
);
|
||||||
|
if (response.status !== 200 || response.data?.allowed !== true) {
|
||||||
|
throw new Error(`gather ${interactableId} failed: ${response.status} ${JSON.stringify(response.data)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function craft(recipeId) {
|
||||||
|
const response = await axios.post(
|
||||||
|
`${baseUrl}/game/players/${playerId}/craft`,
|
||||||
|
{ schemaVersion: 1, recipeId },
|
||||||
|
jsonHeaders,
|
||||||
|
);
|
||||||
|
if (response.status !== 200 || response.data?.success !== true) {
|
||||||
|
throw new Error(`craft ${recipeId} failed: ${response.status} ${JSON.stringify(response.data)}`);
|
||||||
|
}
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
await moveNear(16, -2);
|
||||||
|
await gather("prototype_urban_bulk_delta");
|
||||||
|
await moveNear(10, -2);
|
||||||
|
await gather("prototype_bio_mat_gamma");
|
||||||
|
await moveNear(16, -6);
|
||||||
|
await gather("prototype_subsurface_vein_beta");
|
||||||
|
await moveNear(10, -6);
|
||||||
|
await gather("prototype_resource_node_alpha");
|
||||||
|
|
||||||
|
await craft("refine_scrap_efficient");
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
url: {{baseUrl}}/game/players/{{playerId}}/craft
|
||||||
|
body: json
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
|
|
||||||
|
headers {
|
||||||
|
content-type: application/json
|
||||||
|
}
|
||||||
|
|
||||||
|
body:json {
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"recipeId": "make_field_stim_mk0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
script:post-response {
|
||||||
|
const axios = require("axios");
|
||||||
|
const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl");
|
||||||
|
const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId");
|
||||||
|
|
||||||
|
const inventory = await axios.get(`${baseUrl}/game/players/${playerId}/inventory`);
|
||||||
|
const progression = await axios.get(`${baseUrl}/game/players/${playerId}/skill-progression`);
|
||||||
|
|
||||||
|
bru.setVar("spineInventory", inventory.data);
|
||||||
|
bru.setVar("spineProgression", progression.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
tests {
|
||||||
|
test("make_field_stim_mk0 succeeds after gather and refine", function () {
|
||||||
|
expect(res.getStatus()).to.equal(200);
|
||||||
|
const body = res.getBody();
|
||||||
|
expect(body.schemaVersion).to.equal(1);
|
||||||
|
expect(body.success).to.equal(true);
|
||||||
|
expect(body.reasonCode).to.equal(null);
|
||||||
|
expect(body.outputsGranted).to.deep.include({ itemId: "field_stim_mk0", quantity: 1 });
|
||||||
|
expect(body.xpGrantSummary).to.be.an("object");
|
||||||
|
expect(body.xpGrantSummary.skillId).to.equal("refine");
|
||||||
|
expect(body.xpGrantSummary.amount).to.equal(10);
|
||||||
|
expect(body.xpGrantSummary.sourceKind).to.equal("activity");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("inventory includes field_stim_mk0 after spine", function () {
|
||||||
|
const inventory = bru.getVar("spineInventory");
|
||||||
|
const total = inventory.bagSlots
|
||||||
|
.filter((slot) => slot.itemId === "field_stim_mk0")
|
||||||
|
.reduce((sum, slot) => sum + slot.quantity, 0);
|
||||||
|
expect(total).to.be.at.least(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refine skill XP increased after spine crafts", function () {
|
||||||
|
const progression = bru.getVar("spineProgression");
|
||||||
|
const refine = progression.skills.find((row) => row.id === "refine");
|
||||||
|
expect(refine).to.be.an("object");
|
||||||
|
expect(refine.xp).to.be.at.least(20);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
meta {
|
||||||
|
name: craft
|
||||||
|
}
|
||||||
|
|
||||||
|
docs {
|
||||||
|
NEO-70 craft HTTP. Spine uses gather interact (no inventory POST shortcuts). Deny requests may use inventory POST for setup only.
|
||||||
|
}
|
||||||
|
|
@ -13,9 +13,9 @@
|
||||||
|
|
||||||
**Prep (NEO-42):** Server ships **`RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine`** + **`RefineSkillXpConstants`** under `server/NeonSprawl.Server/Game/Skills/` — delegates to **`SkillProgressionGrantOperations.TryApplyGrant`** with **`refine`** + **`activity`** (prototype **10** XP). **`CraftOperations.TryCraft`** (NEO-69) invokes the same grant path on craft success ([NEO-42 implementation plan](../../plans/NEO-42-implementation-plan.md); [server README — Craft engine (NEO-69)](../../../server/README.md#craft-engine-neo-69)).
|
**Prep (NEO-42):** Server ships **`RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine`** + **`RefineSkillXpConstants`** under `server/NeonSprawl.Server/Game/Skills/` — delegates to **`SkillProgressionGrantOperations.TryApplyGrant`** with **`refine`** + **`activity`** (prototype **10** XP). **`CraftOperations.TryCraft`** (NEO-69) invokes the same grant path on craft success ([NEO-42 implementation plan](../../plans/NEO-42-implementation-plan.md); [server README — Craft engine (NEO-69)](../../../server/README.md#craft-engine-neo-69)).
|
||||||
|
|
||||||
**E3M2-01 (NEO-65):** Frozen eight-recipe catalog in [`content/recipes/prototype_recipes.json`](../../../content/recipes/prototype_recipes.json); [`recipe-def.schema.json`](../../../content/schemas/recipe-def.schema.json) + [`recipe-io-row.schema.json`](../../../content/schemas/recipe-io-row.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) Slice 3 gates ([NEO-65 plan](../../plans/NEO-65-implementation-plan.md)). **E3M2-02 (NEO-66):** fail-fast server load under `server/NeonSprawl.Server/Game/Crafting/` — `RecipeDefinitionCatalogLoader`, `RecipeDefinitionCatalog`, cross-check vs item + skill catalogs, Slice 3 gate; [NEO-66 plan](../../plans/NEO-66-implementation-plan.md); [server README — Recipe catalog](../../../server/README.md#recipe-catalog-contentrecipes-neo-66). **E3M2-03 (NEO-67):** injectable **`IRecipeDefinitionRegistry`** + DI; [NEO-67 plan](../../plans/NEO-67-implementation-plan.md). **E3M2-04 (NEO-68):** **`GET /game/world/recipe-definitions`** — versioned read-only projection (`schemaVersion` **1**, **`recipes`**) backed by **`IRecipeDefinitionRegistry`**; Bruno `bruno/neon-sprawl-server/recipe-definitions/`. Plan: [NEO-68 implementation plan](../../plans/NEO-68-implementation-plan.md). **E3M2-05 (NEO-69):** **`CraftOperations.TryCraft`** + **`CraftResult`** — pre-flight inputs/outputs, inventory mutation, refine XP; [NEO-69 plan](../../plans/NEO-69-implementation-plan.md); [server README — Craft engine (NEO-69)](../../../server/README.md#craft-engine-neo-69).
|
**E3M2-01 (NEO-65):** Frozen eight-recipe catalog in [`content/recipes/prototype_recipes.json`](../../../content/recipes/prototype_recipes.json); [`recipe-def.schema.json`](../../../content/schemas/recipe-def.schema.json) + [`recipe-io-row.schema.json`](../../../content/schemas/recipe-io-row.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) Slice 3 gates ([NEO-65 plan](../../plans/NEO-65-implementation-plan.md)). **E3M2-02 (NEO-66):** fail-fast server load under `server/NeonSprawl.Server/Game/Crafting/` — `RecipeDefinitionCatalogLoader`, `RecipeDefinitionCatalog`, cross-check vs item + skill catalogs, Slice 3 gate; [NEO-66 plan](../../plans/NEO-66-implementation-plan.md); [server README — Recipe catalog](../../../server/README.md#recipe-catalog-contentrecipes-neo-66). **E3M2-03 (NEO-67):** injectable **`IRecipeDefinitionRegistry`** + DI; [NEO-67 plan](../../plans/NEO-67-implementation-plan.md). **E3M2-04 (NEO-68):** **`GET /game/world/recipe-definitions`** — versioned read-only projection (`schemaVersion` **1**, **`recipes`**) backed by **`IRecipeDefinitionRegistry`**; Bruno `bruno/neon-sprawl-server/recipe-definitions/`. Plan: [NEO-68 implementation plan](../../plans/NEO-68-implementation-plan.md). **E3M2-05 (NEO-69):** **`CraftOperations.TryCraft`** + **`CraftResult`** — pre-flight inputs/outputs, inventory mutation, refine XP; [NEO-69 plan](../../plans/NEO-69-implementation-plan.md); [server README — Craft engine (NEO-69)](../../../server/README.md#craft-engine-neo-69-and-craft-http-neo-70). **E3M2-06 (NEO-70):** **`POST /game/players/{id}/craft`** — wire **`CraftResponse`** backed by **`CraftOperations`**; Bruno gather→refine→make spine; [NEO-70 plan](../../plans/NEO-70-implementation-plan.md).
|
||||||
|
|
||||||
### `CraftResult` (server-internal, NEO-69)
|
### `CraftResult` / wire `CraftResponse` (NEO-69 engine, NEO-70 HTTP)
|
||||||
|
|
||||||
| Field | Role |
|
| Field | Role |
|
||||||
|-------|------|
|
|-------|------|
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
| **`OutputsGranted`** | Scaled `{ itemId, quantity }` rows added on success. |
|
| **`OutputsGranted`** | Scaled `{ itemId, quantity }` rows added on success. |
|
||||||
| **`XpGrantSummary`** | Compact refine XP summary (`refine`, **10**, `activity`) on success. |
|
| **`XpGrantSummary`** | Compact refine XP summary (`refine`, **10**, `activity`) on success. |
|
||||||
|
|
||||||
Promoted to wire JSON when craft HTTP lands (NEO-70). Telemetry hooks deferred to NEO-71.
|
Promoted to wire JSON as **`CraftResponse`** on **`POST /game/players/{id}/craft`** (NEO-70). Telemetry hooks deferred to NEO-71.
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not
|
||||||
| 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 |
|
| 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 | Ready | **NEO-41 landed (prototype, superseded on interact by NEO-63):** inline XP on **`resource_node`** interact. **NEO-57–NEO-61** — content, registry, HTTP defs, depletion store (see prior alignment). **NEO-62 landed:** **`GatherOperations`** + **`GatherResult`**. **NEO-63 landed:** **`POST …/interact`** **`resource_node`** → gather engine; **four** **`PrototypeInteractableRegistry`** anchors; Bruno gather → inventory + depletion deny ([NEO-63](../../plans/NEO-63-implementation-plan.md), [`NEO-63` manual QA](../../manual-qa/NEO-63.md)); [server README — Gather via interact (NEO-63)](../../../server/README.md#gather-via-interact-neo-63). **NEO-64 landed:** comment-only **`resource_gathered`** / **`gather_node_depleted`** hook sites in **`GatherOperations.TryGather`** ([NEO-64](../../plans/NEO-64-implementation-plan.md), [`NEO-64` manual QA](../../manual-qa/NEO-64.md)); Epic 3 Slice 2 backlog **E3M1-01**–**E3M1-08** complete. **Slice 5 client (planned):** gather feedback HUD [NEO-73](https://linear.app/neon-sprawl/issue/NEO-73) — [E3S5 backlog](../../plans/E3S5-client-prototype-backlog.md). | [NEO-41](../../plans/NEO-41-implementation-plan.md) … [NEO-64](../../plans/NEO-64-implementation-plan.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md); `server/NeonSprawl.Server/Game/Gathering/`, `Game/Interaction/` |
|
| E3.M1 | Ready | **NEO-41 landed (prototype, superseded on interact by NEO-63):** inline XP on **`resource_node`** interact. **NEO-57–NEO-61** — content, registry, HTTP defs, depletion store (see prior alignment). **NEO-62 landed:** **`GatherOperations`** + **`GatherResult`**. **NEO-63 landed:** **`POST …/interact`** **`resource_node`** → gather engine; **four** **`PrototypeInteractableRegistry`** anchors; Bruno gather → inventory + depletion deny ([NEO-63](../../plans/NEO-63-implementation-plan.md), [`NEO-63` manual QA](../../manual-qa/NEO-63.md)); [server README — Gather via interact (NEO-63)](../../../server/README.md#gather-via-interact-neo-63). **NEO-64 landed:** comment-only **`resource_gathered`** / **`gather_node_depleted`** hook sites in **`GatherOperations.TryGather`** ([NEO-64](../../plans/NEO-64-implementation-plan.md), [`NEO-64` manual QA](../../manual-qa/NEO-64.md)); Epic 3 Slice 2 backlog **E3M1-01**–**E3M1-08** complete. **Slice 5 client (planned):** gather feedback HUD [NEO-73](https://linear.app/neon-sprawl/issue/NEO-73) — [E3S5 backlog](../../plans/E3S5-client-prototype-backlog.md). | [NEO-41](../../plans/NEO-41-implementation-plan.md) … [NEO-64](../../plans/NEO-64-implementation-plan.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md); `server/NeonSprawl.Server/Game/Gathering/`, `Game/Interaction/` |
|
||||||
| 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/`. **NEO-56 landed:** comment-only **`item_created`** / **`inventory_transfer_denied`** telemetry hook sites in [`PlayerInventoryOperations`](../../../server/NeonSprawl.Server/Game/Items/PlayerInventoryOperations.cs) ([NEO-56](../../plans/NEO-56-implementation-plan.md), [`NEO-56` manual QA](../../manual-qa/NEO-56.md)); no E9.M1 ingest. **Slice 5 client (planned):** inventory HUD [NEO-72](https://linear.app/neon-sprawl/issue/NEO-72) — [E3S5 backlog](../../plans/E3S5-client-prototype-backlog.md). | [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), [NEO-56](../../plans/NEO-56-implementation-plan.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.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/`. **NEO-56 landed:** comment-only **`item_created`** / **`inventory_transfer_denied`** telemetry hook sites in [`PlayerInventoryOperations`](../../../server/NeonSprawl.Server/Game/Items/PlayerInventoryOperations.cs) ([NEO-56](../../plans/NEO-56-implementation-plan.md), [`NEO-56` manual QA](../../manual-qa/NEO-56.md)); no E9.M1 ingest. **Slice 5 client (planned):** inventory HUD [NEO-72](https://linear.app/neon-sprawl/issue/NEO-72) — [E3S5 backlog](../../plans/E3S5-client-prototype-backlog.md). | [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), [NEO-56](../../plans/NEO-56-implementation-plan.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3M3-prototype-backlog](../../plans/E3M3-prototype-backlog.md), [E3_M3](E3_M3_ItemizationAndInventorySchema.md) |
|
||||||
| E3.M2 | In Progress | **NEO-65 landed:** frozen prototype eight-recipe catalog in [`content/recipes/prototype_recipes.json`](../../../content/recipes/prototype_recipes.json); [`recipe-def.schema.json`](../../../content/schemas/recipe-def.schema.json) + [`recipe-io-row.schema.json`](../../../content/schemas/recipe-io-row.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) Slice 3 gates ([NEO-65](../../plans/NEO-65-implementation-plan.md)). **NEO-66 landed:** fail-fast server load of `content/recipes/*_recipes.json` at startup — `server/NeonSprawl.Server/Game/Crafting/` ([NEO-66](../../plans/NEO-66-implementation-plan.md)); [server README — Recipe catalog](../../../server/README.md#recipe-catalog-contentrecipes-neo-66). **NEO-67 landed:** injectable **`IRecipeDefinitionRegistry`** + lookup tests ([NEO-67](../../plans/NEO-67-implementation-plan.md)). **NEO-68 landed:** **`GET /game/world/recipe-definitions`** — `RecipeDefinitionsWorldApi` + DTOs in `Game/Crafting/` ([NEO-68](../../plans/NEO-68-implementation-plan.md), [`NEO-68` manual QA](../../manual-qa/NEO-68.md)); [server README — Recipe definitions (NEO-68)](../../../server/README.md#recipe-definitions-neo-68); Bruno `bruno/neon-sprawl-server/recipe-definitions/`. **NEO-69 landed:** **`CraftOperations.TryCraft`** + **`CraftResult`** — pre-flight inputs/outputs, inventory mutation, refine XP ([NEO-69](../../plans/NEO-69-implementation-plan.md)); [server README — Craft engine (NEO-69)](../../../server/README.md#craft-engine-neo-69). **NEO-42 landed (prep):** **`RefineActivitySkillXpGrant`** / **`RefineSkillXpConstants`** — wired from craft engine success path. **Slice 3 backlog in Linear:** [NEO-70](https://linear.app/neon-sprawl/issue/NEO-70) → [NEO-71](https://linear.app/neon-sprawl/issue/NEO-71); [E3M2-prototype-backlog.md](../../plans/E3M2-prototype-backlog.md) — **E3M2-06**–**E3M2-07** open. Upstream: E3.M1 **Ready**, E3.M3 inventory landed. **Slice 5 client (planned):** craft UI [NEO-74](https://linear.app/neon-sprawl/issue/NEO-74); capstone playable loop [NEO-75](https://linear.app/neon-sprawl/issue/NEO-75) — [E3S5 backlog](../../plans/E3S5-client-prototype-backlog.md). | [NEO-42](../../plans/NEO-42-implementation-plan.md), [NEO-65](../../plans/NEO-65-implementation-plan.md), [NEO-66](../../plans/NEO-66-implementation-plan.md), [NEO-67](../../plans/NEO-67-implementation-plan.md), [NEO-68](../../plans/NEO-68-implementation-plan.md), [NEO-69](../../plans/NEO-69-implementation-plan.md) … [NEO-75](https://linear.app/neon-sprawl/issue/NEO-75), [E3M2-prototype-backlog](../../plans/E3M2-prototype-backlog.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3_M2](E3_M2_RefinementAndRecipeExecution.md) |
|
| E3.M2 | In Progress | **NEO-65 landed:** frozen prototype eight-recipe catalog in [`content/recipes/prototype_recipes.json`](../../../content/recipes/prototype_recipes.json); [`recipe-def.schema.json`](../../../content/schemas/recipe-def.schema.json) + [`recipe-io-row.schema.json`](../../../content/schemas/recipe-io-row.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) Slice 3 gates ([NEO-65](../../plans/NEO-65-implementation-plan.md)). **NEO-66 landed:** fail-fast server load of `content/recipes/*_recipes.json` at startup — `server/NeonSprawl.Server/Game/Crafting/` ([NEO-66](../../plans/NEO-66-implementation-plan.md)); [server README — Recipe catalog](../../../server/README.md#recipe-catalog-contentrecipes-neo-66). **NEO-67 landed:** injectable **`IRecipeDefinitionRegistry`** + lookup tests ([NEO-67](../../plans/NEO-67-implementation-plan.md)). **NEO-68 landed:** **`GET /game/world/recipe-definitions`** — `RecipeDefinitionsWorldApi` + DTOs in `Game/Crafting/` ([NEO-68](../../plans/NEO-68-implementation-plan.md), [`NEO-68` manual QA](../../manual-qa/NEO-68.md)); [server README — Recipe definitions (NEO-68)](../../../server/README.md#recipe-definitions-neo-68); Bruno `bruno/neon-sprawl-server/recipe-definitions/`. **NEO-69 landed:** **`CraftOperations.TryCraft`** + **`CraftResult`** — pre-flight inputs/outputs, inventory mutation, refine XP ([NEO-69](../../plans/NEO-69-implementation-plan.md)); [server README — Craft engine (NEO-69)](../../../server/README.md#craft-engine-neo-69-and-craft-http-neo-70). **NEO-70 landed:** **`POST /game/players/{id}/craft`** — `PlayerCraftApi` + wire **`CraftResponse`**; Bruno gather→refine→make spine ([NEO-70](../../plans/NEO-70-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/craft/`. **NEO-42 landed (prep):** **`RefineActivitySkillXpGrant`** / **`RefineSkillXpConstants`** — wired from craft engine success path. **Slice 3 backlog in Linear:** [NEO-71](https://linear.app/neon-sprawl/issue/NEO-71); [E3M2-prototype-backlog.md](../../plans/E3M2-prototype-backlog.md) — **E3M2-07** open. Upstream: E3.M1 **Ready**, E3.M3 inventory landed. **Slice 5 client (planned):** craft UI [NEO-74](https://linear.app/neon-sprawl/issue/NEO-74); capstone playable loop [NEO-75](https://linear.app/neon-sprawl/issue/NEO-75) — [E3S5 backlog](../../plans/E3S5-client-prototype-backlog.md). | [NEO-42](../../plans/NEO-42-implementation-plan.md), [NEO-65](../../plans/NEO-65-implementation-plan.md), [NEO-66](../../plans/NEO-66-implementation-plan.md), [NEO-67](../../plans/NEO-67-implementation-plan.md), [NEO-68](../../plans/NEO-68-implementation-plan.md), [NEO-69](../../plans/NEO-69-implementation-plan.md), [NEO-70](../../plans/NEO-70-implementation-plan.md) … [NEO-75](https://linear.app/neon-sprawl/issue/NEO-75), [E3M2-prototype-backlog](../../plans/E3M2-prototype-backlog.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3_M2](E3_M2_RefinementAndRecipeExecution.md) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -205,10 +205,12 @@ Working backlog for **Epic 3 — Slice 3** ([recipes and crafting pipeline](../d
|
||||||
|
|
||||||
**Acceptance criteria**
|
**Acceptance criteria**
|
||||||
|
|
||||||
- [ ] POST success returns structured **`CraftResult`** (or HTTP envelope) matching engine outcome.
|
- [x] POST success returns structured **`CraftResult`** (or HTTP envelope) matching engine outcome.
|
||||||
- [ ] Bruno spine completes without manual inventory POST shortcuts (gather interact allowed for scrap).
|
- [x] Bruno spine completes without manual inventory POST shortcuts (gather interact allowed for scrap).
|
||||||
- [ ] At least one deny path documented per major `reasonCode`.
|
- [x] At least one deny path documented per major `reasonCode`.
|
||||||
- [ ] Epic 3 Slice 3 acceptance: player completes **gather → refine → usable item** (`field_stim_mk0` consumable).
|
- [x] Epic 3 Slice 3 acceptance: player completes **gather → refine → usable item** (`field_stim_mk0` consumable).
|
||||||
|
|
||||||
|
**Landed ([NEO-70](https://linear.app/neon-sprawl/issue/NEO-70)):** **`POST /game/players/{id}/craft`** — `PlayerCraftApi` + `CraftWireDtos`; Bruno `bruno/neon-sprawl-server/craft/`; plan [NEO-70-implementation-plan.md](NEO-70-implementation-plan.md); [server README — Craft HTTP (NEO-70)](../../server/README.md#craft-engine-neo-69-and-craft-http-neo-70).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,10 +43,10 @@ No other blocking decisions — request shape (`recipeId` + optional `quantity`
|
||||||
|
|
||||||
## Acceptance criteria checklist
|
## Acceptance criteria checklist
|
||||||
|
|
||||||
- [ ] POST success returns structured **`CraftResult`** wire JSON matching engine outcome.
|
- [x] POST success returns structured **`CraftResult`** wire JSON matching engine outcome.
|
||||||
- [ ] Bruno spine: gather **`scrap_metal_bulk`** → **`refine_scrap_standard`** (or efficient path) → **`make_field_stim_mk0`** without manual inventory POST shortcuts.
|
- [x] Bruno spine: gather **`scrap_metal_bulk`** → **`refine_scrap_standard`** (or efficient path) → **`make_field_stim_mk0`** without manual inventory POST shortcuts.
|
||||||
- [ ] At least one deny path documented per major **`reasonCode`** (see Tests / Bruno below).
|
- [x] At least one deny path documented per major **`reasonCode`** (see Tests / Bruno below).
|
||||||
- [ ] Epic 3 Slice 3 acceptance: player completes **gather → refine → usable item** (`field_stim_mk0` consumable).
|
- [x] Epic 3 Slice 3 acceptance: player completes **gather → refine → usable item** (`field_stim_mk0` consumable).
|
||||||
|
|
||||||
## Technical approach
|
## Technical approach
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,248 @@
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using NeonSprawl.Server.Game.Crafting;
|
||||||
|
using NeonSprawl.Server.Game.Items;
|
||||||
|
using NeonSprawl.Server.Game.Skills;
|
||||||
|
using NeonSprawl.Server.Tests;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Tests.Game.Crafting;
|
||||||
|
|
||||||
|
/// <summary>Regression tests for <c>POST …/craft</c> (NEO-70).</summary>
|
||||||
|
public sealed class PlayerCraftApiTests
|
||||||
|
{
|
||||||
|
private const string PlayerId = "dev-local-1";
|
||||||
|
|
||||||
|
private static CraftRequest Request(
|
||||||
|
string recipeId,
|
||||||
|
int? quantity = null,
|
||||||
|
int schemaVersion = CraftRequest.CurrentSchemaVersion) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
SchemaVersion = schemaVersion,
|
||||||
|
RecipeId = recipeId,
|
||||||
|
Quantity = quantity,
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostCraft_ShouldReturnNotFound_WhenPlayerUnknown()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await client.PostAsJsonAsync(
|
||||||
|
"/game/players/missing-player/craft",
|
||||||
|
Request("refine_scrap_standard"));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostCraft_ShouldReturnBadRequest_WhenSchemaVersionMismatch()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await client.PostAsJsonAsync(
|
||||||
|
$"/game/players/{PlayerId}/craft",
|
||||||
|
Request("refine_scrap_standard", schemaVersion: 99));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostCraft_ShouldReturnBadRequest_WhenRecipeIdMissing()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await client.PostAsJsonAsync(
|
||||||
|
$"/game/players/{PlayerId}/craft",
|
||||||
|
new CraftRequest { SchemaVersion = CraftRequest.CurrentSchemaVersion, RecipeId = " " });
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostCraft_RefineScrapStandard_ShouldReturnSuccessEnvelope()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
SeedStack(factory, "scrap_metal_bulk", 5);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await client.PostAsJsonAsync(
|
||||||
|
$"/game/players/{PlayerId}/craft",
|
||||||
|
Request("refine_scrap_standard"));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
var body = await response.Content.ReadFromJsonAsync<CraftResponse>();
|
||||||
|
Assert.NotNull(body);
|
||||||
|
Assert.Equal(CraftResponse.CurrentSchemaVersion, body!.SchemaVersion);
|
||||||
|
Assert.True(body.Success);
|
||||||
|
Assert.Null(body.ReasonCode);
|
||||||
|
Assert.Single(body.InputsConsumed);
|
||||||
|
Assert.Equal("scrap_metal_bulk", body.InputsConsumed[0].ItemId);
|
||||||
|
Assert.Equal(5, body.InputsConsumed[0].Quantity);
|
||||||
|
Assert.Single(body.OutputsGranted);
|
||||||
|
Assert.Equal("refined_plate_stock", body.OutputsGranted[0].ItemId);
|
||||||
|
Assert.Equal(1, body.OutputsGranted[0].Quantity);
|
||||||
|
Assert.NotNull(body.XpGrantSummary);
|
||||||
|
Assert.Equal("refine", body.XpGrantSummary!.SkillId);
|
||||||
|
Assert.Equal(RefineSkillXpConstants.ActivityXpPerCraftRefineCompletion, body.XpGrantSummary.Amount);
|
||||||
|
Assert.Equal(RefineSkillXpConstants.ActivitySourceKind, body.XpGrantSummary.SourceKind);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostCraft_WhenMaterialsMissing_ShouldDenyWithInsufficientMaterials()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
var beforeInventory = await client.GetFromJsonAsync<PlayerInventorySnapshotResponse>(
|
||||||
|
$"/game/players/{PlayerId}/inventory");
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await client.PostAsJsonAsync(
|
||||||
|
$"/game/players/{PlayerId}/craft",
|
||||||
|
Request("refine_scrap_standard"));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
var body = await response.Content.ReadFromJsonAsync<CraftResponse>();
|
||||||
|
Assert.NotNull(body);
|
||||||
|
Assert.False(body!.Success);
|
||||||
|
Assert.Equal(CraftReasonCodes.InsufficientMaterials, body.ReasonCode);
|
||||||
|
Assert.Empty(body.InputsConsumed);
|
||||||
|
Assert.Empty(body.OutputsGranted);
|
||||||
|
Assert.Null(body.XpGrantSummary);
|
||||||
|
var afterInventory = await client.GetFromJsonAsync<PlayerInventorySnapshotResponse>(
|
||||||
|
$"/game/players/{PlayerId}/inventory");
|
||||||
|
Assert.Equal(TotalBagQuantity(beforeInventory!), TotalBagQuantity(afterInventory!));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostCraft_ForUnknownRecipe_ShouldDenyWithUnknownRecipe()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await client.PostAsJsonAsync(
|
||||||
|
$"/game/players/{PlayerId}/craft",
|
||||||
|
Request("not_a_real_recipe"));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
var body = await response.Content.ReadFromJsonAsync<CraftResponse>();
|
||||||
|
Assert.NotNull(body);
|
||||||
|
Assert.False(body!.Success);
|
||||||
|
Assert.Equal(CraftReasonCodes.UnknownRecipe, body.ReasonCode);
|
||||||
|
Assert.Empty(body.InputsConsumed);
|
||||||
|
Assert.Empty(body.OutputsGranted);
|
||||||
|
Assert.Null(body.XpGrantSummary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostCraft_WhenQuantityInvalid_ShouldDenyWithInvalidQuantity()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
SeedStack(factory, "scrap_metal_bulk", 5);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await client.PostAsJsonAsync(
|
||||||
|
$"/game/players/{PlayerId}/craft",
|
||||||
|
Request("refine_scrap_standard", quantity: 0));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
var body = await response.Content.ReadFromJsonAsync<CraftResponse>();
|
||||||
|
Assert.NotNull(body);
|
||||||
|
Assert.False(body!.Success);
|
||||||
|
Assert.Equal(CraftReasonCodes.InvalidQuantity, body.ReasonCode);
|
||||||
|
Assert.Equal(5, CountBagItem(factory, "scrap_metal_bulk"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostCraft_MakePrototypeArmor_WhenEquipmentOccupied_ShouldDenyWithInventoryFull()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
SeedStack(factory, "refined_plate_stock", 8);
|
||||||
|
SeedStack(factory, "scrap_metal_bulk", 5);
|
||||||
|
SeedStack(factory, "prototype_armor_shell", 1);
|
||||||
|
var beforeInventory = await client.GetFromJsonAsync<PlayerInventorySnapshotResponse>(
|
||||||
|
$"/game/players/{PlayerId}/inventory");
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await client.PostAsJsonAsync(
|
||||||
|
$"/game/players/{PlayerId}/craft",
|
||||||
|
Request("make_prototype_armor"));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
var body = await response.Content.ReadFromJsonAsync<CraftResponse>();
|
||||||
|
Assert.NotNull(body);
|
||||||
|
Assert.False(body!.Success);
|
||||||
|
Assert.Equal(CraftReasonCodes.InventoryFull, body.ReasonCode);
|
||||||
|
var afterInventory = await client.GetFromJsonAsync<PlayerInventorySnapshotResponse>(
|
||||||
|
$"/game/players/{PlayerId}/inventory");
|
||||||
|
Assert.Equal(TotalBagQuantity(beforeInventory!), TotalBagQuantity(afterInventory!));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SeedStack(InMemoryWebApplicationFactory factory, string itemId, int quantity)
|
||||||
|
{
|
||||||
|
var itemRegistry = factory.Services.GetRequiredService<IItemDefinitionRegistry>();
|
||||||
|
var inventoryStore = factory.Services.GetRequiredService<IPlayerInventoryStore>();
|
||||||
|
var outcome = PlayerInventoryOperations.TryAddStack(
|
||||||
|
PlayerId,
|
||||||
|
itemId,
|
||||||
|
quantity,
|
||||||
|
itemRegistry,
|
||||||
|
inventoryStore);
|
||||||
|
Assert.Equal(PlayerInventoryMutationKind.Applied, outcome.Kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CountBagItem(InMemoryWebApplicationFactory factory, string itemId)
|
||||||
|
{
|
||||||
|
var inventoryStore = factory.Services.GetRequiredService<IPlayerInventoryStore>();
|
||||||
|
inventoryStore.TryGetSnapshot(PlayerId, out var snapshot);
|
||||||
|
var total = 0;
|
||||||
|
foreach (var slot in snapshot!.BagSlots)
|
||||||
|
{
|
||||||
|
if (string.Equals(slot.ItemId, itemId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
total += slot.Quantity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int TotalBagQuantity(PlayerInventorySnapshotResponse snapshot)
|
||||||
|
{
|
||||||
|
var total = 0;
|
||||||
|
foreach (var slot in snapshot.BagSlots)
|
||||||
|
{
|
||||||
|
total += slot.Quantity;
|
||||||
|
}
|
||||||
|
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,7 @@ namespace NeonSprawl.Server.Game.Crafting;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Orchestrates craft side effects: recipe resolve, inventory pre-flight, input removal, output grant, refine XP (NEO-69).
|
/// Orchestrates craft side effects: recipe resolve, inventory pre-flight, input removal, output grant, refine XP (NEO-69).
|
||||||
/// Craft HTTP wiring is NEO-70.
|
/// Craft HTTP: <see cref="PlayerCraftApi"/> (NEO-70).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class CraftOperations
|
public static class CraftOperations
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
namespace NeonSprawl.Server.Game.Crafting;
|
namespace NeonSprawl.Server.Game.Crafting;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Server-internal craft resolution envelope (NEO-69). May promote to wire JSON when craft HTTP lands (NEO-70).
|
/// Server-internal craft resolution envelope (NEO-69); promoted to wire JSON via <see cref="CraftResponse"/> (NEO-70).
|
||||||
/// NEO-71 telemetry hook sites will live in <see cref="CraftOperations.TryCraft"/>.
|
/// NEO-71 telemetry hook sites will live in <see cref="CraftOperations.TryCraft"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="InputsConsumed">Scaled inputs removed on success; empty when denied.</param>
|
/// <param name="InputsConsumed">Scaled inputs removed on success; empty when denied.</param>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Game.Crafting;
|
||||||
|
|
||||||
|
/// <summary>POST body for <c>POST /game/players/{{id}}/craft</c> (NEO-70).</summary>
|
||||||
|
public sealed class CraftRequest
|
||||||
|
{
|
||||||
|
public const int CurrentSchemaVersion = 1;
|
||||||
|
|
||||||
|
[JsonPropertyName("schemaVersion")]
|
||||||
|
public int SchemaVersion { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("recipeId")]
|
||||||
|
public required string RecipeId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Optional batch count; defaults to <b>1</b> when omitted.</summary>
|
||||||
|
[JsonPropertyName("quantity")]
|
||||||
|
public int? Quantity { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>POST response — promoted <see cref="CraftResult"/> wire shape (NEO-70).</summary>
|
||||||
|
public sealed class CraftResponse
|
||||||
|
{
|
||||||
|
public const int CurrentSchemaVersion = 1;
|
||||||
|
|
||||||
|
[JsonPropertyName("schemaVersion")]
|
||||||
|
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
|
||||||
|
|
||||||
|
[JsonPropertyName("success")]
|
||||||
|
public bool Success { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("reasonCode")]
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public string? ReasonCode { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("inputsConsumed")]
|
||||||
|
public required IReadOnlyList<CraftIoAppliedJson> InputsConsumed { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("outputsGranted")]
|
||||||
|
public required IReadOnlyList<CraftIoAppliedJson> OutputsGranted { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("xpGrantSummary")]
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public CraftXpGrantSummaryJson? XpGrantSummary { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One scaled input consumed or output granted on successful craft.</summary>
|
||||||
|
public sealed class CraftIoAppliedJson
|
||||||
|
{
|
||||||
|
[JsonPropertyName("itemId")]
|
||||||
|
public required string ItemId { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("quantity")]
|
||||||
|
public int Quantity { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Refine skill XP applied on successful craft.</summary>
|
||||||
|
public sealed class CraftXpGrantSummaryJson
|
||||||
|
{
|
||||||
|
[JsonPropertyName("skillId")]
|
||||||
|
public required string SkillId { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("amount")]
|
||||||
|
public int Amount { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("sourceKind")]
|
||||||
|
public required string SourceKind { get; init; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
using NeonSprawl.Server.Game.Items;
|
||||||
|
using NeonSprawl.Server.Game.Mastery;
|
||||||
|
using NeonSprawl.Server.Game.PositionState;
|
||||||
|
using NeonSprawl.Server.Game.Skills;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Game.Crafting;
|
||||||
|
|
||||||
|
/// <summary>Maps <c>POST /game/players/{{id}}/craft</c> — craft execution (NEO-70).</summary>
|
||||||
|
public static class PlayerCraftApi
|
||||||
|
{
|
||||||
|
public static WebApplication MapPlayerCraftApi(this WebApplication app)
|
||||||
|
{
|
||||||
|
app.MapPost(
|
||||||
|
"/game/players/{id}/craft",
|
||||||
|
(string id, CraftRequest? body, IPositionStateStore positions,
|
||||||
|
IRecipeDefinitionRegistry recipeRegistry, IItemDefinitionRegistry itemRegistry,
|
||||||
|
IPlayerInventoryStore inventoryStore, ISkillDefinitionRegistry skillRegistry,
|
||||||
|
IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve,
|
||||||
|
PerkUnlockEngine perkUnlockEngine) =>
|
||||||
|
{
|
||||||
|
if (body is null || body.SchemaVersion != CraftRequest.CurrentSchemaVersion)
|
||||||
|
{
|
||||||
|
return Results.BadRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.RecipeId is null || body.RecipeId.Trim().Length == 0)
|
||||||
|
{
|
||||||
|
return Results.BadRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
var trimmedId = id.Trim();
|
||||||
|
if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _))
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var quantity = body.Quantity ?? 1;
|
||||||
|
var result = CraftOperations.TryCraft(
|
||||||
|
trimmedId,
|
||||||
|
body.RecipeId,
|
||||||
|
quantity,
|
||||||
|
recipeRegistry,
|
||||||
|
itemRegistry,
|
||||||
|
inventoryStore,
|
||||||
|
skillRegistry,
|
||||||
|
xpStore,
|
||||||
|
levelCurve,
|
||||||
|
perkUnlockEngine);
|
||||||
|
|
||||||
|
if (!result.Success &&
|
||||||
|
(string.Equals(result.ReasonCode, CraftReasonCodes.InventoryStoreMissing, StringComparison.Ordinal) ||
|
||||||
|
string.Equals(result.ReasonCode, CraftReasonCodes.ProgressionStoreMissing, StringComparison.Ordinal)))
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Json(MapResponse(result));
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static CraftResponse MapResponse(CraftResult result)
|
||||||
|
{
|
||||||
|
CraftXpGrantSummaryJson? xp = null;
|
||||||
|
if (result.XpGrantSummary is { } summary)
|
||||||
|
{
|
||||||
|
xp = new CraftXpGrantSummaryJson
|
||||||
|
{
|
||||||
|
SkillId = summary.SkillId,
|
||||||
|
Amount = summary.Amount,
|
||||||
|
SourceKind = summary.SourceKind,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CraftResponse
|
||||||
|
{
|
||||||
|
Success = result.Success,
|
||||||
|
ReasonCode = result.ReasonCode,
|
||||||
|
InputsConsumed = MapIoRows(result.InputsConsumed),
|
||||||
|
OutputsGranted = MapIoRows(result.OutputsGranted),
|
||||||
|
XpGrantSummary = xp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<CraftIoAppliedJson> MapIoRows(IReadOnlyList<CraftIoApplied> rows)
|
||||||
|
{
|
||||||
|
if (rows.Count == 0)
|
||||||
|
{
|
||||||
|
return Array.Empty<CraftIoAppliedJson>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var mapped = new CraftIoAppliedJson[rows.Count];
|
||||||
|
for (var i = 0; i < rows.Count; i++)
|
||||||
|
{
|
||||||
|
var row = rows[i];
|
||||||
|
mapped[i] = new CraftIoAppliedJson
|
||||||
|
{
|
||||||
|
ItemId = row.ItemId,
|
||||||
|
Quantity = row.Quantity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -47,6 +47,7 @@ app.MapItemDefinitionsWorldApi();
|
||||||
app.MapRecipeDefinitionsWorldApi();
|
app.MapRecipeDefinitionsWorldApi();
|
||||||
app.MapResourceNodeDefinitionsWorldApi();
|
app.MapResourceNodeDefinitionsWorldApi();
|
||||||
app.MapPlayerInventoryApi();
|
app.MapPlayerInventoryApi();
|
||||||
|
app.MapPlayerCraftApi();
|
||||||
app.MapSkillProgressionSnapshotApi();
|
app.MapSkillProgressionSnapshotApi();
|
||||||
app.MapPerkStateApi();
|
app.MapPerkStateApi();
|
||||||
if (app.Environment.IsDevelopment() ||
|
if (app.Environment.IsDevelopment() ||
|
||||||
|
|
|
||||||
|
|
@ -88,9 +88,9 @@ On success, **Information** logs include the resolved recipes directory path, di
|
||||||
curl -sS -i "http://localhost:5253/game/world/recipe-definitions"
|
curl -sS -i "http://localhost:5253/game/world/recipe-definitions"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Craft engine (NEO-69)
|
## Craft engine (NEO-69) and craft HTTP (NEO-70)
|
||||||
|
|
||||||
**`CraftOperations.TryCraft`** in **`Game/Crafting/`** resolves server-internal **`CraftResult`**: recipe lookup via **`IRecipeDefinitionRegistry`**, read-only pre-flight for inputs and output capacity (simulated adds on a snapshot clone), input removal and output grant via **`PlayerInventoryOperations`**, then **`refine`** skill XP via **`SkillProgressionGrantOperations`** + **`RefineSkillXpConstants`** (same path as **`RefineActivitySkillXpGrant`**). On output-grant or XP failure after inputs were removed, the engine **rolls back** inventory before denying. Craft HTTP (**`POST /game/players/{id}/craft`**) is **NEO-70**; client craft UI is **[NEO-74](https://linear.app/neon-sprawl/issue/NEO-74)**.
|
**`CraftOperations.TryCraft`** in **`Game/Crafting/`** resolves server-internal **`CraftResult`**: recipe lookup via **`IRecipeDefinitionRegistry`**, read-only pre-flight for inputs and output capacity (simulated adds on a snapshot clone), input removal and output grant via **`PlayerInventoryOperations`**, then **`refine`** skill XP via **`SkillProgressionGrantOperations`** + **`RefineSkillXpConstants`** (same path as **`RefineActivitySkillXpGrant`**). On output-grant or XP failure after inputs were removed, the engine **rolls back** inventory before denying. **NEO-70** exposes **`POST /game/players/{id}/craft`** with wire **`CraftResponse`** (promoted **`CraftResult`** fields). Client craft UI is **[NEO-74](https://linear.app/neon-sprawl/issue/NEO-74)**.
|
||||||
|
|
||||||
| Reason code | When |
|
| Reason code | When |
|
||||||
|-------------|------|
|
|-------------|------|
|
||||||
|
|
@ -101,7 +101,15 @@ curl -sS -i "http://localhost:5253/game/world/recipe-definitions"
|
||||||
| **`progression_store_missing`** | Skill progression store could not apply XP (compensating inventory rollback). |
|
| **`progression_store_missing`** | Skill progression store could not apply XP (compensating inventory rollback). |
|
||||||
| **`inventory_store_missing`** | Player inventory store could not read or write. |
|
| **`inventory_store_missing`** | Player inventory store could not read or write. |
|
||||||
|
|
||||||
Plan: [NEO-69 implementation plan](../../docs/plans/NEO-69-implementation-plan.md). Telemetry hook sites (**`item_crafted`**, **`craft_failed`**) are **NEO-71**.
|
**POST** body (**`schemaVersion` 1**): **`recipeId`**, optional **`quantity`** (defaults to **1**). Response: **`success`**, **`reasonCode`** (`null` on success), **`inputsConsumed`**, **`outputsGranted`**, **`xpGrantSummary`** on success. **400** on schema mismatch or empty **`recipeId`**; **404** on unknown player or store-missing codes; **200** with **`success: false`** + stable deny codes on rule failure.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -X POST "http://localhost:5253/game/players/dev-local-1/craft" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"schemaVersion":1,"recipeId":"refine_scrap_standard"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Plan: [NEO-69 implementation plan](../../docs/plans/NEO-69-implementation-plan.md); [NEO-70 implementation plan](../../docs/plans/NEO-70-implementation-plan.md). Bruno: `bruno/neon-sprawl-server/craft/`. Telemetry hook sites (**`item_crafted`**, **`craft_failed`**) are **NEO-71**.
|
||||||
|
|
||||||
## Resource node definitions (NEO-60)
|
## Resource node definitions (NEO-60)
|
||||||
|
|
||||||
|
|
@ -368,7 +376,7 @@ When the interactable’s **`kind`** is **`resource_node`**, **`POST …/interac
|
||||||
|
|
||||||
### Craft / refine hook → skill XP (NEO-42)
|
### Craft / refine hook → skill XP (NEO-42)
|
||||||
|
|
||||||
**E3.M2** craft/refine success paths call **`SkillProgressionGrantOperations.TryApplyGrant`** with **`RefineSkillXpConstants`** (**10** **`refine`** XP, **`sourceKind: activity`**) — the same stack as **`RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine`** and **`POST …/skill-progression`** ([NEO-38](#skill-progression-grant-neo-38)). **`CraftOperations.TryCraft`** ([NEO-69](#craft-engine-neo-69)) owns the live call site; craft HTTP is NEO-70. Plan: [NEO-42 implementation plan](../../docs/plans/NEO-42-implementation-plan.md); [NEO-69 implementation plan](../../docs/plans/NEO-69-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-42.md`](../../docs/manual-qa/NEO-42.md).
|
**E3.M2** craft/refine success paths call **`SkillProgressionGrantOperations.TryApplyGrant`** with **`RefineSkillXpConstants`** (**10** **`refine`** XP, **`sourceKind: activity`**) — the same stack as **`RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine`** and **`POST …/skill-progression`** ([NEO-38](#skill-progression-grant-neo-38)). **`CraftOperations.TryCraft`** ([NEO-69](#craft-engine-neo-69-and-craft-http-neo-70)) owns the live call site; craft HTTP is **`POST …/craft`** ([NEO-70](#craft-engine-neo-69-and-craft-http-neo-70)). Plan: [NEO-42 implementation plan](../../docs/plans/NEO-42-implementation-plan.md); [NEO-69 implementation plan](../../docs/plans/NEO-69-implementation-plan.md); [NEO-70 implementation plan](../../docs/plans/NEO-70-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-42.md`](../../docs/manual-qa/NEO-42.md).
|
||||||
|
|
||||||
### Mission / quest reward → skill XP (NEO-43)
|
### Mission / quest reward → skill XP (NEO-43)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue