diff --git a/bruno/neon-sprawl-server/ability-cast/Post cast happy.bru b/bruno/neon-sprawl-server/ability-cast/Post cast happy.bru
index 07b2c64..0d3194c 100644
--- a/bruno/neon-sprawl-server/ability-cast/Post cast happy.bru
+++ b/bruno/neon-sprawl-server/ability-cast/Post cast happy.bru
@@ -4,6 +4,20 @@ meta {
seq: 22
}
+script:pre-request {
+ const axios = require("axios");
+ const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl");
+ const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId");
+ await axios.post(
+ `${baseUrl}/game/players/${playerId}/move`,
+ {
+ schemaVersion: 1,
+ target: { x: -5, y: 0.9, z: -5 },
+ },
+ { headers: { "Content-Type": "application/json" } },
+ );
+}
+
post {
url: {{baseUrl}}/game/players/dev-local-1/ability-cast
body: json
diff --git a/bruno/neon-sprawl-server/ability-cast/Post hotbar bind slot0 pulse.bru b/bruno/neon-sprawl-server/ability-cast/Post hotbar bind slot0 pulse.bru
index 4e6322d..9340ca0 100644
--- a/bruno/neon-sprawl-server/ability-cast/Post hotbar bind slot0 pulse.bru
+++ b/bruno/neon-sprawl-server/ability-cast/Post hotbar bind slot0 pulse.bru
@@ -4,6 +4,25 @@ meta {
seq: 20
}
+docs {
+ Full-collection order: run `Post move reset dev spawn before cast` (seq 15) first if the dev player was moved by earlier requests (e.g. gather near resource node).
+ Pre-request resets spawn so this folder stays valid even when global `seq` puts other requests (e.g. gather at seq 11) before seq 15.
+}
+
+script:pre-request {
+ const axios = require("axios");
+ const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl");
+ const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId");
+ await axios.post(
+ `${baseUrl}/game/players/${playerId}/move`,
+ {
+ schemaVersion: 1,
+ target: { x: -5, y: 0.9, z: -5 },
+ },
+ { headers: { "Content-Type": "application/json" } },
+ );
+}
+
post {
url: {{baseUrl}}/game/players/dev-local-1/hotbar-loadout
body: json
diff --git a/bruno/neon-sprawl-server/ability-cast/Post move reset dev spawn before cast.bru b/bruno/neon-sprawl-server/ability-cast/Post move reset dev spawn before cast.bru
new file mode 100644
index 0000000..b0f5d8d
--- /dev/null
+++ b/bruno/neon-sprawl-server/ability-cast/Post move reset dev spawn before cast.bru
@@ -0,0 +1,36 @@
+meta {
+ name: POST move - reset dev spawn (ability-cast preset)
+ type: http
+ seq: 15
+}
+
+docs {
+ Resets authoritative position to `Game:DefaultPosition` (~-5, 0.9, -5) so `prototype_target_alpha` lock and cast range checks pass when the collection ran earlier requests that moved the player (e.g. seq 10–11 resource-node gather). Runs before hotbar/target/cast (seq 20–22).
+}
+
+post {
+ url: {{baseUrl}}/game/players/{{playerId}}/move
+ body: json
+ auth: none
+}
+
+headers {
+ content-type: application/json
+}
+
+body:json {
+ {
+ "schemaVersion": 1,
+ "target": {
+ "x": -5,
+ "y": 0.9,
+ "z": -5
+ }
+ }
+}
+
+tests {
+ test("status 200", function () {
+ expect(res.getStatus()).to.equal(200);
+ });
+}
diff --git a/bruno/neon-sprawl-server/ability-cast/Post target select alpha before cast.bru b/bruno/neon-sprawl-server/ability-cast/Post target select alpha before cast.bru
index d906269..4fc3aba 100644
--- a/bruno/neon-sprawl-server/ability-cast/Post target select alpha before cast.bru
+++ b/bruno/neon-sprawl-server/ability-cast/Post target select alpha before cast.bru
@@ -4,6 +4,20 @@ meta {
seq: 21
}
+script:pre-request {
+ const axios = require("axios");
+ const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl");
+ const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId");
+ await axios.post(
+ `${baseUrl}/game/players/${playerId}/move`,
+ {
+ schemaVersion: 1,
+ target: { x: -5, y: 0.9, z: -5 },
+ },
+ { headers: { "Content-Type": "application/json" } },
+ );
+}
+
post {
url: {{baseUrl}}/game/players/dev-local-1/target/select
body: json
diff --git a/bruno/neon-sprawl-server/interaction/Post interact prototype resource node gather.bru b/bruno/neon-sprawl-server/interaction/Post interact prototype resource node gather.bru
new file mode 100644
index 0000000..4369d5d
--- /dev/null
+++ b/bruno/neon-sprawl-server/interaction/Post interact prototype resource node gather.bru
@@ -0,0 +1,50 @@
+meta {
+ name: POST interact - prototype_resource_node_alpha (NEO-41 gather XP)
+ type: http
+ seq: 11
+}
+
+docs {
+ NEO-41: successful interact on `resource_node` kind grants `salvage` XP (`sourceKind: activity`, 10 XP) via same server path as POST skill-progression. Pre-request moves the dev player in horizontal range of the node so this request passes when run alone or when global `seq` order would otherwise leave the player far from the anchor. Optional: GET skill-progression to read `salvage.xp`.
+ Server tests include a stub-registry case where `activity` is disallowed for `salvage` (interact still allowed; XP unchanged).
+}
+
+script:pre-request {
+ const axios = require("axios");
+ const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl");
+ const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId");
+ await axios.post(
+ `${baseUrl}/game/players/${playerId}/move`,
+ {
+ schemaVersion: 1,
+ target: { x: 10, y: 0.9, z: -6 },
+ },
+ { headers: { "Content-Type": "application/json" } },
+ );
+}
+
+post {
+ url: {{baseUrl}}/game/players/{{playerId}}/interact
+ body: json
+ auth: none
+}
+
+headers {
+ content-type: application/json
+}
+
+body:json {
+ {
+ "schemaVersion": 1,
+ "interactableId": "prototype_resource_node_alpha"
+ }
+}
+
+tests {
+ test("interact allowed", function () {
+ expect(res.getStatus()).to.equal(200);
+ const body = res.getBody();
+ expect(body.allowed).to.equal(true);
+ expect(body.interactableId).to.equal("prototype_resource_node_alpha");
+ });
+}
diff --git a/bruno/neon-sprawl-server/position/Post move near prototype resource node.bru b/bruno/neon-sprawl-server/position/Post move near prototype resource node.bru
new file mode 100644
index 0000000..cf0d341
--- /dev/null
+++ b/bruno/neon-sprawl-server/position/Post move near prototype resource node.bru
@@ -0,0 +1,30 @@
+meta {
+ name: POST move - near prototype resource node (NEO-41 gather setup)
+ type: http
+ seq: 10
+}
+
+docs {
+ NEO-41: move dev player within horizontal reach of `prototype_resource_node_alpha` (anchor 12, -6, radius 3).
+}
+
+post {
+ url: {{baseUrl}}/game/players/{{playerId}}/move
+ body: json
+ auth: none
+}
+
+headers {
+ content-type: application/json
+}
+
+body:json {
+ {
+ "schemaVersion": 1,
+ "target": {
+ "x": 10,
+ "y": 0.9,
+ "z": -6
+ }
+ }
+}
diff --git a/docs/decomposition/modules/E2_M2_XpAwardAndLevelEngine.md b/docs/decomposition/modules/E2_M2_XpAwardAndLevelEngine.md
index 781f28d..0f5bb7a 100644
--- a/docs/decomposition/modules/E2_M2_XpAwardAndLevelEngine.md
+++ b/docs/decomposition/modules/E2_M2_XpAwardAndLevelEngine.md
@@ -21,7 +21,9 @@
**NEO-40 landed:** comment-only **telemetry hook sites** in `SkillProgressionSnapshotApi` (`POST …/skill-progression`) for future catalog events **`xp_grant`** and **`level_up`** ([implementation plan](../../plans/NEO-40-implementation-plan.md)); manual QA **[`NEO-40.md`](../../manual-qa/NEO-40.md)**.
-**Still backlog within this module:** callers in Slice 3 (**NEO-41**–**NEO-43**). Keep this paragraph and [documentation tracking](documentation_and_implementation_alignment.md) in sync as Slice 2 stories merge.
+**NEO-41 landed:** prototype **gather → skill XP** — successful **`POST …/interact`** when interactable **`kind`** is **`resource_node`** grants **`salvage`** XP (**`sourceKind: activity`**, **10** XP per interact) via shared **`SkillProgressionGrantOperations`** ([implementation plan](../../plans/NEO-41-implementation-plan.md)); manual QA **[`NEO-41.md`](../../manual-qa/NEO-41.md)**; **[server README — Interaction (gather)](../../../server/README.md#interaction-neo-9)**.
+
+**Still backlog within this module:** Slice 3 **NEO-42**, **NEO-43** (and **NEO-44** gig path). Keep this paragraph and [documentation tracking](documentation_and_implementation_alignment.md) in sync as Slice 2 stories merge.
## Purpose
diff --git a/docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md b/docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md
index 77e1e4a..380fd6e 100644
--- a/docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md
+++ b/docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md
@@ -7,7 +7,7 @@
| **Module ID** | E3.M1 |
| **Epic** | [Epic 3 — Crafting Economy](../epics/epic_03_crafting_economy.md) |
| **Stage target** | Prototype |
-| **Status** | Planned (see [dependency register](module_dependency_register.md)) |
+| **Status** | In Progress (see [dependency register](module_dependency_register.md); prototype gather XP anchor [NEO-41](../../plans/NEO-41-implementation-plan.md)) |
## Purpose
@@ -39,6 +39,8 @@ World resource nodes, gather interactions, and yield outputs feeding inventory (
Epic 3 **Slice 2** — 4–6 node types; `resource_gathered`, `gather_node_depleted`.
+**Prototype implementation anchor (NEO-41):** until **`GatherResult`** / yield tables exist, the repo treats a **successful** **`POST …/interact`** on an entry with **`kind: resource_node`** (`prototype_resource_node_alpha`) as **gather completion** for **skill XP** only — **`salvage`** + **`activity`** via [`SkillProgressionGrantOperations`](../../../server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantOperations.cs) (same rules as NEO-38). Migrate this hook when a dedicated gather pipeline lands.
+
## Source anchors
- Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 3.
diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md
index 4be96e2..3fe8aef 100644
--- a/docs/decomposition/modules/documentation_and_implementation_alignment.md
+++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md
@@ -51,7 +51,8 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not
| E1.M3 | In Progress | **NEO-23 landed:** JSON v1 targeting read + select (`GET`/`POST …/target`, `Game/Targeting/`, [NEO-23](../../plans/NEO-23-implementation-plan.md)) — `PlayerTargetStateResponse` / soft lock / `reasonCode` denials; wire name differs from illustrative **`TargetState`** in module doc (called out in plan + README). **NEO-24 landed:** client tab-target + lock HUD synced to server ([NEO-24](../../plans/NEO-24-implementation-plan.md)) — `TargetSelectionClient` ([`client/scripts/target_selection_client.gd`](../../../client/scripts/target_selection_client.gd)), Tab / Esc bindings, `TargetLockLabel` HUD, hybrid movement-triggered refresh via new `PositionAuthorityClient.authoritative_ack` signal (fires on every server-confirmed position, boot + `move-stream` 200) with a 250 ms cooldown; manual QA in [`docs/manual-qa/NEO-24.md`](../../manual-qa/NEO-24.md). **NEO-25 landed:** versioned **`GET /game/world/interactables`** ([NEO-25](../../plans/NEO-25-implementation-plan.md)) — `InteractablesListResponse` / `InteractablesWorldApi` in `server/NeonSprawl.Server/Game/Interaction/`; Godot `interactables_catalog_client.gd` + `interactable_world_builder.gd` (fetch-driven props + glow); [server README — Interactable descriptors (NEO-25)](../../../server/README.md#interactable-descriptors-neo-25). **NEO-26 landed:** prototype **`SelectionEvent`** — **`TargetSelectionClient.selection_event`** ([NEO-26](../../plans/NEO-26-implementation-plan.md)) when **`lockedTargetId`** changes; optional **`log_selection_events`**. **NEO-27 landed:** Slice 3 telemetry hook sites documented — `selection_event` maps to product `target_changed` in `target_selection_client.gd`; server lock transition hook comments in `InMemoryPlayerTargetLockStore`; reserved `ability_cast_requested` / `ability_cast_denied` comments in `client/scripts/main.gd` (all TODO(E9.M1)); see [NEO-27](../../plans/NEO-27-implementation-plan.md) and [`docs/manual-qa/NEO-27.md`](../../manual-qa/NEO-27.md). **Follow-on / still open:** richer **`InteractableDescriptor`** consumers beyond list projection + existing `POST …/interact`; production telemetry ingest/catalog after E9.M1. **Precursor (E1.M1):** [NEO-9](../../plans/NEO-9-implementation-plan.md) `POST …/interact`. | Linear [NEO-24](https://linear.app/neon-sprawl/issue/NEO-24)–[NEO-27](https://linear.app/neon-sprawl/issue/NEO-27); [E1_M3_InteractionAndTargetingLayer.md](E1_M3_InteractionAndTargetingLayer.md); [E1M3 prototype backlog](../../plans/E1M3-prototype-backlog.md); [server README — Targeting](../../../server/README.md#targeting-neo-23), [Interactable descriptors (NEO-25)](../../../server/README.md#interactable-descriptors-neo-25), [Interaction](../../../server/README.md#interaction-neo-9) |
| E1.M4 | In Progress | **NEO-29 landed:** prototype `HotbarLoadout` v1 server contract (`GET`/`POST /game/players/{id}/hotbar-loadout`) with stable deny reason codes, per-player scope, and persistence policy matching NEO-8/NS-17 (Postgres when configured, in-memory fallback otherwise). Client boot hydration is wired via `hotbar_loadout_client.gd` + `hotbar_state.gd` from `main.gd`; manual QA checklist created. **NEO-31 landed:** prototype **`POST /game/players/{id}/ability-cast`**, digit-key cast wiring from `main.gd`, `ability_cast_client.gd`, and `hotbar_cast_slot_resolver.gd` (target id from `lockedTargetId` in cached target state); GdUnit covers resolver + HTTP client; manual QA [NEO-31](../../manual-qa/NEO-31.md). **NEO-28 landed:** cast POST validates **lock + registry + range** (`invalid_target`, `out_of_range`); **`AbilityCastClient.cast_result_received`** + **`CastFeedbackLabel`** HUD line; manual QA [NEO-28](../../manual-qa/NEO-28.md). **NEO-30 landed:** Slice 3 cast funnel telemetry **hook-site comments** in [`AbilityCastApi.cs`](../../../server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs) (`ability_cast_requested` on authoritative accept, `ability_cast_denied` + non-empty `reasonCode` on each JSON deny branch); client dev hooks unchanged; manual QA [NEO-30](../../manual-qa/NEO-30.md); plan [NEO-30](../../plans/NEO-30-implementation-plan.md). **NEO-32 landed:** `GET /game/players/{id}/cooldown-snapshot` + `on_cooldown` cast deny + prototype global cooldown commit; [`cooldown_snapshot_client.gd`](../../../client/scripts/cooldown_snapshot_client.gd), [`cooldown_state.gd`](../../../client/scripts/cooldown_state.gd), **`CooldownSlotsLabel`** in [`main.gd`](../../../client/scripts/main.gd); manual QA [NEO-32](../../manual-qa/NEO-32.md); plan [NEO-32](../../plans/NEO-32-implementation-plan.md). | [NEO-29](../../plans/NEO-29-implementation-plan.md), [NEO-31](../../plans/NEO-31-implementation-plan.md), [NEO-28](../../plans/NEO-28-implementation-plan.md), [NEO-30](../../plans/NEO-30-implementation-plan.md), [NEO-32](../../plans/NEO-32-implementation-plan.md); [E1_M4_AbilityInputScaffold.md](E1_M4_AbilityInputScaffold.md); [E1M4 prototype backlog](../../plans/E1M4-prototype-backlog.md); `server/NeonSprawl.Server/Game/AbilityInput/`; [`client/scripts/hotbar_loadout_client.gd`](../../../client/scripts/hotbar_loadout_client.gd), [`client/scripts/hotbar_state.gd`](../../../client/scripts/hotbar_state.gd), [`client/scripts/ability_cast_client.gd`](../../../client/scripts/ability_cast_client.gd), [`client/scripts/hotbar_cast_slot_resolver.gd`](../../../client/scripts/hotbar_cast_slot_resolver.gd), [`client/scripts/cooldown_snapshot_client.gd`](../../../client/scripts/cooldown_snapshot_client.gd), [`client/scripts/cooldown_state.gd`](../../../client/scripts/cooldown_state.gd); [server README — Hotbar loadout](../../../server/README.md#hotbar-loadout-neo-29), [Ability cast (NEO-31)](../../../server/README.md#ability-cast-neo-31), [Cooldown snapshot (NEO-32)](../../../server/README.md#cooldown-snapshot-neo-32) |
| E2.M1 | In Progress | **NEO-33 landed:** frozen prototype trio **`salvage`** / **`refine`** / **`intrusion`** in [`content/skills/prototype_skills.json`](../../../content/skills/prototype_skills.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) enforce schema, duplicate `id`, exact trio ids, and category coverage; designer note on **`allowedXpSourceKinds`** in [E2_M1](E2_M1_SkillDefinitionRegistry.md) + [`content/README.md`](../../../content/README.md). **NEO-34 landed:** fail-fast server load of `content/skills/*.json` at startup — `server/NeonSprawl.Server/Game/Skills/` ([NEO-34](../../plans/NEO-34-implementation-plan.md)); config + discovery in [server README — Skill catalog](../../../server/README.md#skill-catalog-contentskills-neo-34). **NEO-35 landed:** `ISkillDefinitionRegistry` / `SkillDefinitionRegistry` + DI ([NEO-35](../../plans/NEO-35-implementation-plan.md)). **NEO-36 landed:** versioned **`GET /game/world/skill-definitions`** ([NEO-36](../../plans/NEO-36-implementation-plan.md)) — `SkillDefinitionsWorldApi` + `SkillDefinitionsListDtos` in `server/NeonSprawl.Server/Game/Skills/`; Bruno `bruno/neon-sprawl-server/skill-definitions/`; manual QA [`NEO-36`](../../manual-qa/NEO-36.md). **E2.M2 consumer (NEO-38 landed):** grant path validates `skillId` + `sourceKind` vs catalog **`allowedXpSourceKinds`** on **`POST …/skill-progression`** ([NEO-38](../../plans/NEO-38-implementation-plan.md)); see [server README — Skill progression grant](../../../server/README.md#skill-progression-grant-neo-38) and [E2_M2](E2_M2_XpAwardAndLevelEngine.md). | [NEO-33](../../plans/NEO-33-implementation-plan.md), [NEO-34](../../plans/NEO-34-implementation-plan.md), [NEO-35](../../plans/NEO-35-implementation-plan.md), [NEO-36](../../plans/NEO-36-implementation-plan.md); [E2_M1](E2_M1_SkillDefinitionRegistry.md); [module_dependency_register.md](module_dependency_register.md) **E2.M1 note**; `server/NeonSprawl.Server/Game/Skills/`; [`docs/manual-qa/NEO-36.md`](../../manual-qa/NEO-36.md); [server README — Skill definitions (NEO-36)](../../../server/README.md#skill-definitions-neo-36) |
-| E2.M2 | In Progress | **NEO-37 landed:** versioned **`GET /game/players/{id}/skill-progression`** ([NEO-37](../../plans/NEO-37-implementation-plan.md)) — read model for every registered skill; known-player gate via `IPositionStateStore`; [server README — Skill progression snapshot (NEO-37)](../../../server/README.md#skill-progression-snapshot-neo-37); manual QA [`NEO-37`](../../manual-qa/NEO-37.md). **NEO-38 landed:** **`POST`** same path — grant apply, persistence (`IPlayerSkillProgressionStore`, `V003` migration), structured denies + **`levelUps`** ([NEO-38](../../plans/NEO-38-implementation-plan.md)); manual QA [`NEO-38`](../../manual-qa/NEO-38.md); Bruno `bruno/neon-sprawl-server/skill-progression/`; [server README — Skill progression grant (NEO-38)](../../../server/README.md#skill-progression-grant-neo-38). **NEO-39 landed:** data-driven `LevelCurve` content + schema + CI validation (`*_level_curve.json`) with fail-fast startup schema checks; progression GET/POST level resolution now uses `ISkillLevelCurve` content-backed thresholds ([NEO-39](../../plans/NEO-39-implementation-plan.md)); manual QA [`NEO-39`](../../manual-qa/NEO-39.md). **NEO-40 landed:** comment-only telemetry hook sites in [`SkillProgressionSnapshotApi.cs`](../../../server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotApi.cs) on **`POST …/skill-progression`** for future **`xp_grant`** / **`level_up`** ([NEO-40](../../plans/NEO-40-implementation-plan.md), [`NEO-40` manual QA](../../manual-qa/NEO-40.md)). **Slice 3 integration** — [NEO-41](https://linear.app/neon-sprawl/issue/NEO-41) / [NEO-42](https://linear.app/neon-sprawl/issue/NEO-42) / [NEO-43](https://linear.app/neon-sprawl/issue/NEO-43); [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44). See [epic_02 — E2.M2 + Slice 3](../epics/epic_02_skills_and_progression.md). | [NEO-37](../../plans/NEO-37-implementation-plan.md), [NEO-38](../../plans/NEO-38-implementation-plan.md), [NEO-39](../../plans/NEO-39-implementation-plan.md), [NEO-40](../../plans/NEO-40-implementation-plan.md), [E2_M2](E2_M2_XpAwardAndLevelEngine.md); label **`E2.M2`** on NEO-37–NEO-40, NEO-41–NEO-43 |
+| E2.M2 | In Progress | **NEO-37 landed:** versioned **`GET /game/players/{id}/skill-progression`** ([NEO-37](../../plans/NEO-37-implementation-plan.md)) — read model for every registered skill; known-player gate via `IPositionStateStore`; [server README — Skill progression snapshot (NEO-37)](../../../server/README.md#skill-progression-snapshot-neo-37); manual QA [`NEO-37`](../../manual-qa/NEO-37.md). **NEO-38 landed:** **`POST`** same path — grant apply, persistence (`IPlayerSkillProgressionStore`, `V003` migration), structured denies + **`levelUps`** ([NEO-38](../../plans/NEO-38-implementation-plan.md)); manual QA [`NEO-38`](../../manual-qa/NEO-38.md); Bruno `bruno/neon-sprawl-server/skill-progression/`; [server README — Skill progression grant (NEO-38)](../../../server/README.md#skill-progression-grant-neo-38). **NEO-39 landed:** data-driven `LevelCurve` content + schema + CI validation (`*_level_curve.json`) with fail-fast startup schema checks; progression GET/POST level resolution now uses `ISkillLevelCurve` content-backed thresholds ([NEO-39](../../plans/NEO-39-implementation-plan.md)); manual QA [`NEO-39`](../../manual-qa/NEO-39.md). **NEO-40 landed:** comment-only telemetry hook sites in [`SkillProgressionSnapshotApi.cs`](../../../server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotApi.cs) on **`POST …/skill-progression`** for future **`xp_grant`** / **`level_up`** ([NEO-40](../../plans/NEO-40-implementation-plan.md), [`NEO-40` manual QA](../../manual-qa/NEO-40.md)). **NEO-41 landed:** gather prototype — **`POST …/interact`** with **`kind: resource_node`** grants **`salvage`** + **`activity`** (10 XP) via [`SkillProgressionGrantOperations`](../../../server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantOperations.cs) ([NEO-41](../../plans/NEO-41-implementation-plan.md), [`NEO-41` manual QA](../../manual-qa/NEO-41.md)); [server README — Interaction](../../../server/README.md#interaction-neo-9). **Slice 3 integration** — [NEO-42](https://linear.app/neon-sprawl/issue/NEO-42) / [NEO-43](https://linear.app/neon-sprawl/issue/NEO-43); [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44). See [epic_02 — E2.M2 + Slice 3](../epics/epic_02_skills_and_progression.md). | [NEO-37](../../plans/NEO-37-implementation-plan.md), [NEO-38](../../plans/NEO-38-implementation-plan.md), [NEO-39](../../plans/NEO-39-implementation-plan.md), [NEO-40](../../plans/NEO-40-implementation-plan.md), [NEO-41](../../plans/NEO-41-implementation-plan.md), [E2_M2](E2_M2_XpAwardAndLevelEngine.md); label **`E2.M2`** on NEO-37–NEO-41, NEO-42–NEO-43 |
+| E3.M1 | In Progress | **NEO-41 landed (prototype):** `POST …/interact` success on **`resource_node`** (`prototype_resource_node_alpha`) applies **`salvage`** skill XP (**`sourceKind: activity`**, 10 XP) via shared NEO-38 grant operations. **Still planned:** `GatherResult`, yields, inventory per [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md). | [NEO-41](../../plans/NEO-41-implementation-plan.md), [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md); `server/NeonSprawl.Server/Game/Interaction/`, `Game/Skills/` |
---
diff --git a/docs/decomposition/modules/module_dependency_register.md b/docs/decomposition/modules/module_dependency_register.md
index 05071cb..9065803 100644
--- a/docs/decomposition/modules/module_dependency_register.md
+++ b/docs/decomposition/modules/module_dependency_register.md
@@ -40,7 +40,7 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen
| Module ID | Module Name | Depends On | Contract Needed | Phase Required | Status |
|---|---|---|---|---|---|
-| E3.M1 | ResourceNodeAndGatherLoop | E1.M3, E2.M2 | ResourceNodeDef, GatherResult, ResourceYieldTable | Prototype | Planned |
+| E3.M1 | ResourceNodeAndGatherLoop | E1.M3, E2.M2 | ResourceNodeDef, GatherResult, ResourceYieldTable | Prototype | In Progress |
| E3.M2 | RefinementAndRecipeExecution | E3.M1, E3.M3 | RecipeDef, CraftRequest, CraftResult | Prototype | Planned |
| E3.M3 | ItemizationAndInventorySchema | None | ItemDef, ItemInstance, InventorySlot | Prototype | Planned |
| E3.M4 | SinkAndDurabilityLifecycle | E3.M3, E8.M3 | DurabilityState, ItemSinkEvent, RepairCostRule | Pre-production | Planned |
diff --git a/docs/manual-qa/NEO-41.md b/docs/manual-qa/NEO-41.md
new file mode 100644
index 0000000..ae69e47
--- /dev/null
+++ b/docs/manual-qa/NEO-41.md
@@ -0,0 +1,55 @@
+# Manual QA — NEO-41 (gather → skill XP via interact)
+
+Reference: [implementation plan](../plans/NEO-41-implementation-plan.md), [NEO-38](./NEO-38.md) (skill progression grant), [NEO-9 README — Interaction](../../server/README.md#interaction-neo-9).
+
+## Preconditions
+
+- Run `NeonSprawl.Server` from `server/NeonSprawl.Server` (`dotnet run`; default `http://localhost:5253`).
+- Player **`dev-local-1`** (default seed).
+
+```bash
+BASE=http://localhost:5253
+ID=dev-local-1
+```
+
+## Move in range of the prototype resource node
+
+Anchor **(12, −6)** on X/Z, radius **3** m. Example stand at **(10, −6)**:
+
+```bash
+curl -sS -i -X POST "${BASE}/game/players/${ID}/move" \
+ -H "Content-Type: application/json" \
+ -d '{"schemaVersion":1,"target":{"x":10,"y":0.9,"z":-6}}'
+```
+
+Expect **HTTP 200**.
+
+## Baseline skill progression (optional)
+
+```bash
+curl -sS "${BASE}/game/players/${ID}/skill-progression"
+```
+
+Note **`salvage`** **`xp`** (often **0** on a fresh store).
+
+## Gather interact (prototype)
+
+```bash
+curl -sS -i -X POST "${BASE}/game/players/${ID}/interact" \
+ -H "Content-Type: application/json" \
+ -d '{"schemaVersion":1,"interactableId":"prototype_resource_node_alpha"}'
+```
+
+Expect **HTTP 200**, **`allowed`:** **true**.
+
+## Verify XP
+
+```bash
+curl -sS "${BASE}/game/players/${ID}/skill-progression"
+```
+
+**`salvage`** **`xp`** should increase by **10** per successful gather interact (repeat interact to see cumulative XP).
+
+## Control — terminal does not grant gather XP
+
+Move near **`prototype_terminal`** (e.g. target **`0.5`, `0.9`, `0.5`**), **`POST …/interact`** with **`prototype_terminal`**, then GET progression: **`salvage`** XP should match whatever you had before this control (no +10 from terminal alone).
diff --git a/docs/plans/NEO-41-implementation-plan.md b/docs/plans/NEO-41-implementation-plan.md
new file mode 100644
index 0000000..85bc380
--- /dev/null
+++ b/docs/plans/NEO-41-implementation-plan.md
@@ -0,0 +1,93 @@
+# NEO-41 — Implementation plan
+
+## Story reference
+
+| Field | Value |
+|--------|--------|
+| **Key** | NEO-41 |
+| **Title** | Gather loop awards skill XP (sourceKind: activity) |
+| **Linear** | https://linear.app/neon-sprawl/issue/NEO-41/gather-loop-awards-skill-xp-sourcekind-activity |
+| **Labels** | E3.M1, E2.M2, server |
+| **Depends on** | [NEO-38](https://linear.app/neon-sprawl/issue/NEO-38) — **Done** (authoritative `POST …/skill-progression`). |
+
+## Kickoff clarifications
+
+| Topic | Question / note | Resolution |
+|--------|-----------------|------------|
+| **What counts as “gather completion”?** | Repo has no `GatherResult` / E3.M1 gather API yet; `InteractionApi` + prototype **`resource_node`** interactable exist. | **User (AskQuestion):** **Prototype stand-in** — treat a **successful** `POST /game/players/{id}/interact` whose resolved interactable has **`Kind == "resource_node"`** (today: `PrototypeInteractableRegistry.PrototypeResourceNodeAlphaId` / `prototype_resource_node_alpha`) as **gather complete** for this slice. |
+| **XP amount** | Linear does not fix per-gather XP. | **Plan default:** **10** XP per qualifying interact (prototype constant, server-side only), same order of magnitude as NEO-38 examples; adjust in implementation PR if product wants a different number before merge. |
+| **Target skill** | Which `SkillDef.id` receives the grant? | **`salvage`** — prototype **gather** anchor per [E2_M1_SkillDefinitionRegistry.md](../decomposition/modules/E2_M1_SkillDefinitionRegistry.md); `allowedXpSourceKinds` already includes **`activity`**. |
+
+## Goal, scope, and out-of-scope
+
+**Goal:** When a **gather-class** prototype interaction succeeds, apply **skill XP** through the **same authoritative persistence and validation rules** as NEO-38 (`ISkillDefinitionRegistry` allowlist, `IPlayerSkillProgressionStore`, `ISkillLevelCurve`), with **`sourceKind: activity`**. **No** ad-hoc client XP counters.
+
+**In scope (from Linear):**
+
+- Gather completion → skill XP via the **same path as NEO-38** (shared server logic, not a duplicate client-only bump).
+- **Never** emit a grant when **`activity`** is not allowed for the chosen skill (guard + unit/integration coverage).
+- **Testable** (and manually verifiable) progression: **`salvage`** XP increases after a successful **`resource_node`** interact; other interactables unchanged.
+
+**Out of scope:**
+
+- Full E3.M1 **`GatherResult`** / yield tables / inventory ([E3_M1_ResourceNodeAndGatherLoop.md](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md) backlog beyond this wire).
+- **Content-driven** XP amounts or multi-skill-per-node mapping (follow-up once gather content exists).
+- Changing **`InteractionResponse`** schema for XP echo (visibility via **`GET …/skill-progression`** after interact is enough for this slice).
+
+## Acceptance criteria checklist
+
+- [x] Gather completion grants skill XP through the **same authoritative path** as NEO-38 (shared grant application; no ad-hoc client XP).
+- [x] Disallowed `sourceKind` / skill combinations **never** emitted from gather (defensive: only `salvage` + `activity` for `resource_node` kind; registry check before apply).
+- [x] Visible or **testable** progression: automated test (and manual QA) shows **`salvage`** XP increases after successful **`resource_node`** interact from in-range position.
+
+## Technical approach
+
+1. **Extract** NEO-38 grant apply logic from `SkillProgressionSnapshotApi` into a **single shared helper or small service** in `Game/Skills/` (e.g. static `SkillProgressionGrantOperations.ApplyGrant` or `ISkillProgressionGrantApplier`) that:
+ - Accepts `playerId`, `skillId`, `amount`, `sourceKind`, and dependencies (`ISkillDefinitionRegistry`, `IPlayerSkillProgressionStore`, `ISkillLevelCurve`).
+ - Performs the same validation order as today: positive amount → known skill → `sourceKind` in `AllowedXpSourceKinds` → `TryApplyXpDelta` → level curve → `SkillProgressionGrantResponse` (success or structured deny with snapshot).
+ - Does **not** re-check `IPositionStateStore` for POST callers that already gated; interact path must only run after existing interact **Allowed** path (player already known to exist at validated position).
+
+2. **`SkillProgressionSnapshotApi` POST** — delegate body handling to the shared helper so HTTP contract stays identical.
+
+3. **`InteractionApi` POST** — after `Allowed == true`, if `entry.Kind == "resource_node"` (string compare **ordinal** against literal `"resource_node"` or a shared constant on `PrototypeInteractableRegistry`), call the helper with **`salvage`**, fixed **`GatherActivityXpAmount` (10)**, **`activity`**. On deny from helper, **do not** change interact outcome: interaction remains **Allowed**; XP grant is a **silent no-op** when validation fails (unexpected if catalog stable) — **prefer**: if deny, still return same `InteractionResponse` as today (gather “succeeds” UX) but **no** XP applied; add **unit test** that corrupt catalog case does not throw. *Clarification:* if `TryApplyXpDelta` returns false (e.g. Postgres missing row), same — no XP, interaction still allowed.
+
+4. **Constants** — `PrototypeInteractableRegistry.ResourceNodeKind` or document `Kind` string `"resource_node"` as the gather trigger; **`GatherActivityXpAmount = 10`** in one place (e.g. `GatherSkillXpConstants.cs`).
+
+5. **Telemetry** — NEO-40 hook sites on `POST …/skill-progression` still fire when clients use that API; **interact-triggered** grants should go through the **same helper** so persistence matches; optional follow-up to duplicate hook comments on interact path — **out of scope** unless trivial one-line reference in helper.
+
+6. **Docs / Bruno / README** — Describe “prototype gather = interact `prototype_resource_node_alpha` in range → `salvage` XP” and curl order: move → interact → GET progression.
+
+## Files to add
+
+| Path | Purpose |
+|------|---------|
+| `server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantOperations.cs` | Shared NEO-38 grant apply + snapshot envelope for POST and gather hook. |
+| `server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantApplyOutcome.cs` | `TryApplyGrant` outcome kind + optional response (deny / granted / store missing). |
+| `server/NeonSprawl.Server/Game/Skills/GatherSkillXpConstants.cs` | Prototype `salvage` + `activity` + XP amount for resource-node gather. |
+| `server/NeonSprawl.Server.Tests/Game/Interaction/InteractionResourceNodeGatherXpTests.cs` | In-range `resource_node` interact increases `salvage` XP on GET snapshot; terminal interact does not; stub-registry deny path. |
+| `server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs` | Test host mirroring in-memory overrides with **`salvage`** excluding **`activity`** for defensive coverage. |
+| `docs/manual-qa/NEO-41.md` | curl: move near node → interact → GET skill-progression. |
+| `bruno/neon-sprawl-server/position/Post move near prototype resource node.bru` | Setup move for NEO-41 Bruno flow. |
+| `bruno/neon-sprawl-server/interaction/Post interact prototype resource node gather.bru` | Bruno: interact `prototype_resource_node_alpha` after move. |
+
+## Files to modify
+
+| Path | Rationale |
+|------|-----------|
+| `server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotApi.cs` | Call shared grant helper from POST handler; remove inlined duplicate logic. |
+| `server/NeonSprawl.Server/Game/Interaction/InteractionApi.cs` | After successful interact on `resource_node` kind, invoke grant helper with `salvage` / `activity` / fixed amount; extend minimal API lambda parameter list for new services. |
+| `server/README.md` | Short subsection: prototype gather XP via interact + link to NEO-41 manual QA. |
+| `docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md` | One-sentence **implementation anchor**: prototype gather completion currently signaled via `InteractionApi` + `Kind: resource_node` until `GatherResult` exists. |
+
+## Tests
+
+| Path | Coverage |
+|------|----------|
+| `server/NeonSprawl.Server.Tests/Game/Skills/SkillProgressionGrantApiTests.cs` | Regression: existing POST cases still pass after refactor to shared helper. |
+| New or extended **`Interaction*Tests.cs`** | `resource_node` interact from seeded/in-range position → GET `…/skill-progression` shows increased **`salvage`** XP; **`prototype_terminal`** interact → **`salvage`** XP unchanged. |
+| **`SalvageActivityDeniedRegistryWebApplicationFactory`** + third test method | Stub **`ISkillDefinitionRegistry`** where **`salvage`** disallows **`activity`**: interact still **`allowed: true`**, **`salvage`** XP stays **0** (no throw). |
+
+## Open questions / risks
+
+- **Long-term:** Real E3.M1 **`GatherResult`** should become the single completion signal; this interact hook is a **deliberate prototype stand-in** — migrate grant call to gather pipeline without changing NEO-38 POST contract.
+- **Risk:** Players could spam interact for XP; **out of scope** for NEO-41 (cooldowns / depletion in E3.M1).
diff --git a/docs/reviews/2026-05-10-NEO-41.md b/docs/reviews/2026-05-10-NEO-41.md
new file mode 100644
index 0000000..67d36fb
--- /dev/null
+++ b/docs/reviews/2026-05-10-NEO-41.md
@@ -0,0 +1,51 @@
+# Code review: NEO-41 (gather interact → skill XP)
+
+**Date:** 2026-05-10
+
+**Scope:** Branch `NEO-41-gather-loop-awards-skill-xp-activity` (commits through `c1e7728`) vs `origin/main`. Linear issue NEO-41.
+
+**Base:** `origin/main`
+
+**Follow-up:** Suggestions and nits below are **done** (strikethrough + **Done.**).
+
+---
+
+## Verdict
+
+**Approve** — Implementation matches the adopted plan; review nits and optional test suggestion addressed in follow-up commit.
+
+## Summary
+
+This work introduces shared `SkillProgressionGrantOperations.TryApplyGrant` (extracted from NEO-38 POST handling), wires `POST /game/players/{id}/interact` to call it when the resolved prototype entry has `kind: resource_node`, using fixed `salvage` + `activity` + 10 XP via `GatherSkillXpConstants`. Denied grants and missing progression store are intentionally silent on the interact path (outcome remains allowed), matching the plan. NEO-40 telemetry hook comments now live on the shared grant path so both HTTP POST and interact-triggered grants see the same hook sites. Docs, Bruno requests, manual QA, and decomposition updates accompany the code. **Risk:** Low; behavior is additive, authority remains server-side, and **136** server tests pass (`dotnet test NeonSprawl.sln`).
+
+## Documentation checked
+
+- `docs/plans/NEO-41-implementation-plan.md` — **matches** (prototype `resource_node` interact, shared grant path, silent no-op on deny/store miss, 10 XP, `salvage` + `activity`, constants/registry usage, tests and manual QA listed).
+- `docs/decomposition/modules/module_dependency_register.md` — **matches** (E3.M1 row moved to **In Progress**; E2.M2 note consistent with NEO-41).
+- `docs/decomposition/modules/documentation_and_implementation_alignment.md` — **matches** (E2.M2 / E3.M1 tracking rows updated for NEO-41).
+- `docs/decomposition/modules/E2_M2_XpAwardAndLevelEngine.md` — **matches** (NEO-41 implementation snapshot paragraph).
+- `docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md` — **Matches** — Summary **Status** set to **In Progress** (aligned with register); prototype anchor paragraph retained.
+- `docs/manual-qa/NEO-41.md` — **matches** intent (move → interact → GET progression).
+- `server/README.md` (diff) — **matches** plan (prototype gather XP pointer).
+- Cross-cutting `client_server_authority.md` / `contracts.md` — **N/A** for this slice (no new client authority; HTTP spikes unchanged in contract “Ready” sense).
+
+**Register / tracking:** Implementation tracking table + register updated; E3.M1 module Summary **Status** aligned with register in follow-up.
+
+## Blocking issues
+
+_None._
+
+## Suggestions
+
+1. ~~**Optional test from plan:** The implementation plan called out an optional unit/integration test for a “corrupt catalog” / disallowed `sourceKind` path ensuring the interact handler does not throw and still returns `Allowed: true`. The current tests cover terminal (no XP) and resource node (XP) happy paths; a focused test with a stub registry that denies `activity` for `salvage` would fully close the stated defensive scenario without needing production catalog corruption.~~ **Done.** (`SalvageActivityDeniedRegistryWebApplicationFactory` + `PostInteract_ResourceNode_WhenSalvageDisallowsActivity_ShouldStillAllowInteract_AndNotApplyXp`.)
+
+2. ~~**E3.M1 Summary table:** Update the **Status** field in `docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md` from **Planned** to **In Progress** so it matches `module_dependency_register.md` and [documentation_and_implementation_alignment.md](documentation_and_implementation_alignment.md) (“Summary table Status field should match the register row”).~~ **Done.**
+
+## Nits
+
+- ~~**Nit:** `_ = SkillProgressionGrantOperations.TryApplyGrant(...)` is clear enough; if the team prefers explicit fire-and-forget documentation, a one-line comment that the outcome is intentionally ignored on the interact path could mirror the plan wording (optional).~~ **Done.** (Comment on interact path in `InteractionApi.cs`.)
+
+## Verification
+
+- `dotnet test NeonSprawl.sln` — **passed** (**136** tests) after follow-up.
+- Manual: follow `docs/manual-qa/NEO-41.md` and/or Bruno flows under `bruno/neon-sprawl-server/position/` and `bruno/neon-sprawl-server/interaction/` for end-to-end smoke.
diff --git a/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionApiTests.cs
index a229e4f..c2e380c 100644
--- a/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionApiTests.cs
+++ b/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionApiTests.cs
@@ -7,7 +7,7 @@ using Xunit;
namespace NeonSprawl.Server.Tests.Game.Interaction;
-/// Integration tests for (NS-18).
+/// Integration tests for (NS-18). NEO-41: covers gather XP on resource_node kind.
public class InteractionApiTests
{
[Fact]
diff --git a/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionResourceNodeGatherXpTests.cs b/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionResourceNodeGatherXpTests.cs
new file mode 100644
index 0000000..19a682d
--- /dev/null
+++ b/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionResourceNodeGatherXpTests.cs
@@ -0,0 +1,118 @@
+using System.Linq;
+using System.Net;
+using System.Net.Http.Json;
+using NeonSprawl.Server.Game.Interaction;
+using NeonSprawl.Server.Game.PositionState;
+using NeonSprawl.Server.Game.Skills;
+using NeonSprawl.Server.Game.World;
+using Xunit;
+
+namespace NeonSprawl.Server.Tests.Game.Interaction;
+
+/// NEO-41: successful resource_node interact grants salvage XP via shared NEO-38 grant path.
+public sealed class InteractionResourceNodeGatherXpTests
+{
+ [Fact]
+ public async Task PostInteract_PrototypeTerminal_ShouldNotIncreaseSalvageXp()
+ {
+ // Arrange
+ await using var factory = new InMemoryWebApplicationFactory();
+ var client = factory.CreateClient();
+ var move = new MoveCommandRequest
+ {
+ SchemaVersion = MoveCommandRequest.CurrentSchemaVersion,
+ Target = new PositionVector { X = 0.5, Y = 0.9, Z = 0.5 },
+ };
+ await client.PostAsJsonAsync("/game/players/dev-local-1/move", move);
+
+ // Act
+ var interact = await client.PostAsJsonAsync(
+ "/game/players/dev-local-1/interact",
+ new InteractionRequest
+ {
+ SchemaVersion = InteractionRequest.CurrentSchemaVersion,
+ InteractableId = PrototypeInteractableRegistry.PrototypeTerminalId,
+ });
+ var snapshot = await client.GetAsync("/game/players/dev-local-1/skill-progression");
+
+ // Assert
+ Assert.Equal(HttpStatusCode.OK, interact.StatusCode);
+ Assert.Equal(HttpStatusCode.OK, snapshot.StatusCode);
+ var body = await snapshot.Content.ReadFromJsonAsync();
+ Assert.NotNull(body);
+ var salvage = body!.Skills!.Single(static s => s.Id == "salvage");
+ Assert.Equal(0, salvage.Xp);
+ }
+
+ [Fact]
+ public async Task PostInteract_ResourceNode_WhenInRange_ShouldGrantSalvageActivityXp_ViaProgressionSnapshot()
+ {
+ // Arrange — node at (12, -6), radius 3; stand at (10, -6) → horizontal distance 2
+ await using var factory = new InMemoryWebApplicationFactory();
+ var client = factory.CreateClient();
+ var move = new MoveCommandRequest
+ {
+ SchemaVersion = MoveCommandRequest.CurrentSchemaVersion,
+ Target = new PositionVector { X = 10, Y = 0.9, Z = -6 },
+ };
+ await client.PostAsJsonAsync("/game/players/dev-local-1/move", move);
+
+ // Act
+ var interact = await client.PostAsJsonAsync(
+ "/game/players/dev-local-1/interact",
+ new InteractionRequest
+ {
+ SchemaVersion = InteractionRequest.CurrentSchemaVersion,
+ InteractableId = PrototypeInteractableRegistry.PrototypeResourceNodeAlphaId,
+ });
+ var snapshot = await client.GetAsync("/game/players/dev-local-1/skill-progression");
+
+ // Assert
+ Assert.Equal(HttpStatusCode.OK, interact.StatusCode);
+ var envelope = await interact.Content.ReadFromJsonAsync();
+ Assert.NotNull(envelope);
+ Assert.True(envelope!.Allowed);
+
+ Assert.Equal(HttpStatusCode.OK, snapshot.StatusCode);
+ var body = await snapshot.Content.ReadFromJsonAsync();
+ Assert.NotNull(body);
+ var salvage = body!.Skills!.Single(static s => s.Id == "salvage");
+ Assert.Equal(GatherSkillXpConstants.ActivityXpPerResourceNodeGather, salvage.Xp);
+ }
+
+ [Fact]
+ public async Task PostInteract_ResourceNode_WhenSalvageDisallowsActivity_ShouldStillAllowInteract_AndNotApplyXp()
+ {
+ // Arrange — stub registry: salvage exists but activity is not in allowedXpSourceKinds (defensive / corrupt-catalog stand-in).
+ await using var factory = new SalvageActivityDeniedRegistryWebApplicationFactory();
+ var client = factory.CreateClient();
+ var move = new MoveCommandRequest
+ {
+ SchemaVersion = MoveCommandRequest.CurrentSchemaVersion,
+ Target = new PositionVector { X = 10, Y = 0.9, Z = -6 },
+ };
+ await client.PostAsJsonAsync("/game/players/dev-local-1/move", move);
+
+ // Act
+ var interact = await client.PostAsJsonAsync(
+ "/game/players/dev-local-1/interact",
+ new InteractionRequest
+ {
+ SchemaVersion = InteractionRequest.CurrentSchemaVersion,
+ InteractableId = PrototypeInteractableRegistry.PrototypeResourceNodeAlphaId,
+ });
+ var snapshot = await client.GetAsync("/game/players/dev-local-1/skill-progression");
+
+ // Assert
+ Assert.Equal(HttpStatusCode.OK, interact.StatusCode);
+ var envelope = await interact.Content.ReadFromJsonAsync();
+ Assert.NotNull(envelope);
+ Assert.True(envelope!.Allowed);
+
+ Assert.Equal(HttpStatusCode.OK, snapshot.StatusCode);
+ var body = await snapshot.Content.ReadFromJsonAsync();
+ Assert.NotNull(body);
+ var salvage = body!.Skills!.Single(static s => s.Id == "salvage");
+ Assert.Equal(0, salvage.Xp);
+ }
+}
diff --git a/server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs
new file mode 100644
index 0000000..ab4dc01
--- /dev/null
+++ b/server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs
@@ -0,0 +1,112 @@
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Time.Testing;
+using NeonSprawl.Server.Game.AbilityInput;
+using NeonSprawl.Server.Game.PositionState;
+using NeonSprawl.Server.Game.Skills;
+using Npgsql;
+
+namespace NeonSprawl.Server.Tests.Game.Interaction;
+
+///
+/// Same in-memory overrides as , plus a stub
+/// where salvage does not allow activity — for NEO-41 defensive tests without mutating disk catalog.
+///
+public sealed class SalvageActivityDeniedRegistryWebApplicationFactory : WebApplicationFactory
+{
+ protected override void ConfigureWebHost(IWebHostBuilder builder)
+ {
+ builder.UseEnvironment("Testing");
+
+ var skillsDir = SkillCatalogPathResolution.TryDiscoverSkillsDirectory(AppContext.BaseDirectory)
+ ?? throw new InvalidOperationException(
+ "Could not discover repo content/skills from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
+ builder.UseSetting("Content:SkillsDirectory", skillsDir);
+
+ builder.ConfigureTestServices(services =>
+ {
+ for (var i = services.Count - 1; i >= 0; i--)
+ {
+ var d = services[i];
+ if (d.ServiceType == typeof(IPositionStateStore) ||
+ d.ServiceType == typeof(IPlayerHotbarLoadoutStore) ||
+ d.ServiceType == typeof(IPlayerSkillProgressionStore) ||
+ d.ServiceType == typeof(TimeProvider) ||
+ d.ServiceType == typeof(IPlayerAbilityCooldownStore) ||
+ d.ServiceType == typeof(ISkillDefinitionRegistry) ||
+ d.ServiceType == typeof(NpgsqlDataSource) ||
+ (d.ServiceType == typeof(IHostedService) &&
+ (d.ImplementationType == typeof(PostgresDevPlayerSeedHostedService) ||
+ d.ImplementationType == typeof(NpgsqlDataSourceShutdownHostedService))))
+ {
+ services.RemoveAt(i);
+ }
+ }
+
+ var fakeTime = new FakeTimeProvider(new DateTimeOffset(2026, 4, 30, 12, 0, 0, TimeSpan.Zero));
+ services.AddSingleton(fakeTime);
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ });
+ }
+}
+
+/// Prototype trio ids; salvage excludes activity so gather interact grant path denies without throwing.
+internal sealed class SalvageActivityDeniedSkillRegistry : ISkillDefinitionRegistry
+{
+ private static readonly SkillDefRow SalvageNoActivity = new(
+ "salvage",
+ "gather",
+ "Salvage",
+ new[] { "mission_reward" });
+
+ private static readonly SkillDefRow Refine = new(
+ "refine",
+ "process",
+ "Refine",
+ new[] { "activity", "mission_reward", "trainer" });
+
+ private static readonly SkillDefRow Intrusion = new(
+ "intrusion",
+ "tech",
+ "Intrusion",
+ new[] { "activity", "mission_reward", "book_or_item" });
+
+ private static readonly IReadOnlyList Ordered =
+ [
+ Intrusion,
+ Refine,
+ SalvageNoActivity,
+ ];
+
+ public bool TryGetDefinition(string? skillId, [NotNullWhen(true)] out SkillDefRow? definition)
+ {
+ if (skillId is null)
+ {
+ definition = null;
+ return false;
+ }
+
+ var k = skillId.Trim();
+ foreach (var row in Ordered)
+ {
+ if (string.Equals(row.Id, k, StringComparison.OrdinalIgnoreCase))
+ {
+ definition = row;
+ return true;
+ }
+ }
+
+ definition = null;
+ return false;
+ }
+
+ public IReadOnlyList GetDefinitionsInIdOrder() => Ordered;
+}
diff --git a/server/NeonSprawl.Server/Game/Interaction/InteractionApi.cs b/server/NeonSprawl.Server/Game/Interaction/InteractionApi.cs
index 59b1c16..4ed189f 100644
--- a/server/NeonSprawl.Server/Game/Interaction/InteractionApi.cs
+++ b/server/NeonSprawl.Server/Game/Interaction/InteractionApi.cs
@@ -1,4 +1,5 @@
using NeonSprawl.Server.Game.PositionState;
+using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Game.World;
namespace NeonSprawl.Server.Game.Interaction;
@@ -13,7 +14,8 @@ public static class InteractionApi
{
app.MapPost(
"/game/players/{id}/interact",
- (string id, InteractionRequest? body, IPositionStateStore store) =>
+ (string id, InteractionRequest? body, IPositionStateStore store,
+ ISkillDefinitionRegistry registry, IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve) =>
{
if (body is null || body.SchemaVersion != InteractionRequest.CurrentSchemaVersion)
{
@@ -66,6 +68,20 @@ public static class InteractionApi
});
}
+ if (string.Equals(entry.Kind, PrototypeInteractableRegistry.ResourceNodeKind, StringComparison.Ordinal))
+ {
+ // NEO-41: prototype gather completion — same grant rules/persistence as POST …/skill-progression.
+ // Interact response stays Allowed=true; grant outcome is intentionally ignored (silent no-op on deny / store miss per plan).
+ _ = SkillProgressionGrantOperations.TryApplyGrant(
+ playerKey,
+ GatherSkillXpConstants.SalvageSkillId,
+ GatherSkillXpConstants.ActivityXpPerResourceNodeGather,
+ GatherSkillXpConstants.ActivitySourceKind,
+ registry,
+ xpStore,
+ levelCurve);
+ }
+
return Results.Json(
new InteractionResponse
{
diff --git a/server/NeonSprawl.Server/Game/Interaction/PrototypeInteractableRegistry.cs b/server/NeonSprawl.Server/Game/Interaction/PrototypeInteractableRegistry.cs
index 460f26b..1bcf04c 100644
--- a/server/NeonSprawl.Server/Game/Interaction/PrototypeInteractableRegistry.cs
+++ b/server/NeonSprawl.Server/Game/Interaction/PrototypeInteractableRegistry.cs
@@ -12,11 +12,14 @@ public static class PrototypeInteractableRegistry
/// Second stub row for multi-entity projection (NEO-25).
public const string PrototypeResourceNodeAlphaId = "prototype_resource_node_alpha";
+ /// for gather-anchor nodes (NEO-41).
+ public const string ResourceNodeKind = "resource_node";
+
/// Source-of-truth anchor and radius; client discovers via GET /game/world/interactables (NEO-25).
public static readonly PrototypeInteractableEntry PrototypeTerminal = new(0, 0.5, 0, 3.0, "terminal");
/// +X / −Z open floor (avoids overlap with scene Obstacle at ~(6,1,5)); same radius as terminal.
- public static readonly PrototypeInteractableEntry PrototypeResourceNodeAlpha = new(12, 0.5, -6, 3.0, "resource_node");
+ public static readonly PrototypeInteractableEntry PrototypeResourceNodeAlpha = new(12, 0.5, -6, 3.0, ResourceNodeKind);
private static readonly Dictionary ById = new(StringComparer.Ordinal)
{
diff --git a/server/NeonSprawl.Server/Game/Skills/GatherSkillXpConstants.cs b/server/NeonSprawl.Server/Game/Skills/GatherSkillXpConstants.cs
new file mode 100644
index 0000000..d41192d
--- /dev/null
+++ b/server/NeonSprawl.Server/Game/Skills/GatherSkillXpConstants.cs
@@ -0,0 +1,11 @@
+namespace NeonSprawl.Server.Game.Skills;
+
+/// NEO-41: prototype gather (resource-node interact) → salvage skill XP via sourceKind: activity.
+public static class GatherSkillXpConstants
+{
+ public const int ActivityXpPerResourceNodeGather = 10;
+
+ public const string SalvageSkillId = "salvage";
+
+ public const string ActivitySourceKind = "activity";
+}
diff --git a/server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantApplyOutcome.cs b/server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantApplyOutcome.cs
new file mode 100644
index 0000000..107be9d
--- /dev/null
+++ b/server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantApplyOutcome.cs
@@ -0,0 +1,19 @@
+namespace NeonSprawl.Server.Game.Skills;
+
+/// Result of (NEO-38 / NEO-41 shared path).
+public enum SkillProgressionGrantApplyKind
+{
+ /// Structured deny — is populated.
+ Denied,
+
+ /// returned false (e.g. player missing from progression store).
+ ProgressionStoreMissing,
+
+ /// XP applied — is success envelope.
+ Granted,
+}
+
+/// Deny or success JSON envelope; null only when is .
+public readonly record struct SkillProgressionGrantApplyOutcome(
+ SkillProgressionGrantApplyKind Kind,
+ SkillProgressionGrantResponse? Response);
diff --git a/server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantOperations.cs b/server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantOperations.cs
new file mode 100644
index 0000000..e905526
--- /dev/null
+++ b/server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantOperations.cs
@@ -0,0 +1,93 @@
+namespace NeonSprawl.Server.Game.Skills;
+
+/// Shared NEO-38 skill XP grant apply (used by HTTP POST and NEO-41 gather interact hook).
+public static class SkillProgressionGrantOperations
+{
+ ///
+ /// Applies one grant if validation passes and succeeds.
+ /// Caller must ensure is already authorized for progression (e.g. known to position state).
+ ///
+ public static SkillProgressionGrantApplyOutcome TryApplyGrant(
+ string playerId,
+ string skillIdRaw,
+ int amount,
+ string sourceKindRaw,
+ ISkillDefinitionRegistry registry,
+ IPlayerSkillProgressionStore xpStore,
+ ISkillLevelCurve levelCurve)
+ {
+ static SkillProgressionGrantResponse Deny(SkillProgressionSnapshotResponse snapshot, string reason) =>
+ new()
+ {
+ Granted = false,
+ ReasonCode = reason,
+ Progression = snapshot,
+ LevelUps = [],
+ };
+
+ var beforeSnapshot = SkillProgressionSnapshotApi.BuildSnapshot(playerId, registry, xpStore, levelCurve);
+
+ if (amount <= 0)
+ {
+ return new SkillProgressionGrantApplyOutcome(
+ SkillProgressionGrantApplyKind.Denied,
+ Deny(beforeSnapshot, SkillProgressionSnapshotApi.ReasonInvalidAmount));
+ }
+
+ var skillLookup = skillIdRaw.Trim();
+ if (skillLookup.Length == 0 || !registry.TryGetDefinition(skillLookup, out var def))
+ {
+ return new SkillProgressionGrantApplyOutcome(
+ SkillProgressionGrantApplyKind.Denied,
+ Deny(beforeSnapshot, SkillProgressionSnapshotApi.ReasonUnknownSkill));
+ }
+
+ var source = sourceKindRaw.Trim();
+ var allowed = def.AllowedXpSourceKinds.Any(k =>
+ string.Equals(k, source, StringComparison.OrdinalIgnoreCase));
+ if (!allowed)
+ {
+ return new SkillProgressionGrantApplyOutcome(
+ SkillProgressionGrantApplyKind.Denied,
+ Deny(beforeSnapshot, SkillProgressionSnapshotApi.ReasonSourceKindNotAllowed));
+ }
+
+ if (!xpStore.TryApplyXpDelta(playerId, def.Id, amount, out var prevXp, out var newXp))
+ {
+ return new SkillProgressionGrantApplyOutcome(SkillProgressionGrantApplyKind.ProgressionStoreMissing, null);
+ }
+
+ // --- Telemetry hook site (NEO-40): future E9.M1 catalog event `xp_grant` ---
+ // No ingest here until the telemetry catalog exists. Planned payload fields: playerId,
+ // skillId, amount, sourceKind; optional correlation/request ids when Slice 3 wires callers.
+
+ var prevLevel = levelCurve.LevelFromTotalXp(prevXp);
+ var nextLevel = levelCurve.LevelFromTotalXp(newXp);
+ var levelUps = new List();
+ if (nextLevel > prevLevel)
+ {
+ // --- Telemetry hook site (NEO-40): future E9.M1 catalog event `level_up` ---
+ // Planned fields: playerId, skillId, previousLevel, newLevel, prevXp, newXp (totals before/after this grant). No logging (comments-only).
+ // Time-to-first-level-up: needs first-seen / milestone persistence not present here; wire
+ // when catalog + storage exist (likely derived from `level_up` or a dedicated milestone).
+
+ levelUps.Add(
+ new SkillLevelUpJson
+ {
+ SkillId = def.Id,
+ PreviousLevel = prevLevel,
+ NewLevel = nextLevel,
+ });
+ }
+
+ var afterSnapshot = SkillProgressionSnapshotApi.BuildSnapshot(playerId, registry, xpStore, levelCurve);
+ return new SkillProgressionGrantApplyOutcome(
+ SkillProgressionGrantApplyKind.Granted,
+ new SkillProgressionGrantResponse
+ {
+ Granted = true,
+ Progression = afterSnapshot,
+ LevelUps = levelUps,
+ });
+ }
+}
diff --git a/server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotApi.cs b/server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotApi.cs
index 65fd2a7..a9d1387 100644
--- a/server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotApi.cs
+++ b/server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotApi.cs
@@ -33,17 +33,6 @@ public static class SkillProgressionSnapshotApi
(string id, SkillProgressionGrantRequest? body, IPositionStateStore positions,
ISkillDefinitionRegistry registry, IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve) =>
{
- static SkillProgressionGrantResponse Deny(
- SkillProgressionSnapshotResponse snapshot,
- string reason) =>
- new SkillProgressionGrantResponse
- {
- Granted = false,
- ReasonCode = reason,
- Progression = snapshot,
- LevelUps = [],
- };
-
if (body is null || body.SchemaVersion != SkillProgressionGrantRequest.CurrentSchemaVersion)
{
return Results.BadRequest();
@@ -55,63 +44,22 @@ public static class SkillProgressionSnapshotApi
return Results.NotFound();
}
- var beforeSnapshot = BuildSnapshot(trimmedId, registry, xpStore, levelCurve);
+ var outcome = SkillProgressionGrantOperations.TryApplyGrant(
+ trimmedId,
+ body.SkillId,
+ body.Amount,
+ body.SourceKind,
+ registry,
+ xpStore,
+ levelCurve);
- if (body.Amount <= 0)
+ return outcome.Kind switch
{
- return Results.Json(Deny(beforeSnapshot, ReasonInvalidAmount));
- }
-
- var skillLookup = body.SkillId.Trim();
- if (skillLookup.Length == 0 || !registry.TryGetDefinition(skillLookup, out var def))
- {
- return Results.Json(Deny(beforeSnapshot, ReasonUnknownSkill));
- }
-
- var source = body.SourceKind.Trim();
- var allowed = def.AllowedXpSourceKinds.Any(k =>
- string.Equals(k, source, StringComparison.OrdinalIgnoreCase));
- if (!allowed)
- {
- return Results.Json(Deny(beforeSnapshot, ReasonSourceKindNotAllowed));
- }
-
- if (!xpStore.TryApplyXpDelta(trimmedId, def.Id, body.Amount, out var prevXp, out var newXp))
- {
- return Results.NotFound();
- }
-
- // --- Telemetry hook site (NEO-40): future E9.M1 catalog event `xp_grant` ---
- // No ingest here until the telemetry catalog exists. Planned payload fields: playerId,
- // skillId, amount, sourceKind; optional correlation/request ids when Slice 3 wires callers.
-
- var prevLevel = levelCurve.LevelFromTotalXp(prevXp);
- var nextLevel = levelCurve.LevelFromTotalXp(newXp);
- var levelUps = new List();
- if (nextLevel > prevLevel)
- {
- // --- Telemetry hook site (NEO-40): future E9.M1 catalog event `level_up` ---
- // Planned fields: playerId, skillId, previousLevel, newLevel, prevXp, newXp (totals before/after this grant). No logging (comments-only).
- // Time-to-first-level-up: needs first-seen / milestone persistence not present here; wire
- // when catalog + storage exist (likely derived from `level_up` or a dedicated milestone).
-
- levelUps.Add(
- new SkillLevelUpJson
- {
- SkillId = def.Id,
- PreviousLevel = prevLevel,
- NewLevel = nextLevel,
- });
- }
-
- var afterSnapshot = BuildSnapshot(trimmedId, registry, xpStore, levelCurve);
- return Results.Json(
- new SkillProgressionGrantResponse
- {
- Granted = true,
- Progression = afterSnapshot,
- LevelUps = levelUps,
- });
+ SkillProgressionGrantApplyKind.Denied => Results.Json(outcome.Response!),
+ SkillProgressionGrantApplyKind.ProgressionStoreMissing => Results.NotFound(),
+ SkillProgressionGrantApplyKind.Granted => Results.Json(outcome.Response!),
+ _ => throw new InvalidOperationException($"Unexpected grant outcome: {outcome.Kind}"),
+ };
});
return app;
diff --git a/server/README.md b/server/README.md
index a94f0e1..4053c56 100644
--- a/server/README.md
+++ b/server/README.md
@@ -174,6 +174,10 @@ curl -s -X POST http://localhost:5253/game/players/dev-local-1/interact \
When **`allowed` is `true`**, **`reasonCode` is omitted** (clients must not require it on success). Unknown player never returns this JSON — use **404**.
+### Gather prototype → skill XP (NEO-41)
+
+When **`allowed` is `true`** and the interactable’s **`kind`** is **`resource_node`** (today: **`prototype_resource_node_alpha`** from **`GET /game/world/interactables`**), the server applies **10** **`salvage`** skill XP with **`sourceKind: activity`** using the **same validation and persistence** as **`POST /game/players/{id}/skill-progression`** ([NEO-38](#skill-progression-grant-neo-38)) via `SkillProgressionGrantOperations` — not a separate client-side XP bump. Move into horizontal range first (anchor **(12, −6)** m on X/Z, radius **3**). Plan: [NEO-41 implementation plan](../../docs/plans/NEO-41-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-41.md`](../../docs/manual-qa/NEO-41.md); Bruno: `bruno/neon-sprawl-server/position/Post move near prototype resource node.bru` then `bruno/neon-sprawl-server/interaction/Post interact prototype resource node gather.bru`.
+
Contract details and PR blurb: [NEO-9 implementation plan](../../docs/plans/NEO-9-implementation-plan.md).
## Interactable descriptors (NEO-25)