Compare commits

...

19 Commits

Author SHA1 Message Date
VinPropane 66d4c7a3f2
Merge pull request #77 from ViPro-Technologies/NEO-42-craft-refine-skill-xp-activity
NEO-42: Craft / refine skill XP prep (RefineActivitySkillXpGrant + docs)
2026-05-10 19:54:38 -04:00
VinPropane caa400f43d NEO-42: address review — decomposition docs, plan AC, grant helper, test assert 2026-05-10 19:46:49 -04:00
VinPropane 15bf598228 NEO-42: add refine activity XP grant helper and tests 2026-05-10 19:38:30 -04:00
VinPropane 20a141ba37 NEO-42: add implementation plan for craft/refine skill XP 2026-05-10 19:36:04 -04:00
VinPropane 49f67ecf4c
Merge pull request #76 from ViPro-Technologies/chore/ci-bruno-dotnet-workflow
chore: Run Bruno server collection in .NET CI workflow
2026-05-10 19:31:00 -04:00
VinPropane 596b319493 chore: run Bruno server collection in .NET GitHub workflow 2026-05-10 19:27:25 -04:00
VinPropane 371d2d17cc
Merge pull request #75 from ViPro-Technologies/NEO-41-gather-loop-awards-skill-xp-activity
NEO-41: Award gather skill XP (activity source) on prototype resource_node interact
2026-05-10 19:25:36 -04:00
VinPropane 95528ae42a chore: Bruno ability-cast preset reset spawn in pre-request 2026-05-10 19:20:56 -04:00
VinPropane 2a65740316 chore: Bruno gather interact pre-request moves player in range 2026-05-10 19:19:50 -04:00
VinPropane b9343aeeda chore: reset dev position in Bruno ability-cast preset 2026-05-10 19:17:09 -04:00
VinPropane 29a6c6d1a7 NEO-41: address code review (E3.M1 status, deny-path test) 2026-05-10 19:13:56 -04:00
VinPropane 05abc47111 NEO-41: add code review for gather XP branch 2026-05-10 19:11:56 -04:00
VinPropane c1e77280e3 NEO-41: grant salvage activity XP on resource_node interact 2026-05-10 19:10:35 -04:00
VinPropane 650767f16c NEO-41: add implementation plan for gather interact skill XP 2026-05-10 19:06:45 -04:00
VinPropane 9446bab292
Merge pull request #74 from ViPro-Technologies/NEO-40-skill-xp-level-up-telemetry-hook-sites
NEO-40: Skill XP / level-up telemetry hook sites
2026-05-10 18:59:25 -04:00
VinPropane 35b666ed60 NEO-40: sync E2.M2 alignment doc; close review feedback 2026-05-10 18:54:17 -04:00
VinPropane aaffaa7f1d NEO-40: add code review for telemetry hook sites branch 2026-05-10 18:52:33 -04:00
VinPropane effd234ee3 NEO-40: add progression telemetry hook comment sites 2026-05-10 18:49:40 -04:00
VinPropane 178ff2b625 NEO-40: add implementation plan for telemetry hook sites 2026-05-10 18:48:18 -04:00
37 changed files with 1419 additions and 66 deletions

View File

@ -1,4 +1,5 @@
# Build and test the C# solution (server + tests). Targets repo root NeonSprawl.sln.
# Build and test the C# solution (server + tests), then run the Bruno server collection
# against a live `dotnet run` host (same Postgres service as tests). Targets repo root NeonSprawl.sln.
# PRs: workflow always runs (so ".NET / build" can be a required check). Path-sensitive
# work uses step-level `if` — skipped steps still leave the job successful (unlike a
# workflow skipped via `on.paths`, which leaves the required check missing/pending).
@ -11,6 +12,7 @@ on:
paths:
- "NeonSprawl.sln"
- "server/**"
- "bruno/neon-sprawl-server/**"
- ".github/workflows/dotnet.yml"
pull_request:
branches: [main]
@ -51,11 +53,12 @@ jobs:
server:
- "NeonSprawl.sln"
- "server/**"
- "bruno/neon-sprawl-server/**"
- ".github/workflows/dotnet.yml"
- name: Skip .NET build (no server-side paths in this PR)
if: github.event_name == 'pull_request' && steps.paths.outputs.server != 'true'
run: echo "Skipping .NET restore, build, and tests."
run: echo "Skipping .NET restore, build, tests, and Bruno collection."
- name: Setup .NET
if: github.event_name == 'push' || steps.paths.outputs.server == 'true'
@ -77,3 +80,45 @@ jobs:
ConnectionStrings__NeonSprawl: >-
Host=localhost;Port=5432;Database=neon_sprawl;Username=neon_sprawl;Password=neon_sprawl_dev
run: dotnet test NeonSprawl.sln --no-build --configuration Release --verbosity normal
- name: Setup Node (Bruno CLI)
if: github.event_name == 'push' || steps.paths.outputs.server == 'true'
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Bruno (server API collection)
if: github.event_name == 'push' || steps.paths.outputs.server == 'true'
env:
ConnectionStrings__NeonSprawl: >-
Host=localhost;Port=5432;Database=neon_sprawl;Username=neon_sprawl;Password=neon_sprawl_dev
run: |
set -euo pipefail
dotnet run --project server/NeonSprawl.Server/NeonSprawl.Server.csproj \
--configuration Release --no-build \
--urls "http://127.0.0.1:5253" &
SERVER_PID=$!
cleanup() {
kill "${SERVER_PID}" 2>/dev/null || true
wait "${SERVER_PID}" 2>/dev/null || true
}
trap cleanup EXIT
for i in $(seq 1 90); do
if curl -sfS "http://127.0.0.1:5253/health" >/dev/null; then
echo "Server ready (${i}s)"
break
fi
if ! kill -0 "${SERVER_PID}" 2>/dev/null; then
echo "Server process exited before /health succeeded" >&2
wait "${SERVER_PID}" || true
exit 1
fi
sleep 1
if [ "${i}" -eq 90 ]; then
echo "Timed out waiting for http://127.0.0.1:5253/health" >&2
exit 1
fi
done
cd bruno/neon-sprawl-server
# v3 defaults to Safe Mode; pre-request scripts use require("axios").
npx --yes @usebruno/cli@3.3.0 run --env Local --sandbox=developer --bail

View File

@ -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

View File

@ -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

View File

@ -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 1011 resource-node gather). Runs before hotbar/target/cast (seq 2022).
}
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);
});
}

View File

@ -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

View File

@ -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");
});
}

View File

@ -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
}
}
}

View File

