diff --git a/bruno/neon-sprawl-server/craft/Post craft deny insufficient materials.bru b/bruno/neon-sprawl-server/craft/Post craft deny insufficient materials.bru
new file mode 100644
index 0000000..629b8dc
--- /dev/null
+++ b/bruno/neon-sprawl-server/craft/Post craft deny insufficient materials.bru
@@ -0,0 +1,61 @@
+meta {
+ name: POST craft deny insufficient materials
+ type: http
+ seq: 1
+}
+
+docs {
+ NEO-70: make_field_stim_mk0 without refined_plate_stock denies with insufficient_materials. Pre-request clears refined stock (inventory POST setup only) so the deny is stable after dotnet test / prior Bruno requests.
+}
+
+script:pre-request {
+ const { getInventory, sumItemQuantity, postMutation } = require("./scripts/inventory-api-helper.js");
+
+ async function clearItem(itemId) {
+ let qty = sumItemQuantity(await getInventory(bru), itemId);
+ while (qty > 0) {
+ const removeQty = Math.min(qty, 99);
+ const response = await postMutation(bru, {
+ schemaVersion: 1,
+ mutationKind: "remove",
+ itemId,
+ quantity: removeQty,
+ });
+ if (response.status !== 200 || response.data?.applied !== true) {
+ throw new Error(`clear ${itemId} failed: ${response.status} ${JSON.stringify(response.data)}`);
+ }
+ qty = sumItemQuantity(await getInventory(bru), itemId);
+ }
+ }
+
+ await clearItem("refined_plate_stock");
+}
+
+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"
+ }
+}
+
+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;
+ });
+}
diff --git a/bruno/neon-sprawl-server/craft/Post craft deny invalid quantity.bru b/bruno/neon-sprawl-server/craft/Post craft deny invalid quantity.bru
new file mode 100644
index 0000000..115c016
--- /dev/null
+++ b/bruno/neon-sprawl-server/craft/Post craft deny invalid quantity.bru
@@ -0,0 +1,53 @@
+meta {
+ name: POST craft deny invalid quantity
+ type: http
+ seq: 4
+}
+
+docs {
+ NEO-70: non-positive quantity denies with invalid_quantity without consuming inputs.
+}
+
+script:pre-request {
+ const { ensureItemQuantity, getInventory, sumItemQuantity } = require("./scripts/inventory-api-helper.js");
+ await ensureItemQuantity(bru, "scrap_metal_bulk", 5);
+ const inventory = await getInventory(bru);
+ bru.setVar("scrapBeforeInvalidQuantityDeny", sumItemQuantity(inventory, "scrap_metal_bulk"));
+}
+
+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
+ }
+}
+
+script:post-response {
+ const { getInventory, sumItemQuantity } = require("./scripts/inventory-api-helper.js");
+ const inventory = await getInventory(bru);
+ bru.setVar("scrapAfterInvalidQuantityDeny", sumItemQuantity(inventory, "scrap_metal_bulk"));
+}
+
+tests {
+ test("denies with invalid_quantity without consuming scrap", 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");
+ expect(bru.getVar("scrapAfterInvalidQuantityDeny")).to.equal(
+ bru.getVar("scrapBeforeInvalidQuantityDeny"),
+ );
+ });
+}
diff --git a/bruno/neon-sprawl-server/craft/Post craft deny inventory full.bru b/bruno/neon-sprawl-server/craft/Post craft deny inventory full.bru
new file mode 100644
index 0000000..cea18be
--- /dev/null
+++ b/bruno/neon-sprawl-server/craft/Post craft deny inventory full.bru
@@ -0,0 +1,46 @@
+meta {
+ name: POST craft deny inventory full
+ type: http
+ seq: 3
+}
+
+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;
+ });
+}
diff --git a/bruno/neon-sprawl-server/craft/Post craft deny unknown recipe.bru b/bruno/neon-sprawl-server/craft/Post craft deny unknown recipe.bru
new file mode 100644
index 0000000..f6ccd2e
--- /dev/null
+++ b/bruno/neon-sprawl-server/craft/Post craft deny unknown recipe.bru
@@ -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);
+ });
+}
diff --git a/bruno/neon-sprawl-server/craft/Post craft gather refine make spine.bru b/bruno/neon-sprawl-server/craft/Post craft gather refine make spine.bru
new file mode 100644
index 0000000..ee244de
--- /dev/null
+++ b/bruno/neon-sprawl-server/craft/Post craft gather refine make spine.bru
@@ -0,0 +1,152 @@
+meta {
+ name: POST craft gather refine make spine
+ type: http
+ seq: 5
+}
+
+docs {
+ NEO-70 prototype spine: accumulate >=11 scrap_metal_bulk (gather when nodes allow, reuse persisted inventory after dotnet test), refine_scrap_efficient when refined_plate_stock < 2, then make_field_stim_mk0. No inventory POST shortcuts for spine materials.
+}
+
+script:pre-request {
+ const axios = require("axios");
+ const { getInventory, sumItemQuantity } = require("./scripts/inventory-api-helper.js");
+ const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl");
+ const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId");
+ const jsonHeaders = { headers: { "Content-Type": "application/json" } };
+
+ const progressionBefore = await axios.get(`${baseUrl}/game/players/${playerId}/skill-progression`);
+ const refineBefore = progressionBefore.data.skills.find((row) => row.id === "refine");
+ bru.setVar("refineXpBeforeSpine", refineBefore ? refineBefore.xp : 0);
+
+ async function currentScrap() {
+ return sumItemQuantity(await getInventory(bru), "scrap_metal_bulk");
+ }
+
+ async function currentRefined() {
+ return sumItemQuantity(await getInventory(bru), "refined_plate_stock");
+ }
+
+ async function moveNear(x, z) {
+ await axios.post(
+ `${baseUrl}/game/players/${playerId}/move`,
+ { schemaVersion: 1, target: { x, y: 0.9, z } },
+ jsonHeaders,
+ );
+ }
+
+ async function tryGather(interactableId) {
+ const response = await axios.post(
+ `${baseUrl}/game/players/${playerId}/interact`,
+ { schemaVersion: 1, interactableId },
+ jsonHeaders,
+ );
+ if (response.status !== 200) {
+ throw new Error(`gather ${interactableId} HTTP ${response.status}`);
+ }
+ if (response.data?.allowed === true) {
+ return;
+ }
+ if (response.data?.reasonCode === "node_depleted") {
+ return;
+ }
+ throw new Error(`gather ${interactableId} denied: ${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;
+ }
+
+ const gatherPlan = [
+ { id: "prototype_urban_bulk_delta", x: 16, z: -2 },
+ { id: "prototype_bio_mat_gamma", x: 10, z: -2 },
+ { id: "prototype_subsurface_vein_beta", x: 16, z: -6 },
+ { id: "prototype_resource_node_alpha", x: 10, z: -6 },
+ ];
+
+ let scrap = await currentScrap();
+ for (const node of gatherPlan) {
+ if (scrap >= 11) {
+ break;
+ }
+ await moveNear(node.x, node.z);
+ await tryGather(node.id);
+ scrap = await currentScrap();
+ }
+
+ if (scrap < 11) {
+ throw new Error(`spine needs >=11 scrap_metal_bulk, have ${scrap} after optional gathers`);
+ }
+
+ if ((await currentRefined()) < 2) {
+ 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");
+ const before = bru.getVar("refineXpBeforeSpine");
+ expect(refine).to.be.an("object");
+ expect(refine.xp - before).to.be.at.least(10);
+ });
+}
diff --git a/bruno/neon-sprawl-server/craft/folder.bru b/bruno/neon-sprawl-server/craft/folder.bru
new file mode 100644
index 0000000..8e412a1
--- /dev/null
+++ b/bruno/neon-sprawl-server/craft/folder.bru
@@ -0,0 +1,7 @@
+meta {
+ name: craft
+}
+
+docs {
+ NEO-70 craft HTTP. CraftResponse emits explicit null for reasonCode / xpGrantSummary when absent (mirror inventory POST). Run order within folder: deny requests (seq 1–4) then spine (seq 5). Full collection runs after dotnet test on shared Postgres — insufficient-materials clears refined stock; spine reuses persisted scrap and tolerates node_depleted gathers. Deny setup requests may use inventory POST only where noted.
+}
diff --git a/docs/decomposition/modules/E3_M2_RefinementAndRecipeExecution.md b/docs/decomposition/modules/E3_M2_RefinementAndRecipeExecution.md
index 8878d38..9f2f90a 100644
--- a/docs/decomposition/modules/E3_M2_RefinementAndRecipeExecution.md
+++ b/docs/decomposition/modules/E3_M2_RefinementAndRecipeExecution.md
@@ -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)).
-**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 |
|-------|------|
@@ -25,7 +25,7 @@
| **`OutputsGranted`** | Scaled `{ itemId, quantity }` rows added 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
diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md
index b2b6bdc..d8a498e 100644
--- a/docs/decomposition/modules/documentation_and_implementation_alignment.md
+++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md
@@ -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 |
| 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.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) |
---
diff --git a/docs/plans/E3M2-prototype-backlog.md b/docs/plans/E3M2-prototype-backlog.md
index 5711fe0..8e6b33b 100644
--- a/docs/plans/E3M2-prototype-backlog.md
+++ b/docs/plans/E3M2-prototype-backlog.md
@@ -205,10 +205,12 @@ Working backlog for **Epic 3 — Slice 3** ([recipes and crafting pipeline](../d
**Acceptance criteria**
-- [ ] POST success returns structured **`CraftResult`** (or HTTP envelope) matching engine outcome.
-- [ ] Bruno spine completes without manual inventory POST shortcuts (gather interact allowed for scrap).
-- [ ] 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] POST success returns structured **`CraftResult`** (or HTTP envelope) matching engine outcome.
+- [x] Bruno spine completes without manual inventory POST shortcuts (gather interact allowed for scrap).
+- [x] At least one deny path documented per major `reasonCode`.
+- [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).
---
@@ -240,7 +242,7 @@ Working backlog for **Epic 3 — Slice 3** ([recipes and crafting pipeline](../d
- Slice 4 (**E3.M4** + **E3.M5**) sinks/policy remains pre-production.
- Track delivery in Linear; keep `blockedBy` links synchronized if scope changes.
- For each issue kickoff, add `docs/plans/{NEO-XX}-implementation-plan.md` per [story-kickoff](../../.cursor/rules/story-kickoff.md).
-- Add `docs/manual-qa/{NEO-XX}.md` when craft HTTP lands (E3M2-06+).
+- Add `docs/manual-qa/{NEO-XX}.md` when craft HTTP lands (E3M2-06+) — **[NEO-70](https://linear.app/neon-sprawl/issue/NEO-70) waived** manual QA at kickoff (Bruno + README only).
## Related docs
diff --git a/docs/plans/NEO-70-implementation-plan.md b/docs/plans/NEO-70-implementation-plan.md
new file mode 100644
index 0000000..9e0e540
--- /dev/null
+++ b/docs/plans/NEO-70-implementation-plan.md
@@ -0,0 +1,144 @@
+# NEO-70 — Implementation plan
+
+## Story reference
+
+| Field | Value |
+|--------|--------|
+| **Key** | NEO-70 |
+| **Title** | E3.M2: Craft HTTP + Bruno gather→refine→make spine |
+| **Linear** | https://linear.app/neon-sprawl/issue/NEO-70/e3m2-craft-http-bruno-gatherrefinemake-spine |
+| **Module** | [E3.M2 — RefinementAndRecipeExecution](../decomposition/modules/E3_M2_RefinementAndRecipeExecution.md) · Epic 3 Slice 3 · backlog **E3M2-06** |
+| **Branch** | `NEO-70-craft-http-bruno-gather-refine-make-spine` |
+| **Precursor** | [NEO-69](https://linear.app/neon-sprawl/issue/NEO-69) — `CraftOperations.TryCraft` + `CraftResult` (**Done** on `main`) |
+| **Pattern** | [NEO-55](https://linear.app/neon-sprawl/issue/NEO-55) — `POST /game/players/{id}/inventory` (known-player gate, schemaVersion, 200 + structured deny) |
+| **Client counterpart** | [NEO-74](https://linear.app/neon-sprawl/issue/NEO-74) — E3S5-03 craft UI + recipe list (blocked by this issue; server-only in this story) |
+| **Blocks** | [NEO-74](https://linear.app/neon-sprawl/issue/NEO-74) craft UI HTTP dependency |
+
+## Kickoff clarifications
+
+| Topic | Question | Agent recommendation | Answer |
+|--------|----------|----------------------|--------|
+| **POST response envelope** | Promote `CraftResult` 1:1 vs include inventory echo? | **CraftResult fields only** — `schemaVersion` **1**, `success`, `reasonCode`, `inputsConsumed`, `outputsGranted`, `xpGrantSummary`; separate GET inventory / skill-progression for Bruno spine (E3_M2 module doc wire table). | **User:** CraftResult fields only. |
+| **Manual QA doc** | Add `docs/manual-qa/NEO-70.md`? | **Yes** — E3M2-06 backlog calls for manual QA when craft HTTP lands (NEO-68 pattern). | **User:** **No** — server-only story, no Godot; Bruno + README sufficient (NEO-55 pattern). |
+
+No other blocking decisions — request shape (`recipeId` + optional `quantity` default **1**), route (`POST /game/players/{id}/craft`), known-player gate, and HTTP status mapping follow [E3M2 kickoff defaults](E3M2-prototype-backlog.md#kickoff-decisions-decomposition-defaults) and NEO-55/NEO-38 precedent.
+
+## Goal, scope, and out-of-scope
+
+**Goal:** Versioned **`POST /game/players/{id}/craft`** wired to **`CraftOperations.TryCraft`**; Bruno documents the full prototype loop **gather → refine → make** through combat-relevant **`field_stim_mk0`** without inventory POST shortcuts.
+
+**In scope (from Linear + [E3M2-06](E3M2-prototype-backlog.md#e3m2-06--craft-http--bruno-gatherrefinemake-spine)):**
+
+- **`POST /game/players/{id}/craft`** + wire DTOs + API integration tests (AAA).
+- Known-player gate via **`IPositionStateStore`** (NEO-37 / NEO-55 pattern).
+- Bruno **`bruno/neon-sprawl-server/craft/`** — full spine + deny paths for major craft **`reasonCode`** values.
+- **`server/README.md`** craft HTTP subsection; module doc + alignment table updates.
+
+**Out of scope (from Linear + backlog):**
+
+- Godot craft UI — **[NEO-74](https://linear.app/neon-sprawl/issue/NEO-74)**.
+- Station interact / world bench placement.
+- **`docs/manual-qa/NEO-70.md`** (kickoff decision — Bruno + README only).
+- Telemetry hook sites ([NEO-71](https://linear.app/neon-sprawl/issue/NEO-71)).
+
+## Acceptance criteria checklist
+
+- [x] POST success returns structured **`CraftResult`** wire JSON matching engine outcome.
+- [x] Bruno spine: gather **`scrap_metal_bulk`** → **`refine_scrap_standard`** (or efficient path) → **`make_field_stim_mk0`** without manual inventory POST shortcuts.
+- [x] At least one deny path documented per major **`reasonCode`** (see Tests / Bruno below).
+- [x] Epic 3 Slice 3 acceptance: player completes **gather → refine → usable item** (`field_stim_mk0` consumable).
+
+## Technical approach
+
+1. **Route:** **`POST /game/players/{id}/craft`** — trim **`id`**; **404** when empty/unknown position (NEO-55 gate).
+
+2. **Request (`PlayerCraftRequest`, `schemaVersion` **1**):**
+ - **`recipeId`** (required, non-empty after trim).
+ - **`quantity`** (optional, default **1** when omitted or null) — passed to **`CraftOperations.TryCraft`**.
+
+3. **Response (`CraftResponse`, `schemaVersion` **1**)** — promote NEO-69 envelope (kickoff decision):
+ - **`success`**: `true` on commit, `false` on rule deny.
+ - **`reasonCode`**: `null` on success; **`CraftReasonCodes`** string on deny.
+ - **`inputsConsumed`**: `{ itemId, quantity }[]` (empty on deny).
+ - **`outputsGranted`**: `{ itemId, quantity }[]` (empty on deny).
+ - **`xpGrantSummary`**: `{ skillId, amount, sourceKind }` on success; omitted or `null` on deny.
+
+4. **HTTP status mapping (NEO-55 / NEO-38 precedent):**
+ - **400** — null body, **`schemaVersion`** mismatch, empty/missing **`recipeId`**.
+ - **404** — unknown player (position gate) or **`CraftReasonCodes.InventoryStoreMissing`** / **`ProgressionStoreMissing`** when store missing (mirror inventory POST store-missing → 404).
+ - **200** + JSON — engine deny with **`success: false`** + **`reasonCode`** + empty I/O lists.
+ - **200** + JSON — success with full envelope.
+
+5. **Implementation:** New **`PlayerCraftApi.cs`** + **`CraftWireDtos.cs`** in `Game/Crafting/` (map request → **`CraftOperations.TryCraft`**, map **`CraftResult`** → response). Wire **`app.MapPlayerCraftApi()`** from **`Program.cs`** after **`MapPlayerInventoryApi()`**.
+
+6. **Bruno (`bruno/neon-sprawl-server/craft/`):**
+ - **`Post craft gather refine make spine.bru`** — pre-request script (axios): move + interact on **four** prototype resource nodes to accumulate **≥11** **`scrap_metal_bulk`** without inventory POST (delta **5** + gamma **3** + beta **2** + alpha **1**); **`POST craft`** **`refine_scrap_efficient`** (10→2 **`refined_plate_stock`**); **`POST craft`** **`make_field_stim_mk0`**; assert **`success` true**; optional follow-up GET inventory + GET skill-progression asserts **`field_stim_mk0`** qty **≥1** and **`refine`** XP increased.
+ - **`Post craft deny unknown recipe.bru`** — **`recipeId`** `unknown_recipe_id` → **`success` false**, **`unknown_recipe`**.
+ - **`Post craft deny insufficient materials.bru`** — empty player (no gather seed) → **`insufficient_materials`**.
+ - **`Post craft deny inventory full.bru`** — pre-request fills bag via inventory helper or repeated adds, then craft with output that cannot fit → **`inventory_full`** (may use **`make_prototype_armor`** or a make recipe after filling bag slots).
+ - **`Post craft deny invalid quantity.bru`** — **`quantity: 0`** → **`invalid_quantity`**.
+ - **`folder.bru`** — match sibling folders.
+
+7. **Docs (on land):** Expand **`server/README.md`** craft section with POST route, request/response JSON, curl examples, reason codes. Update [E3_M2](E3_M2_RefinementAndRecipeExecution.md) **Implementation snapshot** — mark **`CraftResult`** promoted to wire; [documentation_and_implementation_alignment.md](documentation_and_implementation_alignment.md) E3.M2 row; E3M2-06 checkboxes in [E3M2-prototype-backlog.md](E3M2-prototype-backlog.md). Cross-link **NEO-74** per [full-stack epic decomposition](../../.cursor/rules/full-stack-epic-decomposition.md).
+
+## Files to add
+
+| Path | Purpose |
+|------|---------|
+| `server/NeonSprawl.Server/Game/Crafting/CraftWireDtos.cs` | Versioned POST request/response + I/O row + XP summary JSON DTOs (`schemaVersion` 1). |
+| `server/NeonSprawl.Server/Game/Crafting/PlayerCraftApi.cs` | `MapPlayerCraftApi` — POST route, position gate, engine dispatch, JSON mapping. |
+| `server/NeonSprawl.Server.Tests/Game/Crafting/PlayerCraftApiTests.cs` | AAA HTTP integration: success, denies, 400/404, schema mismatch. |
+| `bruno/neon-sprawl-server/craft/folder.bru` | Bruno folder metadata. |
+| `bruno/neon-sprawl-server/craft/Post craft gather refine make spine.bru` | Full gather→refine→make spine (no inventory POST). |
+| `bruno/neon-sprawl-server/craft/Post craft deny unknown recipe.bru` | Deny **`unknown_recipe`**. |
+| `bruno/neon-sprawl-server/craft/Post craft deny insufficient materials.bru` | Deny **`insufficient_materials`**. |
+| `bruno/neon-sprawl-server/craft/Post craft deny inventory full.bru` | Deny **`inventory_full`**. |
+| `bruno/neon-sprawl-server/craft/Post craft deny invalid quantity.bru` | Deny **`invalid_quantity`**. |
+| `docs/plans/NEO-70-implementation-plan.md` | This plan. |
+
+## Files to modify
+
+| Path | Rationale |
+|------|-----------|
+| `server/NeonSprawl.Server/Program.cs` | Register **`MapPlayerCraftApi()`** alongside other player mutation APIs. |
+| `server/README.md` | Document craft POST route, request/response shapes, curl examples, reason codes; remove “HTTP deferred to NEO-70” note. |
+| `server/NeonSprawl.Server/Game/Crafting/CraftResult.cs` | Update XML remark — wire promotion landed in NEO-70. |
+| `docs/decomposition/modules/E3_M2_RefinementAndRecipeExecution.md` | **Implementation snapshot** — craft HTTP + wire **`CraftResult`** contract. |
+| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E3.M2 row — note NEO-70 craft POST when landed. |
+| `docs/plans/E3M2-prototype-backlog.md` | E3M2-06 acceptance checkboxes + landed note when complete. |
+
+## Tests
+
+| Test file | What it covers |
+|-----------|----------------|
+| `server/NeonSprawl.Server.Tests/Game/Crafting/PlayerCraftApiTests.cs` | **404** unknown player. **400** bad **`schemaVersion`**, empty **`recipeId`**. **Success:** seed inventory via store helper (test-only), POST **`refine_scrap_standard`**, assert **`success` true**, **`inputsConsumed`** / **`outputsGranted`**, **`xpGrantSummary`** (`refine`, **10**, `activity`), **`reasonCode` null**. **Deny:** **`unknown_recipe`**, **`insufficient_materials`**, **`invalid_quantity`**, **`inventory_full`** — each **200**, **`success` false**, stable **`reasonCode`**, empty I/O lists; inventory unchanged where applicable. **AAA** per [csharp-style](../../.cursor/rules/csharp-style.md). |
+
+Bruno scripts cover end-to-end gather→craft spine and manual dev-server verification. Store-missing codes (**`inventory_store_missing`**, **`progression_store_missing`**) covered by API tests with test doubles if practical; otherwise documented as 404 paths in README (not Bruno — requires broken store).
+
+Major **`reasonCode`** coverage matrix:
+
+| `reasonCode` | Automated API test | Bruno |
+|--------------|-------------------|-------|
+| `unknown_recipe` | yes | yes |
+| `insufficient_materials` | yes | yes |
+| `invalid_quantity` | yes | yes |
+| `inventory_full` | yes | yes |
+| `inventory_store_missing` | API test or README note | no |
+| `progression_store_missing` | defer to engine tests (NEO-69) | no |
+
+## Open questions / risks
+
+| Question / risk | Agent recommendation | Status |
+|-----------------|----------------------|--------|
+| **Bruno scrap totals for spine** | Use **four** distinct prototype nodes (11 scrap) + **`refine_scrap_efficient`** so **`make_field_stim_mk0`** has 2× **`refined_plate_stock`** + 1 scrap without inventory POST. | **adopted** |
+| **Node depletion** | Spine Bruno must hit **different** `nodeDefId` rows (one interact each); document node order in Bruno `docs` block. | **adopted** |
+| **No manual QA doc** | User chose Bruno + README only; Epic 3 Slice 3 “player completes loop” verified via Bruno spine + API tests. | **adopted** |
+| **Client not in this story** | Cross-link **NEO-74**; Bruno-only verification is not prototype-complete per full-stack rule. | **adopted** |
+| **NEO-71 telemetry** | No hook comments in NEO-70 — NEO-71 owns engine hook placement. | **adopted** |
+
+## Decisions (kickoff)
+
+- **CraftResult-only wire response** — no inventory echo (user confirmed).
+- **No `docs/manual-qa/NEO-70.md`** — Bruno + README only (user confirmed; no Godot in scope).
+- **HTTP deny = 200 + `success: false`** for rule denies; **400** for contract errors; **404** for unknown player / store missing (NEO-55 precedent).
+- **Optional `quantity` defaults to 1** when omitted (E3M2 backlog default).
diff --git a/docs/reviews/2026-05-24-NEO-70.md b/docs/reviews/2026-05-24-NEO-70.md
new file mode 100644
index 0000000..5122653
--- /dev/null
+++ b/docs/reviews/2026-05-24-NEO-70.md
@@ -0,0 +1,66 @@
+# Code review — NEO-70 craft HTTP + Bruno spine
+
+**Date:** 2026-05-24
+**Scope:** Branch `NEO-70-craft-http-bruno-gather-refine-make-spine` · commits `b148db8`–`46393b8` vs `origin/main`
+**Base:** `origin/main`
+
+## Verdict
+
+**Approve with nits** — implementation matches the adopted plan and E3M2-06 acceptance criteria; ready to merge after optional follow-ups below.
+
+## Summary
+
+The branch adds **`POST /game/players/{id}/craft`** via **`PlayerCraftApi`** and versioned wire DTOs in **`CraftWireDtos.cs`**, mapping request → **`CraftOperations.TryCraft`** → **`CraftResponse`**. HTTP behavior follows NEO-55 precedent: known-player gate, **400** on contract errors, **404** on unknown player or store-missing codes, **200** + structured deny for rule failures. Eight AAA integration tests cover success, major deny paths, and error statuses. Bruno **`craft/`** folder documents the full gather→refine→make spine (11 scrap from four nodes → **`refine_scrap_efficient`** → **`make_field_stim_mk0`**) plus five deny scenarios. Docs (plan, module snapshot, backlog E3M2-06, alignment table, README) are updated and cross-link **NEO-74**. Risk is low for prototype scope; gaps are mostly deferred store-missing HTTP tests (explicitly allowed by plan) and minor naming / Bruno isolation nits.
+
+## Documentation checked
+
+| Document | Result |
+|----------|--------|
+| [`docs/plans/NEO-70-implementation-plan.md`](../plans/NEO-70-implementation-plan.md) | **Matches** — CraftResult-only wire response, quantity default **1**, HTTP status mapping, Bruno spine math (11 scrap + efficient refine), deny matrix, no manual QA doc (kickoff), NEO-74 cross-link, acceptance checklist complete. |
+| [`docs/plans/E3M2-prototype-backlog.md`](../plans/E3M2-prototype-backlog.md) · **E3M2-06** | **Matches** — acceptance criteria checked; landed note present. Generic bullet “Add manual QA when craft HTTP lands” remains (NEO-70 opted out per kickoff — see Nits). |
+| [`docs/decomposition/modules/E3_M2_RefinementAndRecipeExecution.md`](../decomposition/modules/E3_M2_RefinementAndRecipeExecution.md) | **Matches** — implementation snapshot includes NEO-70 craft HTTP + wire **`CraftResponse`** field table. |
+| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E3.M2 row notes NEO-70 landed; Bruno path cited. |
+| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **Matches** — server-authoritative craft mutation; client deferred to NEO-74. |
+| NEO-55 reference — [`PlayerInventoryApi`](../../server/NeonSprawl.Server/Game/Items/PlayerInventoryApi.cs) | **Matches** — same position gate, schemaVersion **400**, store-missing → **404**, rule deny → **200** + structured body. |
+| Full-stack epic decomposition | **Matches** — NEO-74 documented as client counterpart; no claim of prototype-complete player-visible craft. |
+
+Register/tracking: E3.M2 **In Progress** is correct; no register bump required until Slice 3 closes (NEO-71).
+
+## Blocking issues
+
+None.
+
+## Suggestions
+
+1. ~~**Store-missing HTTP → 404** — Plan allows API test *or* README for **`inventory_store_missing`** / **`progression_store_missing`**. README documents **404** paths; engine tests cover progression store missing. Optional: add one **`PlayerCraftApiTests`** case with a test double store returning **`InventoryStoreMissing`** to lock HTTP mapping (mirror **`PlayerInventoryApi`** **`StoreMissing` → 404**).~~ **Done.** Added **`PostCraft_WhenInventoryStoreMissing_ShouldReturnNotFound`** with **`InventoryStoreAlwaysMissing`** test double via **`WithWebHostBuilder`**.
+
+2. ~~**Wire request naming** — Wire type is **`CraftRequest`** while inventory uses **`PlayerInventoryMutationRequest`**. Consider **`PlayerCraftRequest`** (or **`CraftWireRequest`**) to avoid collision with the module doc’s conceptual **`CraftRequest`** contract (actor + recipe + quantities) and to match player-scoped mutation DTO naming.~~ **Done.** Renamed wire DTO to **`PlayerCraftRequest`**.
+
+3. ~~**Default `quantity` test** — Plan adopts omitted/null **`quantity` → 1**. Add one API test POST without **`quantity`** (or explicit **`null`**) asserting success with same outcome as **`quantity: 1`**.~~ **Done.** Added **`PostCraft_WhenQuantityOmitted_ShouldDefaultToOneAndSucceed`**.
+
+4. ~~**Bruno deny isolation** — **`Post craft deny insufficient materials.bru`** assumes empty bag on **`dev-local-1`**. On a persistent Postgres dev store, prior spine/inventory requests can seed scrap and cause a false pass. Document collection run order in **`folder.bru`** or add a pre-request inventory baseline assert (same pattern as NEO-63 delta-node isolation).~~ **Done.** **`folder.bru`** documents run order; insufficient-materials request is **seq 1** with pre-request empty-bag guard; spine moved to **seq 5**.
+
+## Nits
+
+- ~~Nit: [`E3M2-prototype-backlog.md`](../plans/E3M2-prototype-backlog.md) line “Add `docs/manual-qa/{NEO-XX}.md` when craft HTTP lands” is generic; NEO-70 kickoff explicitly waived manual QA — consider a footnote so future readers do not expect **`NEO-70.md`**.~~ **Done.**
+
+- ~~Nit: Bruno **`Post craft deny invalid quantity.bru`** does not assert scrap quantity unchanged (API test does via **`CountBagItem`**).~~ **Done.** Pre-request snapshots scrap total; test asserts quantity still **5**.
+
+- ~~Nit: README craft section has curl + reason table but no sample **200** success JSON body; a one-liner example would help Bruno adopters.~~ **Done.**
+
+## Verification
+
+```bash
+cd server
+dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj --filter "FullyQualifiedName~PlayerCraftApiTests"
+dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj --filter "FullyQualifiedName~PlayerCraftApiTests|FullyQualifiedName~CraftOperationsTests"
+```
+
+**Results (2026-05-24):** **10** `PlayerCraftApiTests` pass; **21** craft-related tests (`PlayerCraftApiTests` + `CraftOperationsTests`) pass.
+
+Full suite: **11** failures in `AbilityCastApiTests` (host startup / unrelated to this branch diff); **327** pass. Re-run full suite before merge if CI gates on it — failures appear pre-existing on `main`, not introduced by NEO-70.
+
+Manual (Bruno):
+
+- Start server; run **`bruno/neon-sprawl-server/craft/Post craft gather refine make spine.bru`** against `environments/Local.bru` — **200**, **`success: true`**, **`field_stim_mk0`** in inventory, **`refine`** XP ≥ 20.
+- Run deny requests in **`craft/`** folder (inventory full / invalid quantity use inventory POST for setup only — per plan).
diff --git a/server/NeonSprawl.Server.Tests/Game/Crafting/PlayerCraftApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Crafting/PlayerCraftApiTests.cs
new file mode 100644
index 0000000..5838719
--- /dev/null
+++ b/server/NeonSprawl.Server.Tests/Game/Crafting/PlayerCraftApiTests.cs
@@ -0,0 +1,328 @@
+using System.Net;
+using System.Net.Http.Json;
+using Microsoft.AspNetCore.TestHost;
+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;
+
+/// Regression tests for POST …/craft (NEO-70).
+public sealed class PlayerCraftApiTests
+{
+ private const string PlayerId = "dev-local-1";
+
+ private static PlayerCraftRequest Request(
+ string recipeId,
+ int? quantity = null,
+ int schemaVersion = PlayerCraftRequest.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 PlayerCraftRequest { SchemaVersion = PlayerCraftRequest.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();
+ 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);
+ var json = await response.Content.ReadAsStringAsync();
+ Assert.Contains("\"reasonCode\":null", json, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task PostCraft_WhenQuantityOmitted_ShouldDefaultToOneAndSucceed()
+ {
+ // 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",
+ new { schemaVersion = PlayerCraftRequest.CurrentSchemaVersion, recipeId = "refine_scrap_standard" });
+
+ // Assert
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var body = await response.Content.ReadFromJsonAsync();
+ Assert.NotNull(body);
+ Assert.True(body!.Success);
+ Assert.Single(body.InputsConsumed);
+ Assert.Equal(5, body.InputsConsumed[0].Quantity);
+ Assert.Single(body.OutputsGranted);
+ Assert.Equal(1, body.OutputsGranted[0].Quantity);
+ }
+
+ [Fact]
+ public async Task PostCraft_WhenMaterialsMissing_ShouldDenyWithInsufficientMaterials()
+ {
+ // Arrange
+ await using var factory = new InMemoryWebApplicationFactory();
+ var client = factory.CreateClient();
+ var beforeInventory = await client.GetFromJsonAsync(
+ $"/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();
+ 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(
+ $"/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();
+ 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);
+ var json = await response.Content.ReadAsStringAsync();
+ Assert.Contains("\"reasonCode\":\"unknown_recipe\"", json, StringComparison.Ordinal);
+ Assert.Contains("\"xpGrantSummary\":null", json, StringComparison.Ordinal);
+ }
+
+ [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();
+ 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(
+ $"/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();
+ Assert.NotNull(body);
+ Assert.False(body!.Success);
+ Assert.Equal(CraftReasonCodes.InventoryFull, body.ReasonCode);
+ var afterInventory = await client.GetFromJsonAsync(
+ $"/game/players/{PlayerId}/inventory");
+ Assert.Equal(TotalBagQuantity(beforeInventory!), TotalBagQuantity(afterInventory!));
+ }
+
+ [Fact]
+ public async Task PostCraft_WhenInventoryStoreMissing_ShouldReturnNotFound()
+ {
+ // Arrange
+ await using var factory = new InMemoryWebApplicationFactory().WithWebHostBuilder(builder =>
+ {
+ builder.ConfigureTestServices(services =>
+ {
+ for (var i = services.Count - 1; i >= 0; i--)
+ {
+ if (services[i].ServiceType == typeof(IPlayerInventoryStore))
+ {
+ services.RemoveAt(i);
+ }
+ }
+
+ services.AddSingleton();
+ });
+ });
+ var client = factory.CreateClient();
+
+ // Act
+ var response = await client.PostAsJsonAsync(
+ $"/game/players/{PlayerId}/craft",
+ Request("refine_scrap_standard"));
+
+ // Assert
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
+
+ private static void SeedStack(InMemoryWebApplicationFactory factory, string itemId, int quantity)
+ {
+ var itemRegistry = factory.Services.GetRequiredService();
+ var inventoryStore = factory.Services.GetRequiredService();
+ 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();
+ 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;
+ }
+
+ private sealed class InventoryStoreAlwaysMissing : IPlayerInventoryStore
+ {
+ public bool TryGetSnapshot(string playerId, out PlayerInventorySnapshot snapshot)
+ {
+ snapshot = PlayerInventorySnapshot.Empty();
+ return false;
+ }
+
+ public bool TryReplaceSnapshot(string playerId, PlayerInventorySnapshot snapshot) => false;
+
+ public bool TryMutateSnapshot(
+ string playerId,
+ Func mutator,
+ out PlayerInventorySnapshot result)
+ {
+ result = PlayerInventorySnapshot.Empty();
+ return false;
+ }
+ }
+}
diff --git a/server/NeonSprawl.Server/Game/Crafting/CraftOperations.cs b/server/NeonSprawl.Server/Game/Crafting/CraftOperations.cs
index 57f66fa..c9dca41 100644
--- a/server/NeonSprawl.Server/Game/Crafting/CraftOperations.cs
+++ b/server/NeonSprawl.Server/Game/Crafting/CraftOperations.cs
@@ -6,7 +6,7 @@ namespace NeonSprawl.Server.Game.Crafting;
///
/// 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: (NEO-70).
///
public static class CraftOperations
{
diff --git a/server/NeonSprawl.Server/Game/Crafting/CraftResult.cs b/server/NeonSprawl.Server/Game/Crafting/CraftResult.cs
index 7ba6fa4..b226f1b 100644
--- a/server/NeonSprawl.Server/Game/Crafting/CraftResult.cs
+++ b/server/NeonSprawl.Server/Game/Crafting/CraftResult.cs
@@ -1,7 +1,7 @@
namespace NeonSprawl.Server.Game.Crafting;
///
-/// 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 (NEO-70).
/// NEO-71 telemetry hook sites will live in .
///
/// Scaled inputs removed on success; empty when denied.
diff --git a/server/NeonSprawl.Server/Game/Crafting/CraftWireDtos.cs b/server/NeonSprawl.Server/Game/Crafting/CraftWireDtos.cs
new file mode 100644
index 0000000..b245f23
--- /dev/null
+++ b/server/NeonSprawl.Server/Game/Crafting/CraftWireDtos.cs
@@ -0,0 +1,66 @@
+using System.Text.Json.Serialization;
+
+namespace NeonSprawl.Server.Game.Crafting;
+
+/// POST body for POST /game/players/{{id}}/craft (NEO-70).
+public sealed class PlayerCraftRequest
+{
+ public const int CurrentSchemaVersion = 1;
+
+ [JsonPropertyName("schemaVersion")]
+ public int SchemaVersion { get; init; }
+
+ [JsonPropertyName("recipeId")]
+ public required string RecipeId { get; init; }
+
+ /// Optional batch count; defaults to 1 when omitted.
+ [JsonPropertyName("quantity")]
+ public int? Quantity { get; init; }
+}
+
+/// POST response — promoted wire shape (NEO-70).
+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")]
+ public string? ReasonCode { get; init; }
+
+ [JsonPropertyName("inputsConsumed")]
+ public required IReadOnlyList InputsConsumed { get; init; }
+
+ [JsonPropertyName("outputsGranted")]
+ public required IReadOnlyList OutputsGranted { get; init; }
+
+ [JsonPropertyName("xpGrantSummary")]
+ public CraftXpGrantSummaryJson? XpGrantSummary { get; init; }
+}
+
+/// One scaled input consumed or output granted on successful craft.
+public sealed class CraftIoAppliedJson
+{
+ [JsonPropertyName("itemId")]
+ public required string ItemId { get; init; }
+
+ [JsonPropertyName("quantity")]
+ public int Quantity { get; init; }
+}
+
+/// Refine skill XP applied on successful craft.
+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; }
+}
diff --git a/server/NeonSprawl.Server/Game/Crafting/PlayerCraftApi.cs b/server/NeonSprawl.Server/Game/Crafting/PlayerCraftApi.cs
new file mode 100644
index 0000000..2a6dbc5
--- /dev/null
+++ b/server/NeonSprawl.Server/Game/Crafting/PlayerCraftApi.cs
@@ -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;
+
+/// Maps POST /game/players/{{id}}/craft — craft execution (NEO-70).
+public static class PlayerCraftApi
+{
+ public static WebApplication MapPlayerCraftApi(this WebApplication app)
+ {
+ app.MapPost(
+ "/game/players/{id}/craft",
+ (string id, PlayerCraftRequest? body, IPositionStateStore positions,
+ IRecipeDefinitionRegistry recipeRegistry, IItemDefinitionRegistry itemRegistry,
+ IPlayerInventoryStore inventoryStore, ISkillDefinitionRegistry skillRegistry,
+ IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve,
+ PerkUnlockEngine perkUnlockEngine) =>
+ {
+ if (body is null || body.SchemaVersion != PlayerCraftRequest.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 MapIoRows(IReadOnlyList rows)
+ {
+ if (rows.Count == 0)
+ {
+ return Array.Empty();
+ }
+
+ 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;
+ }
+}
diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs
index 7d7e2e7..645dd15 100644
--- a/server/NeonSprawl.Server/Program.cs
+++ b/server/NeonSprawl.Server/Program.cs
@@ -47,6 +47,7 @@ app.MapItemDefinitionsWorldApi();
app.MapRecipeDefinitionsWorldApi();
app.MapResourceNodeDefinitionsWorldApi();
app.MapPlayerInventoryApi();
+app.MapPlayerCraftApi();
app.MapSkillProgressionSnapshotApi();
app.MapPerkStateApi();
if (app.Environment.IsDevelopment() ||
diff --git a/server/README.md b/server/README.md
index 36dc5b5..11addb2 100644
--- a/server/README.md
+++ b/server/README.md
@@ -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"
```
-## 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 |
|-------------|------|
@@ -101,7 +101,21 @@ curl -sS -i "http://localhost:5253/game/world/recipe-definitions"
| **`progression_store_missing`** | Skill progression store could not apply XP (compensating inventory rollback). |
| **`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** when omitted). Wire request type: **`PlayerCraftRequest`**. 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"}'
+```
+
+Sample **200** success body (after seeding **5×** **`scrap_metal_bulk`** via gather or inventory POST):
+
+```json
+{"schemaVersion":1,"success":true,"inputsConsumed":[{"itemId":"scrap_metal_bulk","quantity":5}],"outputsGranted":[{"itemId":"refined_plate_stock","quantity":1}],"xpGrantSummary":{"skillId":"refine","amount":10,"sourceKind":"activity"}}
+```
+
+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)
@@ -368,7 +382,7 @@ When the interactable’s **`kind`** is **`resource_node`**, **`POST …/interac
### 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)