# NEO-62 — Implementation plan

## Story reference

| Field | Value |
|--------|--------|
| **Key** | NEO-62 |
| **Title** | E3.M1: GatherResult engine (yields + inventory + skill XP) |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-62/e3m1-gatherresult-engine-yields-inventory-skill-xp |
| **Module** | [E3.M1 — ResourceNodeAndGatherLoop](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md) · Epic 3 Slice 2 · backlog **E3M1-06** |
| **Branch** | `NEO-62-e3m1-gatherresult-engine-yields-inventory-skill-xp` |
| **Precursors** | [NEO-59](https://linear.app/neon-sprawl/issue/NEO-59) — `IResourceNodeDefinitionRegistry` + DI (**Done**); [NEO-61](https://linear.app/neon-sprawl/issue/NEO-61) — `IResourceNodeInstanceStore` + depletion ops (**Done**); [NEO-54](https://linear.app/neon-sprawl/issue/NEO-54) — `PlayerInventoryOperations` (**Done**); [NEO-38](https://linear.app/neon-sprawl/issue/NEO-38) / [NEO-41](https://linear.app/neon-sprawl/issue/NEO-41) — skill XP grant path (**Done**) |
| **Pattern** | [NEO-61](https://linear.app/neon-sprawl/issue/NEO-61) — static `*Operations` orchestrator over injectable stores; [NEO-54](https://linear.app/neon-sprawl/issue/NEO-54) — subsystem reason codes surfaced on gather deny |

## Kickoff clarifications

| Topic | Question | Agent recommendation | Answer |
|--------|----------|----------------------|--------|
| **Side-effect order** | Inventory / XP / depletion commit sequence? | **Inventory add → XP grant → depletion decrement last** — depletion only after successful grant; avoids consuming capacity on `inventory_full` (NEO-54 all-or-nothing). | **User:** inventory → XP → depletion last. |
| **XP failure after inventory** | Roll back items or partial success? | **Deny gather + compensating `TryRemoveStack`** — gather success must include items and XP per AC; NEO-41 silent XP no-op stays on interact until NEO-63. | **User:** rollback + deny. |

## Goal, scope, and out-of-scope

**Goal:** Single server operation resolving **`GatherResult`**: validate node + capacity, grant configured yield via **`PlayerInventoryOperations`**, award **`salvage`** XP via **`SkillProgressionGrantOperations`**, commit depletion via **`ResourceNodeInstanceOperations`** — engine-only, ready for interact wiring (NEO-63).

**In scope (from Linear + [E3M1-06](E3M1-prototype-backlog.md#e3m1-06--gatherresult-engine-yields--inventory--skill-xp)):**

- **`GatherOperations`** / **`GatherResult`** types in `server/NeonSprawl.Server/Game/Gathering/`.
- Stable deny **`reasonCode`** passthrough: `node_depleted`, `inventory_full`, `unknown_node`, plus gather-specific `progression_store_missing` when XP rollback path runs.
- Unit / integration tests (AAA) via **`InMemoryWebApplicationFactory`** with real catalogs and stores.
- Document **`GatherResult`** shape in module doc (server-internal until promoted to wire).

**Out of scope (from Linear + backlog):**

- **`InteractionApi`** wiring and duplicate NEO-41 XP removal ([NEO-63](https://linear.app/neon-sprawl/issue/NEO-63)).
- Client HUD, Bruno, HTTP gather route.
- Regen / respawn timers ([E4.M2](../decomposition/modules/E4_M2_SpawnEcologyController.md)).
- Telemetry hook sites ([NEO-64](https://linear.app/neon-sprawl/issue/NEO-64)).
- Cross-store transactions (prototype relies on ordered commits + compensating remove on XP failure).

## Acceptance criteria checklist

- [x] Successful gather adds configured **`scrap_metal_bulk`** quantity to player bag.
- [x] Full bag returns stable **`inventory_full`** without partial silent loss or depletion change.
- [x] Skill XP grant uses same rules as NEO-38 / NEO-41 (`salvage`, `activity`, 10 XP via **`GatherSkillXpConstants`**).
- [x] Depletion store decrements atomically with successful grant (decrement **last**, only when inventory + XP both succeed).

## Technical approach

1. **Entry point:** **`GatherOperations.TryGather(...)`** — static orchestrator accepting `playerId`, `interactableId`, and dependencies:
   - **`IResourceNodeDefinitionRegistry`** (def + yield),
   - **`IItemDefinitionRegistry`** + **`IPlayerInventoryStore`**,
   - **`ISkillDefinitionRegistry`**, **`IPlayerSkillProgressionStore`**, **`ISkillLevelCurve`**, **`PerkUnlockEngine`**,
   - **`IResourceNodeInstanceStore`**.

2. **Normalize key:** **`ResourceNodeInstanceIds.Normalize(interactableId)`** (trim + lowercase); empty → **`unknown_node`**.

3. **Definition + yield resolve:** **`TryGetDefinition`** and **`TryGetYield`** on normalized id. Either miss → **`Denied`** + **`GatherReasonCodes.UnknownNode`** (no writes).

4. **Capacity pre-check (read-only, no decrement):**
   - **`store.TryEnsureInitialized(key, definition.MaxGathers)`** (lazy init mirror of NEO-61).
   - **`store.TryGetRemainingGathers(key, out snapshot)`** — if **`remainingGathers <= 0`** → **`Denied`** + **`GatherReasonCodes.NodeDepleted`** with snapshot (no inventory / XP / decrement).

5. **Inventory grant (first mutation):** **`PlayerInventoryOperations.TryAddStack(playerId, yield.ItemId, yield.Quantity, itemRegistry, inventoryStore)`**.
   - **`Denied`** + **`inventory_full`** (passthrough) → return immediately; depletion unchanged.
   - **`StoreMissing`** → **`GatherReasonCodes.InventoryStoreMissing`** (gather surface; unlikely in tests).
   - **`Applied`** → continue.

6. **Skill XP (second mutation):** **`SkillProgressionGrantOperations.TryApplyGrant`** with **`GatherSkillXpConstants.SalvageSkillId`**, **`ActivityXpPerResourceNodeGather`**, **`ActivitySourceKind`**.
   - **`Granted`** → continue.
   - **`ProgressionStoreMissing`** or **`Denied`** → **compensating rollback:** **`PlayerInventoryOperations.TryRemoveStack`** same **`itemId`** / **`quantity`**; return **`Denied`** + **`GatherReasonCodes.ProgressionStoreMissing`** (or passthrough skill deny **`reasonCode`** from response when **`Denied`**). Depletion unchanged.

7. **Depletion commit (last mutation):** **`ResourceNodeInstanceOperations.TryCommitSuccessfulGatherDecrement(key, nodeRegistry, store)`**.
   - Expect **`Applied`** given step 4 pre-check; on unexpected **`Denied`**, attempt compensating inventory remove + surface **`node_depleted`** (defensive; document as prototype race risk — no XP rollback in this edge case).

8. **Success envelope — `GatherResult`:**
   - **`Success = true`**, **`ReasonCode = null`**
   - **`GrantsApplied`**: single entry `{ itemId, quantity }` from yield row (prototype: one yield per node).
   - **`RemainingGathers`**: from depletion outcome snapshot.
   - **`XpGrantSummary`**: optional compact summary from grant response (e.g. **`skillId`**, **`amount`**, **`sourceKind`**) for tests / future wire.

9. **Reason codes (`GatherReasonCodes`):** Re-export stable subsystem strings where applicable:
   - **`unknown_node`**, **`node_depleted`**, **`inventory_full`** — same literals as NEO-61 / NEO-54.
   - **`progression_store_missing`**, **`inventory_store_missing`** — gather-layer codes for store-missing paths.

10. **Yield quantities (content, fixed):** Alpha **1**, beta **2**, gamma **3**, delta **5** `scrap_metal_bulk` per [`prototype_resource_yields.json`](../../content/resource-nodes/prototype_resource_yields.json). Tests spot-check alpha (1) and optionally delta (5).

11. **Docs (on land):** Update [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md) **Related implementation slices** + **`GatherResult`** contract note; [documentation_and_implementation_alignment.md](documentation_and_implementation_alignment.md) E3.M1 row; E3M1-06 checkboxes in [E3M1-prototype-backlog.md](E3M1-prototype-backlog.md); brief **`server/README.md`** gather-engine subsection (engine-only; interact in NEO-63).

## Files to add

| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Gathering/GatherReasonCodes.cs` | Stable gather deny codes (passthrough + store-missing). |
| `server/NeonSprawl.Server/Game/Gathering/GatherGrantApplied.cs` | One applied yield row in success envelope. |
| `server/NeonSprawl.Server/Game/Gathering/GatherXpGrantSummary.cs` | Compact XP summary on successful gather. |
| `server/NeonSprawl.Server/Game/Gathering/GatherResult.cs` | Success/deny envelope: grants, remainingGathers, reasonCode, xp summary. |
| `server/NeonSprawl.Server/Game/Gathering/GatherOperations.cs` | Orchestrator: pre-check → inventory → XP → depletion; rollback on XP failure. |
| `server/NeonSprawl.Server.Tests/Game/Gathering/GatherOperationsTests.cs` | AAA: success path, inventory_full, node_depleted, unknown_node, XP rollback, quantity per yield. |

## Files to modify

| Path | Rationale |
|------|-----------|
| `server/README.md` | Gather engine subsection: `GatherOperations`, commit order, reason codes; note interact wiring in NEO-63. |
| `docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md` | **Related implementation slices** — gather engine bullet (NEO-62); **`GatherResult`** field table alignment. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E3.M1 row — note NEO-62 gather engine when landed. |
| `docs/plans/E3M1-prototype-backlog.md` | E3M1-06 acceptance checkboxes when landed. |

## Tests

| Test file | What it covers |
|-----------|----------------|
| `server/NeonSprawl.Server.Tests/Game/Gathering/GatherOperationsTests.cs` | **Integration-style (DI factory):** **`TryGather`** on **`prototype_resource_node_alpha`** — **`Success`**, bag quantity +1 **`scrap_metal_bulk`**, **`salvage`** XP +10, **`RemainingGathers == 9`**. **Full bag:** pre-fill 24 bag slots (`survey_drone_kit` ×1 each) → **`Denied`**, **`inventory_full`**, depletion row unchanged (still lazy-uninitialized or full capacity). **Depletion loop:** exhaust alpha (10 gathers) → next **`node_depleted`**, no inventory/XP change. **`unknown_node`** for bogus id. **XP failure:** custom test factory or stub progression store that returns **`ProgressionStoreMissing`** after inventory add → gather denied, inventory restored to pre-gather snapshot. **Optional:** delta node grants quantity **5**. **AAA** per [csharp-style](../../.cursor/rules/csharp-style.md). |

No Bruno or HTTP tests — out of scope; manual gather flow waits for NEO-63.

## Open questions / risks

| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| **NEO-61 on `main`** | Branch from **`main`** where NEO-59/60/61 are merged (confirmed at kickoff). | **adopted** |
| **Duplicate XP via `InteractionApi`** | Leave NEO-41 interact hook until **NEO-63** removes it; engine-only scope for NEO-62. | **adopted** |
| **Depletion fails after inventory+XP** | Extremely unlikely after pre-check; defensive inventory rollback, no XP undo — document; no cross-store transaction in prototype. | **deferred** |
| **Per-lens yield quantities in tests** | Spot-check alpha (1) + delta (5); full four-node matrix optional. | **adopted** |

None blocking.

## Decisions (kickoff)

- **Commit order:** inventory add → XP grant → depletion decrement **last**.
- **XP failure:** deny gather + compensating **`TryRemoveStack`**; depletion not committed.
- **Reason codes:** passthrough subsystem literals on gather deny surface; gather-specific codes for store-missing only.