@ -6,6 +6,8 @@ meta {
docs {
NEO-38: single skill XP grant + allowlist + level-up metadata; see server README.
NEO-40: same contract; server adds comment-only telemetry hook markers (no request/response change).
E2.M2 tracking row in docs/decomposition/modules/documentation_and_implementation_alignment.md lists NEO-40 landed with plan + manual QA links.
}
post {

View File

@ -19,7 +19,13 @@
**NEO-39 landed:** data-driven **`LevelCurve`** at `content/skills/prototype_level_curve.json` with schema `content/schemas/level-curve.schema.json`; CI validation extended in `scripts/validate_content.py`; server boot now fail-fast loads curve content and uses it for XP→level resolution in skill progression GET/POST paths (no code edit required for threshold tuning).
**Still backlog within this module:** telemetry hook sites (**NEO-40**); 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-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)**.
**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)**.
**NEO-42 landed (prep):** **`RefineActivitySkillXpGrant`** + **`RefineSkillXpConstants`** + test **`RefineActivityDeniedRegistryWebApplicationFactory`** — craft/refine success is intended to award **`refine`** XP (**`sourceKind: activity`**, **10** XP) via the same **`SkillProgressionGrantOperations`** stack as NEO-38 ([implementation plan](../../plans/NEO-42-implementation-plan.md)); manual QA **[`NEO-42.md`](../../manual-qa/NEO-42.md)**; **[server README — Craft / refine hook (NEO-42)](../../../server/README.md#craft--refine-hook--skill-xp-neo-42)**. **E3.M2** must invoke **`GrantOnSuccessfulCraftOrRefine`** on real **`CraftResult`** success for end-to-end behavior (no craft route in repo at this slice).
**Still backlog within this module:** Slice 3 **NEO-43** (and **NEO-44** gig path); **NEO-42** end-to-end wiring awaits **E3.M2** success handler. Keep this paragraph and [documentation tracking](documentation_and_implementation_alignment.md) in sync as Slice 2 stories merge.
## Purpose

View File

@ -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** — 46 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.

View File

@ -9,6 +9,10 @@
| **Stage target** | Prototype |
| **Status** | Planned (see [dependency register](module_dependency_register.md)) |
## Implementation snapshot
**Prep (NEO-42):** Server ships **`RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine`** + **`RefineSkillXpConstants`** under `server/NeonSprawl.Server/Game/Skills/` — delegates to **`SkillProgressionGrantOperations.TryApplyGrant`** with **`refine`** + **`activity`** (prototype **10** XP), same persistence and **`allowedXpSourceKinds`** rules as **`POST …/skill-progression`**. **No** `CraftRequest` / **`CraftResult`** route yet; when this module adds authoritative craft/refine success, call that helper once on success ([NEO-42 implementation plan](../../plans/NEO-42-implementation-plan.md); [server README — Craft / refine hook (NEO-42)](../../../server/README.md#craft--refine-hook--skill-xp-neo-42)).
## Purpose
Implements the processing and crafting pipeline: raw-to-refined flows and recipe execution with server-authoritative inventory mutations. Bridges gathered materials ([E3.M1](E3_M1_ResourceNodeAndGatherLoop.md)) and item definitions ([E3.M3](E3_M3_ItemizationAndInventorySchema.md)) into usable outputs for combat, quests, and economy loops.

View File

@ -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). **Follow-on:** [NEO-40](https://linear.app/neon-sprawl/issue/NEO-40) (telemetry hook sites). 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), [E2_M2](E2_M2_XpAwardAndLevelEngine.md); label **`E2.M2`** on NEO-37NEO-40, NEO-41NEO-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). **NEO-42 landed (prep):** **`RefineActivitySkillXpGrant`** / **`RefineSkillXpConstants`** + deny-registry factory + tests — same NEO-38 grant stack; **E3.M2** must invoke on craft/refine success ([NEO-42](../../plans/NEO-42-implementation-plan.md), [`NEO-42` manual QA](../../manual-qa/NEO-42.md)); [server README — Craft / refine hook (NEO-42)](../../../server/README.md#craft--refine-hook--skill-xp-neo-42). **Slice 3 still open:** [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), [NEO-42](../../plans/NEO-42-implementation-plan.md), [E2_M2](E2_M2_XpAwardAndLevelEngine.md); label **`E2.M2`** on NEO-37NEO-41, NEO-42NEO-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/` |
---

View File

@ -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 |

View File

@ -0,0 +1,32 @@
# Manual QA — NEO-40 (progression telemetry hook sites)
Reference: [implementation plan](../plans/NEO-40-implementation-plan.md), [NEO-38 manual QA](./NEO-38.md) (same server surface).
## Preconditions
- Same as NEO-38: run `NeonSprawl.Server` (`dotnet run` from `server/NeonSprawl.Server`; default `http://localhost:5253`).
- This story adds **comments only**; behavior matches NEO-38.
```bash
BASE=http://localhost:5253
ID=dev-local-1
```
## Verify API unchanged
1. **Grant** (same as NEO-38 happy path):
```bash
curl -sS -i -X POST "${BASE}/game/players/${ID}/skill-progression" \
-H "Content-Type: application/json" \
-d '{"schemaVersion":1,"skillId":"salvage","amount":10,"sourceKind":"activity"}'
```
Expect **HTTP 200**, `granted` **true**, `progression` and `levelUps` present as before.
2. **Level-up path** (optional, same as NEO-38 bonus): large grant on `intrusion` if XP crosses the curve threshold; expect `levelUps` non-empty when a boundary is crossed.
## Code review check
- Open `server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotApi.cs` and confirm **comment blocks** mark **`xp_grant`** (after successful `TryApplyXpDelta`) and **`level_up`** (inside `nextLevel > prevLevel`), referencing **E9.M1** / no production ingest.
- Confirm there are **no** new `ILogger` or metrics calls for these events.

View File

@ -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).

View File

@ -0,0 +1,59 @@
# Manual QA — NEO-42 (craft / refine → refine skill XP, `activity`)
Reference: [implementation plan](../plans/NEO-42-implementation-plan.md), [NEO-38](./NEO-38.md) (skill progression grant), [NEO-41](./NEO-41.md) (gather analogue).
## 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
```
## Automated coverage
`RefineActivitySkillXpGrantTests` exercises the **NEO-38** grant path with disk **`prototype_skills.json`** (`refine` allows **`activity`**) and a stub registry where **`refine`** disallows **`activity`**.
## Control: direct POST grant (same stack as E3.M2 hook)
Move once so **`GET …/skill-progression`** is not **404**:
```bash
curl -sS -i -X POST "${BASE}/game/players/${ID}/move" \
-H "Content-Type: application/json" \
-d '{"schemaVersion":1,"target":{"x":0.5,"y":0.9,"z":0.5}}'
```
Baseline:
```bash
curl -sS "${BASE}/game/players/${ID}/skill-progression"
```
Apply **`refine`** + **`activity`** (mirrors what **`RefineActivitySkillXpGrant`** does internally):
```bash
curl -sS -i -X POST "${BASE}/game/players/${ID}/skill-progression" \
-H "Content-Type: application/json" \
-d '{"schemaVersion":1,"skillId":"refine","amount":10,"sourceKind":"activity"}'
```
Expect **HTTP 200**, **`granted": true`**, and **`refine`** **`xp`** increased (repeat **POST** to see cumulative XP).
## Deny path (catalog guard)
With default catalog, **`trainer`** is allowed for **`refine`** but **`book_or_item`** is not:
```bash
curl -sS -i -X POST "${BASE}/game/players/${ID}/skill-progression" \
-H "Content-Type: application/json" \
-d '{"schemaVersion":1,"skillId":"refine","amount":5,"sourceKind":"book_or_item"}'
```
Expect **HTTP 200**, **`granted": false`**, **`reasonCode":"source_kind_not_allowed"`**.
## E3.M2 follow-up
When recipe/refine execution exists, confirm **one** server-side call to **`RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine`** on **success only**, then **`GET …/skill-progression`** shows expected **`refine`** **`xp`**.

View File

@ -0,0 +1,75 @@
# NEO-40 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-40 |
| **Title** | Skill XP / level-up telemetry hook sites |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-40/skill-xp-level-up-telemetry-hook-sites |
| **Module** | [E2.M2 — XpAwardAndLevelEngine](../decomposition/modules/E2_M2_XpAwardAndLevelEngine.md) |
| **Depends on** | [NEO-38](https://linear.app/neon-sprawl/issue/NEO-38) — **Done** on `main` (authoritative grant + level-up). |
| **Related** | [NEO-39](https://linear.app/neon-sprawl/issue/NEO-39) may land in parallel (curve content); hooks stay in the same API surface. |
## Kickoff clarifications
| Topic | Question / note | Resolution |
|--------|-----------------|------------|
| **Hook runtime behavior** | Linear: “comments / no-op or dev-only logging **as agreed at kickoff**.” User asked for recommendation. | **Agent:** Prefer **comments-only** hook markers (no `ILogger` / no runtime emission) to eliminate any risk of misconfigured production logging. **User:** confirmed **comments only**. |
## Goal, scope, and out-of-scope
**Goal:** Document and place **hook sites** in the server-authoritative grant + level-up path for progression telemetry aligned with Epic 2 Slice 2: future catalog events **`xp_grant`** and **`level_up`**, plus documented intent for **time-to-first-level-up** (no code path until milestone/session data exists). **No production ingest** until **E9.M1** catalog — satisfied by **comments only** (no logging calls).
**In scope (from Linear):**
- Hook locations on the **successful grant** path and **level-up detection** path; same story documents them in this plan and in **code comments** referencing future event names.
- Ensure **no** high-volume or accidental production logging for these events.
**Out of scope (from Linear + kickoff):**
- Telemetry **catalog** / ingest (**E9.M1**).
- **`ILogger`**, metrics exporters, or dev-only log lines (explicitly rejected at kickoff in favor of comments-only).
- Persisting “first level-up” timestamps or session analytics (**time-to-first-level-up** remains design intent in comments + this plan only).
## Acceptance criteria checklist
- [x] Hook locations in **grant** + **level-up** path; documented here **and** in code comments referencing future **`xp_grant`** / **`level_up`** (and **time-to-first-level-up** intent where appropriate).
- [x] **No** mistaken high-volume logging to production pipelines (no new log statements for these hooks).
## Technical approach
1. **Primary surface:** `SkillProgressionSnapshotApi` **`POST /game/players/{id}/skill-progression`** — the only authoritative apply path after NEO-38. After validation passes and **`xpStore.TryApplyXpDelta`** succeeds, the persisted totals reflect the grant; after **`levelCurve.LevelFromTotalXp`**, compare **`prevLevel`** / **`nextLevel`** for level-up.
2. **Hook site A — `xp_grant`:** Immediately after a **successful** `TryApplyXpDelta` (and before or alongside level derivation), add a **short comment block** naming the future **E9.M1** event **`xp_grant`**, listing payload fields that ingest will eventually need (e.g. `playerId`, `skillId`, `amount`, `sourceKind`, optional correlation ids when Slice 3 adds them). **No executable telemetry code.**
3. **Hook site B — `level_up`:** On the branch **`nextLevel > prevLevel`**, before or inside construction of **`SkillLevelUpJson`**, add a comment block naming **`level_up`** and fields (`playerId`, `skillId`, `previousLevel`, `newLevel`, deltas). **No executable telemetry code.**
4. **Time-to-first-level-up:** Add a **single** concise comment (near hook site B or in this plan only — prefer **one** code comment + bullet here) stating that this metric requires **first-seen** / milestone persistence or session context **not** present in NEO-40; future wiring will subscribe to **`level_up`** (or a dedicated milestone event) once catalog + storage exist.
5. **Persistence layer:** **Do not** add duplicate hook comments inside **`IPlayerSkillProgressionStore`** implementations unless the team wants a second anchor; **prefer a single authoritative layer** (HTTP grant handler) to avoid drift. If decomposition doc should mention hook sites, add a **one-line** cross-reference in `docs/decomposition/modules/E2_M2_XpAwardAndLevelEngine.md` only if that file already discusses telemetry — keep edits minimal.
## Files to add
| Path | Purpose |
|------|---------|
| `docs/manual-qa/NEO-40.md` | Manual QA checklist (API parity + comment review). |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotApi.cs` | Insert **telemetry hook site** comments on the **successful grant** path (`xp_grant`) and **level-up** branch (`level_up`); optional one-line **time-to-first-level-up** intent. |
| `docs/decomposition/modules/E2_M2_XpAwardAndLevelEngine.md` | **NEO-40 landed** line + backlog sentence updated (replaces “telemetry still backlog”). |
## Tests
| Path | Change |
|------|--------|
| **None required** | Comments-only markers have no runtime behavior; existing **`SkillProgressionGrantApiTests`** already cover grant + level-up paths. |
**Manual verification:** During implementation, add **`docs/manual-qa/NEO-40.md`** per repo convention: confirm POST still behaves as today; visually verify comment blocks cite **`xp_grant`** / **`level_up`** and mention **E9.M1** / no production ingest.
## Open questions / risks
**None.** Blocker NEO-38 is complete; risk of production logging is avoided by **comments-only** decision.

View File

@ -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).

View File

@ -0,0 +1,83 @@
# NEO-42 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-42 |
| **Title** | Craft / refine awards skill XP (sourceKind: activity) |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-42/craft-refine-awards-skill-xp-sourcekind-activity |
| **Labels** | E3.M2, E2.M2, server |
| **Blocked by (Linear)** | [NEO-38](https://linear.app/neon-sprawl/issue/NEO-38) (grant path — **done** on `main`); **E3.M2** recipe/refine execution (not present in repo at kickoff). |
## Kickoff clarifications
| Topic | Question / note | Resolution |
|--------|-----------------|------------|
| **Delivery shape** | User asked what to recommend until E3.M2 HTTP/recipe loop exists. | **Agent recommendation (accepted unless you object in chat):** add **`RefineSkillXpConstants`** + a thin **`RefineActivitySkillXpGrant`** (or equivalent) that delegates to **`SkillProgressionGrantOperations.TryApplyGrant`** — **no** new public craft HTTP in NEO-42. E3.M2 calls this helper from the real **`CraftResult`** success path when that module lands. |
| **Prototype XP amount** | User asked what to recommend. | **Agent recommendation:** **10** XP per successful craft/refine completion for the prototype, matching **[NEO-41](NEO-41-implementation-plan.md)** gather constant pattern; replace with recipe-driven amounts in a later issue when content exists. |
| **“Craft” vs “refine” skill id** | `content/skills/prototype_skills.json` defines **`refine`** (process) with **`activity`** in `allowedXpSourceKinds`; there is no separate **`craft`** skill row today. | **Prototype mapping:** both **recipe craft** and **refine** success paths call the helper with **`skillId: refine`** and **`sourceKind: activity`** so allowlist enforcement matches catalog. If a dedicated craft skill is added later, E3.M2 can call the same helper with a different `skillId` per recipe metadata. |
## Goal, scope, and out-of-scope
**Goal:** When **E3.M2** reports a **successful** craft or refine completion, award **refine** skill XP through the **NEO-38** grant stack (`TryApplyGrant` → registry allowlist → store → level curve) with **`sourceKind: activity`** wherever **`activity`** is listed on the target skills **`allowedXpSourceKinds`**.
**In scope (from Linear):**
- Wire (or **prepare the single call site** for) craft/refine completion → **`SkillProgressionGrantOperations.TryApplyGrant`** with **`activity`**.
- **Respect catalog allowlists** (no grant when `activity` is disallowed — mirror NEO-41 defensive tests).
**Out of scope:**
- Owning the full **E3.M2** recipe validation, inventory mutations, or new public craft routes (separate story/module).
- **Recipe-driven XP amounts** and multi-skill mapping from **`RecipeDef`** (follow-up once execution + content exist).
- **Telemetry** (`NEO-40`) — comment-only hook sites already noted on grant path.
## Acceptance criteria checklist
- [x] **Preparatory:** **`RefineActivitySkillXpGrant`** invokes the **NEO-38** path (**`TryApplyGrant`**, same stack as **`POST …/skill-progression`**). **Follow-up:** **E3.M2** success handler must call **`GrantOnSuccessfulCraftOrRefine`** (documented in **`server/README.md`** + manual QA); no craft route in repo yet.
- [x] Grant channels respect **skill catalog** `allowedXpSourceKinds` (stub registry test: **`refine`** without **`activity`** → no XP).
## Decisions (implementation)
- **`RefineActivityDeniedRegistryWebApplicationFactory`** added for allowlist-deny coverage (parallel to NEO-41 salvage-denied factory).
## Technical approach
1. **Constants** — Add `RefineSkillXpConstants` (parallel to `GatherSkillXpConstants`): **`refine`** skill id, **`activity`** source kind, **10** XP per completion (name clearly scoped to prototype craft/refine success).
2. **Shared grant helper** — Add a small static type (e.g. `RefineActivitySkillXpGrant`) whose sole job is to invoke **`SkillProgressionGrantOperations.TryApplyGrant`** with those constants. **Discard** the outcome on success paths intended to mirror gather (UI reads **`GET …/skill-progression`**); callers that need structured denies can call **`TryApplyGrant`** directly later.
3. **E3.M2 integration** — When **`CraftResult`** (or equivalent) success is finalized in server code, add **one call** to the helper with `playerId` + injected `ISkillDefinitionRegistry`, `IPlayerSkillProgressionStore`, `ISkillLevelCurve`. If E3.M2 is still absent when NEO-42 implements (1)(2), document the **exact line / type** in this plans **Decisions** after the merge that adds the success handler.
4. **Tests** — (a) Happy path: grant applies **refine** XP with **`activity`** when catalog allows it. (b) Deny path: stub registry where **`refine`** disallows **`activity`** → helper run leaves **0** XP (same spirit as `SalvageActivityDeniedRegistryWebApplicationFactory` in NEO-41). Use **in-memory** store + real or test JSON catalog + placeholder level curve; **no** dependency on craft HTTP.
5. **Manual QA** — If only (1)(2) ship before E3.M2: **`docs/manual-qa/NEO-42.md`** can note “verify via unit tests + temporary `POST …/skill-progression` control grant for `refine`/`activity`”; once E3.M2 exists, extend checklist to **Bruno** craft success + **GET** progression.
## Files to add
| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Skills/RefineSkillXpConstants.cs` | NEO-42 prototype constants: `refine`, `activity`, flat XP amount. |
| `server/NeonSprawl.Server/Game/Skills/RefineActivitySkillXpGrant.cs` | Single entry point calling `TryApplyGrant` for successful craft/refine (prototype). |
| `server/NeonSprawl.Server.Tests/Game/Skills/RefineActivitySkillXpGrantTests.cs` | AAA tests: grant when allowed; no XP when `activity` not in allowlist for `refine`. |
| `server/NeonSprawl.Server.Tests/Game/Skills/RefineActivityDeniedRegistryWebApplicationFactory.cs` | Test host with **`refine`** catalog row that omits **`activity`**. |
| `docs/manual-qa/NEO-42.md` | Curl checklist + E3.M2 follow-up note. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/README.md` | Document NEO-42 hook type and E3.M2 integration expectation. |
| **E3.M2 craft success handler** (path TBD) | Call **`RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine`** once on successful craft/refine — **not done in this PR** (module absent). |
## Tests
| Test file | What it covers |
|-----------|------------------|
| `RefineActivitySkillXpGrantTests.cs` | **Arrange / Act / Assert:** successful grant increases **`refine`** XP via shared path; registry without **`activity`** for **`refine`** yields **no** XP delta (allowlist respected). |
## Open questions / risks
- **Merge ordering:** If NEO-42 merges **before** E3.M2, acceptance checkbox “craft/refine completion” is only fully satisfied after a tiny follow-up commit adds the call in the new success handler — track in Linear or a child issue if needed.
- **XP balance:** Flat **10** is a placeholder; design may change before public playtests.

View File

@ -0,0 +1,47 @@
# Code review — NEO-40 (skill progression telemetry hook sites)
**Date:** 2026-05-10
**Scope:** Branch `NEO-40-skill-xp-level-up-telemetry-hook-sites` vs `origin/main` (commits through `effd234`).
**Base:** `origin/main`
**Follow-up:** Suggestions and nits below are **done** (strikethrough + **Done.**).
## Verdict
**Approve**
## Summary
The branch adds comment-only telemetry hook markers on the authoritative `POST …/skill-progression` path after a successful `TryApplyXpDelta`, plus a second marker on the level-up branch. Behavior and JSON contracts are unchanged; documentation (implementation plan, manual QA, E2.M2 module note, Bruno doc string, implementation alignment table E2.M2 row) matches the comments-only kickoff decision. Server tests pass.
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-40-implementation-plan.md` | **Matches** — hook sites after successful apply + on `nextLevel > prevLevel`; comments-only; no `ILogger`; time-to-first-level-up called out; single authoritative layer (HTTP handler). |
| `docs/decomposition/modules/E2_M2_XpAwardAndLevelEngine.md` | **Matches** — NEO-40 landed paragraph + backlog narrowed to NEO-41NEO-43. |
| `docs/decomposition/modules/module_dependency_register.md` | **N/A** for diff content (no table edit); E2.M2 row unchanged, still appropriate. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E2.M2 row includes **NEO-40 landed** (hook sites + plan + manual QA links); follow-on line removed. |
| `docs/decomposition/modules/contracts.md` / `client_server_authority.md` / `data_and_ops_policy.md` | **N/A** — no contract or authority change; telemetry ingest remains E9.M1 backlog, consistent with comments. |
| `docs/manual-qa/NEO-40.md` | **Matches** — API parity + comment spot-check aligns with plan. |
~~**Register / tracking table:** After merge, update the E2.M2 cell in `documentation_and_implementation_alignment.md` to a “**NEO-40 landed:** …” bullet (and trim the old “Follow-on: NEO-40” line) so it matches `E2_M2_XpAwardAndLevelEngine.md`.~~ **Done.** (E2.M2 snapshot + Plans column updated in-repo.)
## Blocking issues
(none)
## Suggestions
1. ~~**`documentation_and_implementation_alignment.md` (E2.M2 row):** Replace the remaining “Follow-on: NEO-40” wording with a landed summary (hook sites in `SkillProgressionSnapshotApi`, link to `NEO-40` plan + manual QA), mirroring the style used for NEO-37NEO-39 in the same cell. This satisfies the module docs instruction to keep that table in sync.~~ **Done.**
## Nits
- ~~**Nit:** The implementation plans technical approach for `level_up` mentions “deltas” in the payload sketch; the in-code comment lists `playerId`, `skillId`, `previousLevel`, `newLevel` but not XP/total deltas. Optional one-word addition (`prevXp`/`newXp` or total XP) if you want comment parity with the plan.~~ **Done.** (`SkillProgressionSnapshotApi` `level_up` hook comment lists `prevXp`, `newXp`.)
## Verification
- `dotnet test NeonSprawl.sln`**Passed** (133 tests) on review machine.
- Optional: follow `docs/manual-qa/NEO-40.md` curl steps against a running server.

View File

@ -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.

View File

@ -0,0 +1,66 @@
# Code review: NEO-42 (craft/refine → refine skill XP, prep helper)
**Date:** 2026-05-10
**Scope:** Branch `NEO-42-craft-refine-skill-xp-activity` (commits `20a141b`, `15bf598`) vs `origin/main`. Linear issue NEO-42. Diff: 7 files, +377 / -0.
**Base:** `origin/main`
---
## Verdict
**Approve with nits** — Plan-aligned preparatory slice: adds `RefineActivitySkillXpGrant` + `RefineSkillXpConstants` (parallel to NEO-41 gather constants), defensive deny-path test factory, and docs (server README, manual QA, implementation plan). No live E3.M2 call site exists yet (module absent), which is explicitly acknowledged in the plan, README, and manual QA. All `dotnet test NeonSprawl.sln` tests pass (**139** total, **3** new under `RefineActivitySkillXpGrantTests`). Two doc-alignment suggestions and a few nits below.
## Summary
This PR adds a thin static helper `RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine(playerId, registry, xpStore, levelCurve)` that delegates to `SkillProgressionGrantOperations.TryApplyGrant` with `refine` + `activity` + **10** XP, plus a constants holder. There is no recipe/craft execution path in the repo to call it from, so the helper is shipped as a known callsite for a future E3.M2 merge. Three xUnit tests use the existing AAA pattern: a happy-path that asserts the in-memory store gained 10 XP under `refine`, a happy-path that round-trips through `GET …/skill-progression`, and a deny path using a new `RefineActivityDeniedRegistryWebApplicationFactory` (refine catalog row without `activity` in `allowedXpSourceKinds`). Server README and `docs/manual-qa/NEO-42.md` document the contract and verification flow via the existing `POST …/skill-progression` control endpoint. **Risk:** Low; pure additive surface, server authority unchanged, helper is unreferenced until E3.M2 wires it.
## Documentation checked
- `docs/plans/NEO-42-implementation-plan.md`**matches** (kickoff clarifications honored, both acceptance checkboxes correspond to landed artifacts, files added/modified list matches diff, helper-then-E3.M2-call shape preserved).
- `docs/decomposition/modules/module_dependency_register.md`**matches** for E3.M2 row (kept **Planned**, defensible since no `CraftRequest` / `RecipeDef` / `CraftResult` implemented); **matches** for E2.M2 row (already **In Progress**).
- `docs/decomposition/modules/E2_M2_XpAwardAndLevelEngine.md`**matches** (post-follow-up: **NEO-42 landed (prep)** paragraph + backlog text updated). ~~**partially matches**: the "Implementation snapshot" still asserts "Still backlog within this module: Slice 3 NEO-42, NEO-43"; NEO-42 has landed as a preparatory helper and should get a parallel "NEO-42 landed (prep)" paragraph the way NEO-41 did. See Suggestion 1.~~ **Done.**
- `docs/decomposition/modules/documentation_and_implementation_alignment.md`**matches** (E2.M2 tracking row extended with NEO-42 prep + plan link). ~~**partially matches**: the E2.M2 tracking-table row enumerates NEO-37 → NEO-41 with landed notes but does not yet add a NEO-42 fragment. See Suggestion 1.~~ **Done.**
- `docs/decomposition/modules/E3_M2_RefinementAndRecipeExecution.md`**matches** (**Implementation snapshot** prep paragraph added). ~~**partially matches**: doc still has no Implementation snapshot; adding a single bullet noting the preparatory helper + plan would help E3.M2 readers find the wiring point when they later implement `CraftResult`. See Suggestion 2.~~ **Done.**
- `docs/manual-qa/NEO-42.md`**matches** (`book_or_item` is correctly outside `refine.allowedXpSourceKinds = [activity, mission_reward, trainer]` in `content/skills/prototype_skills.json`; control POST + GET steps reflect the real grant path).
- `server/README.md` (diff) — **matches** plan (Craft / refine hook → skill XP (NEO-42) section).
- Cross-cutting `client_server_authority.md` / `contracts.md`**N/A** (no new public surface; helper lives behind future server caller).
**Register / tracking:** ~~Implementation tracking table E2.M2 row needs a NEO-42 paragraph; register Status fields require no change.~~ E2.M2 row updated; register Status fields unchanged. **Done.**
## Blocking issues
_None._
## Suggestions
1. ~~**Update E2.M2 implementation-snapshot docs to reflect NEO-42 landing.** Two locations:~~ **Done.**
- ~~`docs/decomposition/modules/E2_M2_XpAwardAndLevelEngine.md` — replace / supplement the line "Still backlog within this module: Slice 3 NEO-42, NEO-43" with a "**NEO-42 landed (prep):** `RefineActivitySkillXpGrant` + `RefineSkillXpConstants` + deny-path factory; awaiting E3.M2 success handler to invoke" paragraph, mirroring the NEO-41 paragraph.~~
- ~~`docs/decomposition/modules/documentation_and_implementation_alignment.md` — extend the E2.M2 tracking row's snapshot column with the equivalent NEO-42 sentence and add the plan link. This keeps the same pattern NEO-41 set in `0a141ba`-style follow-ups.~~
2. ~~**Add an Implementation snapshot to `E3_M2_RefinementAndRecipeExecution.md`.** Status can remain **Planned** in the register (no `CraftRequest` / `RecipeDef` / `CraftResult` yet), but a one-paragraph snapshot under Summary noting "preparatory helper `RefineActivitySkillXpGrant` ready for the future success handler; plan: NEO-42" would help the next implementer find the existing wiring point. Mirrors how NEO-25 / NEO-26 noted partial work without prematurely flipping module status.~~ **Done.**
## Nits
- ~~**Nit (test 3 assertion strength):** `RefineActivitySkillXpGrantTests.GrantOnSuccessfulCraftOrRefine_WhenRefineDisallowsActivity_ShouldLeaveRefineXpZero` currently does:~~ **Done.** (`Assert.False(totals.ContainsKey("refine"));`)
```csharp
var totals = xpStore.GetXpTotals("dev-local-1");
_ = totals.TryGetValue("refine", out var refineXp);
Assert.Equal(0, refineXp);
```
~~Because `TryApplyGrant` returns before `TryApplyXpDelta` on a deny, the `refine` key should never exist in the store. `Assert.False(totals.ContainsKey("refine"));` (or `Assert.False(totals.TryGetValue("refine", out _));`) expresses that semantic exactly and would catch a regression where the store unexpectedly created a 0-valued entry. Current form passes both states.~~
- ~~**Nit (helper symmetry with gather call site):** `RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine` calls `playerId.Trim()` before delegating, but the NEO-41 inline call in `InteractionApi.cs` passes the already-normalized `playerKey` straight through. Harmless either way (store re-normalizes), but picking one direction across both gather and refine hooks keeps callers consistent for whoever wires E3.M2 next.~~ **Done.** (Trim removed; `<remarks>` documents normalized `playerId`.)
- **Nit (factory duplication):** `RefineActivityDeniedRegistryWebApplicationFactory` is ~90% identical to `SalvageActivityDeniedRegistryWebApplicationFactory` (only the stub-registry composition and namespace differ). Optional follow-up: extract a small `InMemoryFactoryWithSkillRegistryOverride<TRegistry>` base in `NeonSprawl.Server.Tests` to keep future deny-path factories DRY (combat XP, intrusion, etc.). Not worth blocking on for two factories.
- ~~**Nit (acceptance-criteria phrasing):** Plan checkbox "Craft/refine completion **shall** grant skill XP via NEO-38 path" is ticked, but no craft/refine completion path exists yet. The plan acknowledges this in the same bullet ("E3.M2 must call `GrantOnSuccessfulCraftOrRefine` on success … no craft route in repo yet"). Consider rewording to "**Preparatory** helper invokes NEO-38 grant path; E3.M2 success handler wiring tracked as follow-up" so future readers don't misread the box as a fully implemented end-to-end behavior. Optional.~~ **Done.**
## Verification
- `dotnet test NeonSprawl.sln`**passed** (**139** tests, including the 3 new `RefineActivitySkillXpGrantTests`).
- Manual: follow `docs/manual-qa/NEO-42.md``POST …/move``POST …/skill-progression` with `{skillId: "refine", sourceKind: "activity", amount: 10}` → expect `granted: true`, then re-`POST` with `sourceKind: "book_or_item"` → expect `granted: false`, `reasonCode: "source_kind_not_allowed"`.
- After E3.M2 lands: confirm a single call to `RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine` on the craft-success branch (not on deny / cost-failure) and re-verify via `GET …/skill-progression`.

View File

@ -7,7 +7,7 @@ using Xunit;
namespace NeonSprawl.Server.Tests.Game.Interaction;
/// <summary>Integration tests for <see cref="InteractionApi"/> (NS-18).</summary>
/// <summary>Integration tests for <see cref="InteractionApi"/> (NS-18). NEO-41: <see cref="InteractionResourceNodeGatherXpTests"/> covers gather XP on <c>resource_node</c> kind.</summary>
public class InteractionApiTests
{
[Fact]

View File

@ -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;
/// <summary>NEO-41: successful <c>resource_node</c> interact grants <c>salvage</c> XP via shared NEO-38 grant path.</summary>
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<SkillProgressionSnapshotResponse>();
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<InteractionResponse>();
Assert.NotNull(envelope);
Assert.True(envelope!.Allowed);
Assert.Equal(HttpStatusCode.OK, snapshot.StatusCode);
var body = await snapshot.Content.ReadFromJsonAsync<SkillProgressionSnapshotResponse>();
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<InteractionResponse>();
Assert.NotNull(envelope);
Assert.True(envelope!.Allowed);
Assert.Equal(HttpStatusCode.OK, snapshot.StatusCode);
var body = await snapshot.Content.ReadFromJsonAsync<SkillProgressionSnapshotResponse>();
Assert.NotNull(body);
var salvage = body!.Skills!.Single(static s => s.Id == "salvage");
Assert.Equal(0, salvage.Xp);
}
}

View File

@ -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;
/// <summary>
/// Same in-memory overrides as <see cref="InMemoryWebApplicationFactory"/>, plus a stub <see cref="ISkillDefinitionRegistry"/>
/// where <c>salvage</c> does not allow <c>activity</c> — for NEO-41 defensive tests without mutating disk catalog.
/// </summary>
public sealed class SalvageActivityDeniedRegistryWebApplicationFactory : WebApplicationFactory<Program>
{
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<TimeProvider>(fakeTime);
services.AddSingleton<IPositionStateStore, InMemoryPositionStateStore>();
services.AddSingleton<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>();
services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>();
services.AddSingleton<IPlayerAbilityCooldownStore, InMemoryPlayerAbilityCooldownStore>();
services.AddSingleton<ISkillDefinitionRegistry, SalvageActivityDeniedSkillRegistry>();
});
}
}
/// <summary>Prototype trio ids; <c>salvage</c> excludes <c>activity</c> so gather interact grant path denies without throwing.</summary>
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<SkillDefRow> 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<SkillDefRow> GetDefinitionsInIdOrder() => Ordered;
}

