# NEO-69 — Implementation plan ## Story reference | Field | Value | |--------|--------| | **Key** | NEO-69 | | **Title** | E3.M2: CraftResult engine (inputs, outputs, refine XP) | | **Linear** | https://linear.app/neon-sprawl/issue/NEO-69/e3m2-craftresult-engine-inputs-outputs-refine-xp | | **Module** | [E3.M2 — RefinementAndRecipeExecution](../decomposition/modules/E3_M2_RefinementAndRecipeExecution.md) · Epic 3 Slice 3 · backlog **E3M2-05** | | **Branch** | `NEO-69-craftresult-engine-inputs-outputs-refine-xp` | | **Precursors** | [NEO-67](https://linear.app/neon-sprawl/issue/NEO-67) — `IRecipeDefinitionRegistry` + DI (**Done** on `main`); [NEO-54](https://linear.app/neon-sprawl/issue/NEO-54) — `PlayerInventoryOperations` (**Done**); [NEO-42](https://linear.app/neon-sprawl/issue/NEO-42) — `RefineActivitySkillXpGrant` (**Done**) | | **Pattern** | [NEO-62](https://linear.app/neon-sprawl/issue/NEO-62) — static `*Operations` orchestrator + subsystem reason codes; [NEO-64](https://linear.app/neon-sprawl/issue/NEO-64) — engine-only scope (HTTP in follow-up) | | **Client counterpart** | [NEO-74](https://linear.app/neon-sprawl/issue/NEO-74) — E3S5-03 craft UI + recipe list (blocked by [NEO-70](https://linear.app/neon-sprawl/issue/NEO-70) craft HTTP; server-only in this story) | | **Blocks** | [NEO-70](https://linear.app/neon-sprawl/issue/NEO-70) — craft POST + Bruno spine; [NEO-71](https://linear.app/neon-sprawl/issue/NEO-71) — telemetry hook sites | ## Kickoff clarifications | Topic | Question | Agent recommendation | Answer | |--------|----------|----------------------|--------| | **Output-full deny strategy** | Pre-flight output capacity vs consume inputs then rollback? | **Pre-flight** — simulate all scaled output adds on a cloned snapshot before any input removal; deny **`inventory_full`** with zero mutations (cleaner for multi-input recipes than compensating rollback). | **User:** pre-flight. | | **Quantity in engine API** | Include `quantity` in `TryCraft` in NEO-69? | **Yes** — E3M2-05 lists **`invalid_quantity`**; [NEO-70](https://linear.app/neon-sprawl/issue/NEO-70) POST will pass `recipeId` + optional quantity default **1**; engine owns validation now. | **Adopted** — backlog + NEO-70 wiring. | | **Craft vs inventory reason codes** | Surface `insufficient_quantity` or craft-layer `insufficient_materials`? | **Craft-layer `insufficient_materials`** — E3M2 backlog stable deny vocabulary; map from inventory checks at orchestration boundary (mirror gather passthrough for `inventory_full` only). | **Adopted** — backlog. | | **XP failure after commit** | Roll back inventory on XP deny? | **Yes** — compensating remove of outputs + re-add inputs (mirror NEO-62 gather rollback on XP failure). | **Adopted** — NEO-62 precedent. | | **Manual QA doc** | Add `docs/manual-qa/NEO-69.md`? | **No** — engine-only like NEO-62; automated tests + [NEO-70](https://linear.app/neon-sprawl/issue/NEO-70) manual QA cover player-visible verification. | **Adopted** — NEO-62 pattern. | ## Goal, scope, and out-of-scope **Goal:** Single server operation resolving **`CraftResult`**: validate recipe + inventory, atomically remove inputs and add outputs via **`PlayerInventoryOperations`**, award **`refine`** XP via **`RefineActivitySkillXpGrant`** on success — engine-only, ready for craft HTTP (NEO-70). **In scope (from Linear + [E3M2-05](E3M2-prototype-backlog.md#e3m2-05--craftresult-engine-consume-inputs-grant-outputs-refine-xp)):** - **`CraftOperations`** / **`CraftResult`** types in `server/NeonSprawl.Server/Game/Crafting/`. - Stable deny reason codes: **`unknown_recipe`**, **`insufficient_materials`**, **`inventory_full`**, **`invalid_quantity`** (+ store-missing craft-layer codes as needed). - Pre-flight output capacity before input removal (kickoff decision). - Compensating inventory rollback when XP grant fails after successful input/output mutations (NEO-62 pattern). - Unit tests (AAA) via **`InMemoryWebApplicationFactory`** with real catalogs and stores; **`RefineActivitySkillXpGrant`** invoked on success path. - Document **`CraftResult`** shape in module doc (server-internal until promoted to wire in NEO-70). **Out of scope (from Linear + backlog):** - Craft HTTP, Bruno, `docs/manual-qa/` ([NEO-70](https://linear.app/neon-sprawl/issue/NEO-70)). - Client HUD ([NEO-74](https://linear.app/neon-sprawl/issue/NEO-74)). - Skill level gates, station checks, craft failure RNG. - Telemetry hook sites ([NEO-71](https://linear.app/neon-sprawl/issue/NEO-71)). ## Acceptance criteria checklist - [x] Successful craft removes configured inputs and adds outputs deterministically. - [x] Insufficient materials returns stable **`reasonCode`** with **no** inventory change. - [x] Full bag on output returns stable **`reasonCode`**; inputs are **not** consumed. - [x] **`refine`** skill XP increases by **10** on success (NEO-42). - [x] **`make_prototype_armor`** respects equipment slot rules from E3.M3 when bag is full but equip slot empty. ## Technical approach 1. **Entry point:** **`CraftOperations.TryCraft(...)`** — static orchestrator accepting `playerId`, `recipeId`, `quantity`, and dependencies: - **`IRecipeDefinitionRegistry`** - **`IItemDefinitionRegistry`** + **`IPlayerInventoryStore`** - **`ISkillDefinitionRegistry`**, **`IPlayerSkillProgressionStore`**, **`ISkillLevelCurve`**, **`PerkUnlockEngine`** 2. **Validate quantity:** `quantity <= 0` → **`Denied`** + **`CraftReasonCodes.InvalidQuantity`** (no writes). 3. **Resolve recipe:** **`registry.TryGetDefinition(recipeId.Trim(), out def)`** — miss → **`unknown_recipe`**. 4. **Pre-flight inputs (read-only):** For each input row, required amount = **`row.Quantity * quantity`**. Sum held quantity in the correct container (bag vs equipment via catalog **`inventorySlotKind`**) from current snapshot. Any shortfall → **`insufficient_materials`** without mutations. 5. **Pre-flight outputs (read-only, kickoff decision):** Clone current snapshot; **simulate removing all scaled inputs** on the clone, then simulate each scaled output add. If any output cannot be fully placed → **`inventory_full`** without mutations. Implementation: **`TrySimulateRemoveStack`** + **`TrySimulateAddStack`** on `PlayerInventoryOperations`. 6. **Commit mutations (only after steps 4–5 pass):** - **Remove inputs:** foreach input row, **`TryRemoveStack`** scaled quantity. (Should succeed given pre-flight; defensive deny **`insufficient_materials`** if not.) - **Add outputs:** foreach output row, **`TryAddStack`** scaled quantity. (Should succeed given pre-flight; defensive deny + compensating re-add inputs if not.) - **XP:** **`RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine`**. On failure (store missing / skill deny): compensating rollback — remove granted outputs, re-add removed inputs; return **`Denied`** + **`progression_store_missing`** or passthrough skill **`reasonCode`**. 7. **Success envelope — `CraftResult`:** - **`Success = true`**, **`ReasonCode = null`** - **`InputsConsumed`**: list of `{ itemId, quantity }` applied (scaled). - **`OutputsGranted`**: list of `{ itemId, quantity }` applied (scaled). - **`XpGrantSummary`**: compact summary (`refine`, **10**, `activity`) for tests / future wire. 8. **Reason codes (`CraftReasonCodes`):** - **`unknown_recipe`**, **`insufficient_materials`**, **`invalid_quantity`** — craft-layer. - **`inventory_full`** — passthrough literal (same string as NEO-54). - **`progression_store_missing`**, **`inventory_store_missing`** — craft-layer store-missing (mirror gather). 9. **Equipment slot AC (`make_prototype_armor`):** No special-case in craft engine — **`prototype_armor_shell`** routes to equipment via catalog metadata in pre-flight + **`TryAddStack`**. Test: fill all **24** bag slots, leave equipment empty, craft succeeds and places armor in **`equipmentSlots[0]`**. 10. **Primary demo recipe for happy path tests:** **`refine_scrap_standard`** (5× **`scrap_metal_bulk`** → 1× **`refined_plate_stock`**) and/or **`make_field_stim_mk0`**. 11. **Docs (on land):** Update [E3_M2](E3_M2_RefinementAndRecipeExecution.md) **Implementation snapshot** + **`CraftResult`** contract note; [documentation_and_implementation_alignment.md](documentation_and_implementation_alignment.md) E3.M2 row; E3M2-05 checkboxes in [E3M2-prototype-backlog.md](E3M2-prototype-backlog.md); brief **`server/README.md`** craft-engine subsection (engine-only; HTTP in NEO-70). Cross-link **NEO-74** / full-stack note per [full-stack epic decomposition](../../.cursor/rules/full-stack-epic-decomposition.md). ## Files to add | Path | Purpose | |------|---------| | `server/NeonSprawl.Server/Game/Crafting/CraftReasonCodes.cs` | Stable craft deny codes (craft-layer + passthrough). | | `server/NeonSprawl.Server/Game/Crafting/CraftIoApplied.cs` | One consumed input or granted output row in success envelope. | | `server/NeonSprawl.Server/Game/Crafting/CraftXpGrantSummary.cs` | Compact refine XP summary on successful craft. | | `server/NeonSprawl.Server/Game/Crafting/CraftResult.cs` | Success/deny envelope: inputs consumed, outputs granted, reasonCode, xp summary. | | `server/NeonSprawl.Server/Game/Crafting/CraftOperations.cs` | Orchestrator: validate → pre-flight → remove inputs → add outputs → XP; rollback on XP failure. | | `server/NeonSprawl.Server.Tests/Game/Crafting/CraftOperationsTests.cs` | AAA: success, insufficient_materials, inventory_full (bag), make_prototype_armor + full bag, unknown_recipe, invalid_quantity, XP grant + rollback path. | | `docs/plans/NEO-69-implementation-plan.md` | This plan. | ## Files to modify | Path | Rationale | |------|-----------| | `server/NeonSprawl.Server/Game/Items/PlayerInventoryOperations.cs` | Add snapshot-based **`CanPlaceStack`** (or simulate helper) for craft output pre-flight without mutation. | | `server/README.md` | Document craft engine (`CraftOperations`, reason codes, refine XP hook); note HTTP deferred to NEO-70. | | `docs/decomposition/modules/E3_M2_RefinementAndRecipeExecution.md` | **Implementation snapshot** — craft engine + **`CraftResult`** field table. | | `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E3.M2 row — note NEO-69 craft engine when landed. | | `docs/plans/E3M2-prototype-backlog.md` | E3M2-05 acceptance checkboxes + landed note when complete. | ## Tests | Test file | What it covers | |-----------|----------------| | `server/NeonSprawl.Server.Tests/Game/Crafting/CraftOperationsTests.cs` | **Success:** seed **`scrap_metal_bulk`**, craft **`refine_scrap_standard`**, assert inputs removed, **`refined_plate_stock`** granted, **`refine`** XP +10, envelope fields. **Insufficient materials:** empty inventory → **`insufficient_materials`**, snapshot unchanged. **Inventory full:** fill bag so output cannot fit → **`inventory_full`**, inputs unchanged. **Equipment routing:** full bag + empty equip slot → **`make_prototype_armor`** succeeds, armor in equipment slot. **Unknown recipe** → **`unknown_recipe`**. **Invalid quantity** (0 or negative) → **`invalid_quantity`**. **XP path:** assert **`RefineActivitySkillXpGrant`** effect via progression store (mirror **`GatherOperationsTests`** / **`RefineActivitySkillXpGrantTests`**). **AAA** per [csharp-style](../../.cursor/rules/csharp-style.md). | | `server/NeonSprawl.Server.Tests/Game/Items/PlayerInventoryOperationsTests.cs` | **Optional:** one test for new **`CanPlaceStack`** helper if added as public surface (bag full vs equipment route). | No Bruno or manual QA in this story — [NEO-70](https://linear.app/neon-sprawl/issue/NEO-70) owns player-visible HTTP verification. ## Open questions / risks | Question / risk | Agent recommendation | Status | |-----------------|----------------------|--------| | **Pre-flight simulate vs rollback** | User chose **pre-flight** at kickoff. | **adopted** | | **Multi-output pre-flight order** | Simulate adds in recipe **`outputs`** order on one cloned snapshot (order should not matter for prototype fixed outputs; document if later recipes introduce interactions). | **deferred** | | **NEO-71 telemetry hooks** | Do not add `item_crafted` / `craft_failed` comments in NEO-69 — NEO-71 owns hook placement. | **adopted** | | **Client not in this story** | Cross-link **NEO-74**; Bruno-only verification is not prototype-complete. | **adopted** | ## Decisions (kickoff) - **Pre-flight output capacity** before any input removal (user confirmed). - **`quantity` parameter** on **`TryCraft`** with **`invalid_quantity`** deny in this story. - **Craft-layer `insufficient_materials`** at orchestration boundary (not raw **`insufficient_quantity`** on craft deny). - **XP failure rollback** — remove outputs, restore inputs (NEO-62 gather precedent). - **No `docs/manual-qa/NEO-69.md`** — engine-only; NEO-70 manual QA covers HTTP.