134 lines
11 KiB
Markdown
134 lines
11 KiB
Markdown
# 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.
|