View File

@ -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.Skills;
/// <summary>
/// Same in-memory overrides as <see cref="InMemoryWebApplicationFactory"/>, plus a stub <see cref="ISkillDefinitionRegistry"/>
/// where <c>refine</c> does not allow <c>activity</c> — NEO-42 defensive tests without mutating disk catalog.
/// </summary>
public sealed class RefineActivityDeniedRegistryWebApplicationFactory : WebApplicationFactory<Program>
{
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<TimeProvider>(fakeTime);
services.AddSingleton<IPositionStateStore, InMemoryPositionStateStore>();
services.AddSingleton<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>();
services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>();
services.AddSingleton<IPlayerAbilityCooldownStore, InMemoryPlayerAbilityCooldownStore>();
services.AddSingleton<ISkillDefinitionRegistry, RefineActivityDeniedSkillRegistry>();
});
}
}
/// <summary>Prototype trio ids; <c>refine</c> excludes <c>activity</c> so craft/refine grant path denies without throwing.</summary>
internal sealed class RefineActivityDeniedSkillRegistry : ISkillDefinitionRegistry
{
private static readonly SkillDefRow Salvage = new(
"salvage",
"gather",
"Salvage",
new[] { "activity", "mission_reward" });
private static readonly SkillDefRow RefineNoActivity = new(
"refine",
"process",
"Refine",
new[] { "mission_reward", "trainer" });
private static readonly SkillDefRow Intrusion = new(
"intrusion",
"tech",
"Intrusion",
new[] { "activity", "mission_reward", "book_or_item" });
private static readonly IReadOnlyList<SkillDefRow> Ordered =
[
Intrusion,
RefineNoActivity,
Salvage,
];
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<SkillDefRow> GetDefinitionsInIdOrder() => Ordered;
}

