From acf9fe125f76dd7e61f74770a82674ed6a8c6f56 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 24 May 2026 15:30:27 -0400 Subject: [PATCH] NEO-62: add gather engine implementation plan. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kickoff clarifications: inventory → XP → depletion commit order; compensating inventory rollback on XP failure. --- docs/plans/NEO-62-implementation-plan.md | 133 +++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 docs/plans/NEO-62-implementation-plan.md diff --git a/docs/plans/NEO-62-implementation-plan.md b/docs/plans/NEO-62-implementation-plan.md new file mode 100644 index 0000000..88cda64 --- /dev/null +++ b/docs/plans/NEO-62-implementation-plan.md @@ -0,0 +1,133 @@ +# 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 + +- [ ] Successful gather adds configured **`scrap_metal_bulk`** quantity to player bag. +- [ ] Full bag returns stable **`inventory_full`** without partial silent loss or depletion change. +- [ ] Skill XP grant uses same rules as NEO-38 / NEO-41 (`salvage`, `activity`, 10 XP via **`GatherSkillXpConstants`**). +- [ ] 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.