# E3.M1 — ResourceNodeAndGatherLoop

## Summary

| Field | Value |
|--------|--------|
| **Module ID** | E3.M1 |
| **Epic** | [Epic 3 — Crafting Economy](../epics/epic_03_crafting_economy.md) |
| **Stage target** | Prototype |
| **Status** | Ready — Slice 2 backlog [NEO-57](https://linear.app/neon-sprawl/issue/NEO-57)–[NEO-64](https://linear.app/neon-sprawl/issue/NEO-64) complete ([E3M1-prototype-backlog](../../plans/E3M1-prototype-backlog.md); NEO-41 anchor below; see [dependency register](module_dependency_register.md)); Slice 5 client [NEO-73](https://linear.app/neon-sprawl/issue/NEO-73) gather feedback + capstone [NEO-75](https://linear.app/neon-sprawl/issue/NEO-75) playable loop landed |
| **Linear** | Label **`E3.M1`** per [decomposition README — Linear alignment](../README.md#linear-alignment) |

## Purpose

**Authority:** [Client vs server authority](client_server_authority.md) — the server resolves **whether** a gather succeeds, **what** items are granted, **remaining node capacity**, and **skill XP** from gather; the client sends **interact intent** via [E1.M3 — InteractionAndTargetingLayer](E1_M3_InteractionAndTargetingLayer.md) (`POST …/interact`) and displays outcomes.

World resource nodes, gather interactions, and yield outputs feeding inventory ([E3.M3](E3_M3_ItemizationAndInventorySchema.md)) and skill XP ([E2.M2](E2_M2_XpAwardAndLevelEngine.md)). Ecology / respawn pacing per zone defers to [E4.M2 — SpawnEcologyController](E4_M2_SpawnEcologyController.md).

## Boundary: E1.M3 vs E3.M1

| Topic | E1.M3 (today) | E3.M1 (this module) |
|--------|----------------|---------------------|
| **Primary job** | Interactable eligibility (range, known id, kind) | **Gather resolution**: yields, depletion, inventory + XP side effects |
| **Wire / HTTP** | `POST …/interact` + `InteractionResponse` | No separate gather route in prototype — E3.M1 hooks **after** E1.M3 allows interact |
| **Registry** | `PrototypeInteractableRegistry` / `GET …/interactables` | **`ResourceNodeDef`** + **`ResourceYieldTable`** content; instance capacity store |
| **Denials** | `out_of_range`, `unknown_interactable` | Adds **`node_depleted`**, `inventory_full`, etc. via gather engine |

**Shared policy:** horizontal reach on **X/Z** matches [NEO-9](../../plans/NEO-9-implementation-plan.md) / E1.M3 — gather does not redefine range math.

## Responsibilities

- Author **`ResourceNodeDef`** and **`ResourceYieldTable`** content; load and expose defs to server subsystems.
- Track per-instance **remaining gathers** (prototype capacity/depletion); deny when exhausted.
- Resolve **`GatherResult`**: item grants into [E3.M3](E3_M3_ItemizationAndInventorySchema.md) inventory, **`salvage`** skill XP via [E2.M2](E2_M2_XpAwardAndLevelEngine.md) grant operations.
- Integrate with **`resource_node`** interactables in `PrototypeInteractableRegistry` (world anchors until [E4.M1](E4_M1_ZoneGraphAndTravelRules.md)).

**Deferred:** zone spawn profiles, regen timers, probabilistic yields, cross-skill unlock nodes — [gathering.md](../../game-design/gathering.md) open questions.

## Key contracts

| Contract | Kind | Role |
|----------|------|------|
| `ResourceNodeDef` | Content | Node type, max gathers, gather lens, link to yield table. |
| `ResourceYieldTable` | Content | Fixed item outputs per gather (`itemId`, quantity). |
| `GatherResult` | Server-internal (prototype) | Success/deny, granted items, depletion snapshot, reason codes. |

### Contract notes ([contracts.md](contracts.md))

- **Content** defs live under `content/resource-nodes/` with JSON Schema in CI ([E3M1-01](../../plans/E3M1-prototype-backlog.md#e3m1-01--prototype-resourcenodedef--yield-tables--schemas--ci)).
- **`GatherResult`** may promote to **wire** JSON or Protobuf when the client needs explicit gather payloads; until then, prototype uses **`InteractionResponse`** + inventory GET for verification.
- **Denials:** stable **`reasonCode`** strings on interact deny (`node_depleted`, `inventory_full`, …) consistent with [E1.M3](E1_M3_InteractionAndTargetingLayer.md) patterns.

**Logical fields (illustrative):**

| Contract | Illustrative fields |
|----------|---------------------|
| `ResourceNodeDef` | `nodeDefId`; `displayName`; `gatherLens` (`consumer_salvage`, `subsurface`, `bio`, `urban_bulk`); `maxGathers` — see `content/resource-nodes/*_resource_nodes.json` |
| `ResourceYieldTable` | `nodeDefId`; `itemId`; `quantity` — prototype: one row per node in `*_resource_yields.json` |
| `GatherResult` | `success`; `reasonCode`; `grantsApplied[]`; `remainingGathers`; optional `xpGrantSummary` |

## Module dependencies

- **E1.M3** — InteractionAndTargetingLayer (`POST …/interact`, interactable descriptors).
- **E2.M2** — XpAwardAndLevelEngine (skill XP grant operations).
- **E3.M3** — ItemizationAndInventorySchema (inventory grants) — logical dependency for Slice 2 yields; register lists E1.M3 + E2.M2 only so gather XP anchor ([NEO-41](../../plans/NEO-41-implementation-plan.md)) could land before inventory.

## Dependents (by design)

- **E3.M2** — RefinementAndRecipeExecution (crafted inputs from gathered materials).
- **E4.M2** — SpawnEcologyController (respawn / ecology).

## Designer note: gather lenses and yields

Prototype Slice 2 uses **four** nodes — one per [gather lens](../../game-design/gathering.md#node-lenses-design-facing). All prototype yields grant **`scrap_metal_bulk`** (different quantities per node are allowed) so the gather→refine loop can start without expanding the item catalog. **Skill XP** stays **`salvage`** + **`activity`** (10 XP) on every node until per-lens skill mapping is a deliberate content pass.

**Depletion:** each interactable instance has **`maxGathers`** successful collects; at zero, interact returns **`node_depleted`**. Regen on timer is **out of scope** for Slice 2 ([E4.M2](E4_M2_SpawnEcologyController.md)).

## Prototype Slice 2 freeze (E3M1-01)

The **first shipped four-node spine** is **frozen** for downstream references (recipes, ecology, quests). **`nodeDefId`** must match **`interactableId`** in `PrototypeInteractableRegistry` for prototype wiring.

| `nodeDefId` / `interactableId` | `gatherLens` | Notes |
|-------------------------------|--------------|--------|
| **`prototype_resource_node_alpha`** | `consumer_salvage` | NEO-9/NEO-41 anchor **(12, −6)** |
| **`prototype_subsurface_vein_beta`** | `subsurface` | NEO-63 anchor **(18, −6)** |
| **`prototype_bio_mat_gamma`** | `bio` | NEO-63 anchor **(12, 0)** |
| **`prototype_urban_bulk_delta`** | `urban_bulk` | NEO-63 anchor **(18, 0)** |

**Rules:** Do **not** rename these ids after ship. CI enforces **exactly four** node defs and valid yield item references. Relaxing the gate belongs in a new Linear issue when the roster grows.

## Related implementation slices

Epic 3 **Slice 2** — [gather nodes and outputs](../epics/epic_03_crafting_economy.md#epic-3-slice-2): 4–6 node types, yield tables, `resource_gathered`, `gather_node_depleted`.

**Interact path (NEO-63):** **`POST …/interact`** on **`kind: resource_node`** delegates to **`GatherOperations`** — success grants inventory + **`salvage`** XP + depletion; denies return **`allowed: false`** + **`reasonCode`** (`node_depleted`, `inventory_full`, …). **Four** anchors in **`PrototypeInteractableRegistry`**. Plan: [NEO-63 implementation plan](../../plans/NEO-63-implementation-plan.md); [server README — Gather via interact (NEO-63)](../../../server/README.md#gather-via-interact-neo-63).

**Gather engine (NEO-62):** **`GatherOperations.TryGather`** → **`GatherResult`** (`success`, `reasonCode`, `grantsApplied[]`, `remainingGathers`, optional `xpGrantSummary`) — inventory + **`salvage`** XP + depletion commit in one orchestrated path; server-internal until promoted to wire. Plan: [NEO-62 implementation plan](../../plans/NEO-62-implementation-plan.md); [server README — Gather engine (NEO-62)](../../../server/README.md#gather-engine-neo-62).

**Gather telemetry hooks (NEO-64):** comment-only **`resource_gathered`** + **`gather_node_depleted`** hook sites in **`GatherOperations.TryGather`** (`TODO(E9.M1)`; no ingest). **`gather_node_depleted`** fires on success when **`remainingGathers`** reaches **0** — not on pre-gather depletion deny. Plan: [NEO-64 implementation plan](../../plans/NEO-64-implementation-plan.md); [server README — Gather telemetry hooks (NEO-64)](../../../server/README.md#gather-telemetry-hooks-neo-64); manual QA: [`NEO-64`](../../manual-qa/NEO-64.md).

**Resource node definitions HTTP (NEO-60):** **`GET /game/world/resource-node-definitions`** — versioned read-only projection (`schemaVersion` **1**, **`nodes`**) backed by **`IResourceNodeDefinitionRegistry`**; each row includes nested **`yield`** summary. Bruno `bruno/neon-sprawl-server/resource-node-definitions/`. Plan: [NEO-60 implementation plan](../../plans/NEO-60-implementation-plan.md).

**Resource node instance depletion store (NEO-61):** **`IResourceNodeInstanceStore`** + **`ResourceNodeInstanceOperations`** — lazy-init remaining gathers from catalog **`maxGathers`**, atomic decrement, **`node_depleted`** / **`unknown_node`** reason codes; Postgres **`V006`** or in-memory fallback. Plan: [NEO-61 implementation plan](../../plans/NEO-61-implementation-plan.md).

**Linear backlog (decomposed):** [E3M1-prototype-backlog.md](../../plans/E3M1-prototype-backlog.md) — **E3M1-01** [NEO-57](https://linear.app/neon-sprawl/issue/NEO-57) through **E3M1-08** [NEO-64](https://linear.app/neon-sprawl/issue/NEO-64). **Client (Slice 5):** gather feedback **landed** — [E3S5-client-prototype-backlog.md](../../plans/E3S5-client-prototype-backlog.md) **E3S5-02** [NEO-73](https://linear.app/neon-sprawl/issue/NEO-73): **`skill_progression_client.gd`**, **`prototype_interactable_picker.gd`** (nearest in-range **R**), **`GatherFeedbackLabel`** + **`SkillProgressionLabel`**, auto inventory/skill refresh after successful gather ([NEO-73 implementation plan](../../plans/NEO-73-implementation-plan.md), [`NEO-73` manual QA](../../manual-qa/NEO-73.md)); `client/README.md` gather section.

## Source anchors

- Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 3.
- [gathering.md](../../game-design/gathering.md)
- [Module dependency register](module_dependency_register.md)