View File

@ -0,0 +1,81 @@
using System.Linq;
using System.Net;
using System.Net.Http.Json;
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Skills;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Skills;
/// <summary>NEO-42: craft/refine completion hook → <c>refine</c> XP via shared NEO-38 grant path (<c>sourceKind: activity</c>).</summary>
public sealed class RefineActivitySkillXpGrantTests
{
[Fact]
public async Task GrantOnSuccessfulCraftOrRefine_WhenCatalogAllowsActivity_ShouldIncreaseRefineXp()
{
// Arrange — dev player is seeded on the in-memory progression store (NEO-38).
await using var factory = new InMemoryWebApplicationFactory();
_ = factory.CreateClient();
using var scope = factory.Services.CreateScope();
var registry = scope.ServiceProvider.GetRequiredService<ISkillDefinitionRegistry>();
var xpStore = scope.ServiceProvider.GetRequiredService<IPlayerSkillProgressionStore>();
var levelCurve = scope.ServiceProvider.GetRequiredService<ISkillLevelCurve>();
// Act
RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine("dev-local-1", registry, xpStore, levelCurve);
// Assert
var totals = xpStore.GetXpTotals("dev-local-1");
Assert.True(totals.TryGetValue("refine", out var xp));
Assert.Equal(RefineSkillXpConstants.ActivityXpPerCraftRefineCompletion, xp);
}
[Fact]
public async Task GrantOnSuccessfulCraftOrRefine_WhenCatalogAllowsActivity_ShouldMatchSkillProgressionGet()
{
// 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);
using var scope = factory.Services.CreateScope();
var registry = scope.ServiceProvider.GetRequiredService<ISkillDefinitionRegistry>();
var xpStore = scope.ServiceProvider.GetRequiredService<IPlayerSkillProgressionStore>();
var levelCurve = scope.ServiceProvider.GetRequiredService<ISkillLevelCurve>();
// Act
RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine("dev-local-1", registry, xpStore, levelCurve);
var snapshot = await client.GetAsync("/game/players/dev-local-1/skill-progression");
// Assert
Assert.Equal(HttpStatusCode.OK, snapshot.StatusCode);
var body = await snapshot.Content.ReadFromJsonAsync<SkillProgressionSnapshotResponse>();
Assert.NotNull(body);
var refine = body!.Skills!.Single(static s => s.Id == "refine");
Assert.Equal(RefineSkillXpConstants.ActivityXpPerCraftRefineCompletion, refine.Xp);
}
[Fact]
public async Task GrantOnSuccessfulCraftOrRefine_WhenRefineDisallowsActivity_ShouldLeaveRefineXpZero()
{
// Arrange
await using var factory = new RefineActivityDeniedRegistryWebApplicationFactory();
_ = factory.CreateClient();
using var scope = factory.Services.CreateScope();
var registry = scope.ServiceProvider.GetRequiredService<ISkillDefinitionRegistry>();
var xpStore = scope.ServiceProvider.GetRequiredService<IPlayerSkillProgressionStore>();
var levelCurve = scope.ServiceProvider.GetRequiredService<ISkillLevelCurve>();
// Act
RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine("dev-local-1", registry, xpStore, levelCurve);
// Assert — deny returns before TryApplyXpDelta; refine row must not appear in stored XP map.
var totals = xpStore.GetXpTotals("dev-local-1");
Assert.False(totals.ContainsKey("refine"));
}
}

View File

@ -6,6 +6,7 @@ using Xunit;
namespace NeonSprawl.Server.Tests.Game.Skills;
/// <summary>Regression tests for <c>POST …/skill-progression</c> (NEO-38). NEO-40 adds comment-only hook sites; JSON contract unchanged. E2.M2 implementation-alignment row documents NEO-40 landed.</summary>
public sealed class SkillProgressionGrantApiTests
{
private static SkillProgressionGrantRequest Grant(

View File

@ -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
{

View File

@ -12,11 +12,14 @@ public static class PrototypeInteractableRegistry
/// <summary>Second stub row for multi-entity projection (NEO-25).</summary>
public const string PrototypeResourceNodeAlphaId = "prototype_resource_node_alpha";
/// <summary><see cref="PrototypeInteractableEntry.Kind"/> for gather-anchor nodes (NEO-41).</summary>
public const string ResourceNodeKind = "resource_node";
/// <summary>Source-of-truth anchor and radius; client discovers via <c>GET /game/world/interactables</c> (NEO-25).</summary>
public static readonly PrototypeInteractableEntry PrototypeTerminal = new(0, 0.5, 0, 3.0, "terminal");
/// <summary>+X / Z open floor (avoids overlap with scene <c>Obstacle</c> at ~(6,1,5)); same radius as terminal.</summary>
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<string, PrototypeInteractableEntry> ById = new(StringComparer.Ordinal)
{

View File

@ -0,0 +1,11 @@
namespace NeonSprawl.Server.Game.Skills;
/// <summary>NEO-41: prototype gather (resource-node interact) → <c>salvage</c> skill XP via <c>sourceKind: activity</c>.</summary>
public static class GatherSkillXpConstants
{
public const int ActivityXpPerResourceNodeGather = 10;
public const string SalvageSkillId = "salvage";
public const string ActivitySourceKind = "activity";
}

View File

@ -0,0 +1,27 @@
namespace NeonSprawl.Server.Game.Skills;
/// <summary>
/// NEO-42: E3.M2 craft/refine success should call <see cref="GrantOnSuccessfulCraftOrRefine"/> so XP uses the same path as
/// <c>POST …/skill-progression</c>. Outcome is ignored on success paths (mirror gather interact); use
/// <see cref="SkillProgressionGrantOperations.TryApplyGrant"/> directly when callers need deny envelopes.
/// </summary>
public static class RefineActivitySkillXpGrant
{
/// <summary>Applies one <c>refine</c> + <c>activity</c> grant if the catalog allowlist permits it.</summary>
/// <remarks>Pass a normalized <paramref name="playerId"/> (e.g. route-trimmed), matching <c>InteractionApi</c> gather hook callers.</remarks>
public static void GrantOnSuccessfulCraftOrRefine(
string playerId,
ISkillDefinitionRegistry registry,
IPlayerSkillProgressionStore xpStore,
ISkillLevelCurve levelCurve)
{
_ = SkillProgressionGrantOperations.TryApplyGrant(
playerId,
RefineSkillXpConstants.RefineSkillId,
RefineSkillXpConstants.ActivityXpPerCraftRefineCompletion,
RefineSkillXpConstants.ActivitySourceKind,
registry,
xpStore,
levelCurve);
}
}

View File

@ -0,0 +1,11 @@
namespace NeonSprawl.Server.Game.Skills;
/// <summary>NEO-42: prototype craft/refine completion → <c>refine</c> skill XP via <c>sourceKind: activity</c>.</summary>
public static class RefineSkillXpConstants
{
public const int ActivityXpPerCraftRefineCompletion = 10;
public const string RefineSkillId = "refine";
public const string ActivitySourceKind = "activity";
}

View File

@ -0,0 +1,19 @@
namespace NeonSprawl.Server.Game.Skills;
/// <summary>Result of <see cref="SkillProgressionGrantOperations.TryApplyGrant"/> (NEO-38 / NEO-41 shared path).</summary>
public enum SkillProgressionGrantApplyKind
{
/// <summary>Structured deny — <see cref="SkillProgressionGrantApplyOutcome.Response"/> is populated.</summary>
Denied,
/// <summary><see cref="IPlayerSkillProgressionStore.TryApplyXpDelta"/> returned false (e.g. player missing from progression store).</summary>
ProgressionStoreMissing,
/// <summary>XP applied — <see cref="SkillProgressionGrantApplyOutcome.Response"/> is success envelope.</summary>
Granted,
}
/// <param name="Response">Deny or success JSON envelope; null only when <see cref="Kind"/> is <see cref="SkillProgressionGrantApplyKind.ProgressionStoreMissing"/>.</param>
public readonly record struct SkillProgressionGrantApplyOutcome(
SkillProgressionGrantApplyKind Kind,
SkillProgressionGrantResponse? Response);

View File

@ -0,0 +1,93 @@
namespace NeonSprawl.Server.Game.Skills;
/// <summary>Shared NEO-38 skill XP grant apply (used by HTTP POST and NEO-41 gather interact hook).</summary>
public static class SkillProgressionGrantOperations
{
/// <summary>
/// Applies one grant if validation passes and <see cref="IPlayerSkillProgressionStore.TryApplyXpDelta"/> succeeds.
/// Caller must ensure <paramref name="playerId"/> is already authorized for progression (e.g. known to position state).
/// </summary>
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<SkillLevelUpJson>();
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,
});
}
}

View File

@ -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,54 +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();
}
var prevLevel = levelCurve.LevelFromTotalXp(prevXp);
var nextLevel = levelCurve.LevelFromTotalXp(newXp);
var levelUps = new List<SkillLevelUpJson>();
if (nextLevel > prevLevel)
{
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;

View File

@ -174,6 +174,14 @@ 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 interactables **`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`.
### Craft / refine hook → skill XP (NEO-42)
**E3.M2** recipe/refine success paths should call **`RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine`**, which applies **10** **`refine`** skill XP with **`sourceKind: activity`** through **`SkillProgressionGrantOperations.TryApplyGrant`** (same validation, **`allowedXpSourceKinds`**, and persistence as **[NEO-38](#skill-progression-grant-neo-38)**). There is no separate craft HTTP in NEO-42; verify behavior with **`POST …/skill-progression`** for **`refine`**/**`activity`** until the craft module wires the helper. Plan: [NEO-42 implementation plan](../../docs/plans/NEO-42-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-42.md`](../../docs/manual-qa/NEO-42.md).
Contract details and PR blurb: [NEO-9 implementation plan](../../docs/plans/NEO-9-implementation-plan.md).
## Interactable descriptors (NEO-25)