diff --git a/bruno/neon-sprawl-server/interaction/Post interact prototype resource node gather.bru b/bruno/neon-sprawl-server/interaction/Post interact prototype resource node gather.bru new file mode 100644 index 0000000..df33408 --- /dev/null +++ b/bruno/neon-sprawl-server/interaction/Post interact prototype resource node gather.bru @@ -0,0 +1,35 @@ +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. Run `Post move near prototype resource node` first. Optional: GET skill-progression to read `salvage.xp`. +} + +post { + url: {{baseUrl}}/game/players/{{playerId}}/interact + body: json + auth: none +} + +headers { + content-type: application/json +} + +body:json { + { + "schemaVersion": 1, + "interactableId": "prototype_resource_node_alpha" + } +} + +tests { + test("interact allowed", function () { + expect(res.getStatus()).to.equal(200); + const body = res.getBody(); + expect(body.allowed).to.equal(true); + expect(body.interactableId).to.equal("prototype_resource_node_alpha"); + }); +} diff --git a/bruno/neon-sprawl-server/position/Post move near prototype resource node.bru b/bruno/neon-sprawl-server/position/Post move near prototype resource node.bru new file mode 100644 index 0000000..cf0d341 --- /dev/null +++ b/bruno/neon-sprawl-server/position/Post move near prototype resource node.bru @@ -0,0 +1,30 @@ +meta { + name: POST move - near prototype resource node (NEO-41 gather setup) + type: http + seq: 10 +} + +docs { + NEO-41: move dev player within horizontal reach of `prototype_resource_node_alpha` (anchor 12, -6, radius 3). +} + +post { + url: {{baseUrl}}/game/players/{{playerId}}/move + body: json + auth: none +} + +headers { + content-type: application/json +} + +body:json { + { + "schemaVersion": 1, + "target": { + "x": 10, + "y": 0.9, + "z": -6 + } + } +} diff --git a/docs/decomposition/modules/E2_M2_XpAwardAndLevelEngine.md b/docs/decomposition/modules/E2_M2_XpAwardAndLevelEngine.md index 781f28d..0f5bb7a 100644 --- a/docs/decomposition/modules/E2_M2_XpAwardAndLevelEngine.md +++ b/docs/decomposition/modules/E2_M2_XpAwardAndLevelEngine.md @@ -21,7 +21,9 @@ **NEO-40 landed:** comment-only **telemetry hook sites** in `SkillProgressionSnapshotApi` (`POST …/skill-progression`) for future catalog events **`xp_grant`** and **`level_up`** ([implementation plan](../../plans/NEO-40-implementation-plan.md)); manual QA **[`NEO-40.md`](../../manual-qa/NEO-40.md)**. -**Still backlog within this module:** callers in Slice 3 (**NEO-41**–**NEO-43**). Keep this paragraph and [documentation tracking](documentation_and_implementation_alignment.md) in sync as Slice 2 stories merge. +**NEO-41 landed:** prototype **gather → skill XP** — successful **`POST …/interact`** when interactable **`kind`** is **`resource_node`** grants **`salvage`** XP (**`sourceKind: activity`**, **10** XP per interact) via shared **`SkillProgressionGrantOperations`** ([implementation plan](../../plans/NEO-41-implementation-plan.md)); manual QA **[`NEO-41.md`](../../manual-qa/NEO-41.md)**; **[server README — Interaction (gather)](../../../server/README.md#interaction-neo-9)**. + +**Still backlog within this module:** Slice 3 **NEO-42**, **NEO-43** (and **NEO-44** gig path). Keep this paragraph and [documentation tracking](documentation_and_implementation_alignment.md) in sync as Slice 2 stories merge. ## Purpose diff --git a/docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md b/docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md index 77e1e4a..9666de9 100644 --- a/docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md +++ b/docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md @@ -39,6 +39,8 @@ World resource nodes, gather interactions, and yield outputs feeding inventory ( Epic 3 **Slice 2** — 4–6 node types; `resource_gathered`, `gather_node_depleted`. +**Prototype implementation anchor (NEO-41):** until **`GatherResult`** / yield tables exist, the repo treats a **successful** **`POST …/interact`** on an entry with **`kind: resource_node`** (`prototype_resource_node_alpha`) as **gather completion** for **skill XP** only — **`salvage`** + **`activity`** via [`SkillProgressionGrantOperations`](../../../server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantOperations.cs) (same rules as NEO-38). Migrate this hook when a dedicated gather pipeline lands. + ## Source anchors - Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 3. diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index 4be96e2..3fe8aef 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -51,7 +51,8 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not | E1.M3 | In Progress | **NEO-23 landed:** JSON v1 targeting read + select (`GET`/`POST …/target`, `Game/Targeting/`, [NEO-23](../../plans/NEO-23-implementation-plan.md)) — `PlayerTargetStateResponse` / soft lock / `reasonCode` denials; wire name differs from illustrative **`TargetState`** in module doc (called out in plan + README). **NEO-24 landed:** client tab-target + lock HUD synced to server ([NEO-24](../../plans/NEO-24-implementation-plan.md)) — `TargetSelectionClient` ([`client/scripts/target_selection_client.gd`](../../../client/scripts/target_selection_client.gd)), Tab / Esc bindings, `TargetLockLabel` HUD, hybrid movement-triggered refresh via new `PositionAuthorityClient.authoritative_ack` signal (fires on every server-confirmed position, boot + `move-stream` 200) with a 250 ms cooldown; manual QA in [`docs/manual-qa/NEO-24.md`](../../manual-qa/NEO-24.md). **NEO-25 landed:** versioned **`GET /game/world/interactables`** ([NEO-25](../../plans/NEO-25-implementation-plan.md)) — `InteractablesListResponse` / `InteractablesWorldApi` in `server/NeonSprawl.Server/Game/Interaction/`; Godot `interactables_catalog_client.gd` + `interactable_world_builder.gd` (fetch-driven props + glow); [server README — Interactable descriptors (NEO-25)](../../../server/README.md#interactable-descriptors-neo-25). **NEO-26 landed:** prototype **`SelectionEvent`** — **`TargetSelectionClient.selection_event`** ([NEO-26](../../plans/NEO-26-implementation-plan.md)) when **`lockedTargetId`** changes; optional **`log_selection_events`**. **NEO-27 landed:** Slice 3 telemetry hook sites documented — `selection_event` maps to product `target_changed` in `target_selection_client.gd`; server lock transition hook comments in `InMemoryPlayerTargetLockStore`; reserved `ability_cast_requested` / `ability_cast_denied` comments in `client/scripts/main.gd` (all TODO(E9.M1)); see [NEO-27](../../plans/NEO-27-implementation-plan.md) and [`docs/manual-qa/NEO-27.md`](../../manual-qa/NEO-27.md). **Follow-on / still open:** richer **`InteractableDescriptor`** consumers beyond list projection + existing `POST …/interact`; production telemetry ingest/catalog after E9.M1. **Precursor (E1.M1):** [NEO-9](../../plans/NEO-9-implementation-plan.md) `POST …/interact`. | Linear [NEO-24](https://linear.app/neon-sprawl/issue/NEO-24)–[NEO-27](https://linear.app/neon-sprawl/issue/NEO-27); [E1_M3_InteractionAndTargetingLayer.md](E1_M3_InteractionAndTargetingLayer.md); [E1M3 prototype backlog](../../plans/E1M3-prototype-backlog.md); [server README — Targeting](../../../server/README.md#targeting-neo-23), [Interactable descriptors (NEO-25)](../../../server/README.md#interactable-descriptors-neo-25), [Interaction](../../../server/README.md#interaction-neo-9) | | E1.M4 | In Progress | **NEO-29 landed:** prototype `HotbarLoadout` v1 server contract (`GET`/`POST /game/players/{id}/hotbar-loadout`) with stable deny reason codes, per-player scope, and persistence policy matching NEO-8/NS-17 (Postgres when configured, in-memory fallback otherwise). Client boot hydration is wired via `hotbar_loadout_client.gd` + `hotbar_state.gd` from `main.gd`; manual QA checklist created. **NEO-31 landed:** prototype **`POST /game/players/{id}/ability-cast`**, digit-key cast wiring from `main.gd`, `ability_cast_client.gd`, and `hotbar_cast_slot_resolver.gd` (target id from `lockedTargetId` in cached target state); GdUnit covers resolver + HTTP client; manual QA [NEO-31](../../manual-qa/NEO-31.md). **NEO-28 landed:** cast POST validates **lock + registry + range** (`invalid_target`, `out_of_range`); **`AbilityCastClient.cast_result_received`** + **`CastFeedbackLabel`** HUD line; manual QA [NEO-28](../../manual-qa/NEO-28.md). **NEO-30 landed:** Slice 3 cast funnel telemetry **hook-site comments** in [`AbilityCastApi.cs`](../../../server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs) (`ability_cast_requested` on authoritative accept, `ability_cast_denied` + non-empty `reasonCode` on each JSON deny branch); client dev hooks unchanged; manual QA [NEO-30](../../manual-qa/NEO-30.md); plan [NEO-30](../../plans/NEO-30-implementation-plan.md). **NEO-32 landed:** `GET /game/players/{id}/cooldown-snapshot` + `on_cooldown` cast deny + prototype global cooldown commit; [`cooldown_snapshot_client.gd`](../../../client/scripts/cooldown_snapshot_client.gd), [`cooldown_state.gd`](../../../client/scripts/cooldown_state.gd), **`CooldownSlotsLabel`** in [`main.gd`](../../../client/scripts/main.gd); manual QA [NEO-32](../../manual-qa/NEO-32.md); plan [NEO-32](../../plans/NEO-32-implementation-plan.md). | [NEO-29](../../plans/NEO-29-implementation-plan.md), [NEO-31](../../plans/NEO-31-implementation-plan.md), [NEO-28](../../plans/NEO-28-implementation-plan.md), [NEO-30](../../plans/NEO-30-implementation-plan.md), [NEO-32](../../plans/NEO-32-implementation-plan.md); [E1_M4_AbilityInputScaffold.md](E1_M4_AbilityInputScaffold.md); [E1M4 prototype backlog](../../plans/E1M4-prototype-backlog.md); `server/NeonSprawl.Server/Game/AbilityInput/`; [`client/scripts/hotbar_loadout_client.gd`](../../../client/scripts/hotbar_loadout_client.gd), [`client/scripts/hotbar_state.gd`](../../../client/scripts/hotbar_state.gd), [`client/scripts/ability_cast_client.gd`](../../../client/scripts/ability_cast_client.gd), [`client/scripts/hotbar_cast_slot_resolver.gd`](../../../client/scripts/hotbar_cast_slot_resolver.gd), [`client/scripts/cooldown_snapshot_client.gd`](../../../client/scripts/cooldown_snapshot_client.gd), [`client/scripts/cooldown_state.gd`](../../../client/scripts/cooldown_state.gd); [server README — Hotbar loadout](../../../server/README.md#hotbar-loadout-neo-29), [Ability cast (NEO-31)](../../../server/README.md#ability-cast-neo-31), [Cooldown snapshot (NEO-32)](../../../server/README.md#cooldown-snapshot-neo-32) | | E2.M1 | In Progress | **NEO-33 landed:** frozen prototype trio **`salvage`** / **`refine`** / **`intrusion`** in [`content/skills/prototype_skills.json`](../../../content/skills/prototype_skills.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) enforce schema, duplicate `id`, exact trio ids, and category coverage; designer note on **`allowedXpSourceKinds`** in [E2_M1](E2_M1_SkillDefinitionRegistry.md) + [`content/README.md`](../../../content/README.md). **NEO-34 landed:** fail-fast server load of `content/skills/*.json` at startup — `server/NeonSprawl.Server/Game/Skills/` ([NEO-34](../../plans/NEO-34-implementation-plan.md)); config + discovery in [server README — Skill catalog](../../../server/README.md#skill-catalog-contentskills-neo-34). **NEO-35 landed:** `ISkillDefinitionRegistry` / `SkillDefinitionRegistry` + DI ([NEO-35](../../plans/NEO-35-implementation-plan.md)). **NEO-36 landed:** versioned **`GET /game/world/skill-definitions`** ([NEO-36](../../plans/NEO-36-implementation-plan.md)) — `SkillDefinitionsWorldApi` + `SkillDefinitionsListDtos` in `server/NeonSprawl.Server/Game/Skills/`; Bruno `bruno/neon-sprawl-server/skill-definitions/`; manual QA [`NEO-36`](../../manual-qa/NEO-36.md). **E2.M2 consumer (NEO-38 landed):** grant path validates `skillId` + `sourceKind` vs catalog **`allowedXpSourceKinds`** on **`POST …/skill-progression`** ([NEO-38](../../plans/NEO-38-implementation-plan.md)); see [server README — Skill progression grant](../../../server/README.md#skill-progression-grant-neo-38) and [E2_M2](E2_M2_XpAwardAndLevelEngine.md). | [NEO-33](../../plans/NEO-33-implementation-plan.md), [NEO-34](../../plans/NEO-34-implementation-plan.md), [NEO-35](../../plans/NEO-35-implementation-plan.md), [NEO-36](../../plans/NEO-36-implementation-plan.md); [E2_M1](E2_M1_SkillDefinitionRegistry.md); [module_dependency_register.md](module_dependency_register.md) **E2.M1 note**; `server/NeonSprawl.Server/Game/Skills/`; [`docs/manual-qa/NEO-36.md`](../../manual-qa/NEO-36.md); [server README — Skill definitions (NEO-36)](../../../server/README.md#skill-definitions-neo-36) | -| E2.M2 | In Progress | **NEO-37 landed:** versioned **`GET /game/players/{id}/skill-progression`** ([NEO-37](../../plans/NEO-37-implementation-plan.md)) — read model for every registered skill; known-player gate via `IPositionStateStore`; [server README — Skill progression snapshot (NEO-37)](../../../server/README.md#skill-progression-snapshot-neo-37); manual QA [`NEO-37`](../../manual-qa/NEO-37.md). **NEO-38 landed:** **`POST`** same path — grant apply, persistence (`IPlayerSkillProgressionStore`, `V003` migration), structured denies + **`levelUps`** ([NEO-38](../../plans/NEO-38-implementation-plan.md)); manual QA [`NEO-38`](../../manual-qa/NEO-38.md); Bruno `bruno/neon-sprawl-server/skill-progression/`; [server README — Skill progression grant (NEO-38)](../../../server/README.md#skill-progression-grant-neo-38). **NEO-39 landed:** data-driven `LevelCurve` content + schema + CI validation (`*_level_curve.json`) with fail-fast startup schema checks; progression GET/POST level resolution now uses `ISkillLevelCurve` content-backed thresholds ([NEO-39](../../plans/NEO-39-implementation-plan.md)); manual QA [`NEO-39`](../../manual-qa/NEO-39.md). **NEO-40 landed:** comment-only telemetry hook sites in [`SkillProgressionSnapshotApi.cs`](../../../server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotApi.cs) on **`POST …/skill-progression`** for future **`xp_grant`** / **`level_up`** ([NEO-40](../../plans/NEO-40-implementation-plan.md), [`NEO-40` manual QA](../../manual-qa/NEO-40.md)). **Slice 3 integration** — [NEO-41](https://linear.app/neon-sprawl/issue/NEO-41) / [NEO-42](https://linear.app/neon-sprawl/issue/NEO-42) / [NEO-43](https://linear.app/neon-sprawl/issue/NEO-43); [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44). See [epic_02 — E2.M2 + Slice 3](../epics/epic_02_skills_and_progression.md). | [NEO-37](../../plans/NEO-37-implementation-plan.md), [NEO-38](../../plans/NEO-38-implementation-plan.md), [NEO-39](../../plans/NEO-39-implementation-plan.md), [NEO-40](../../plans/NEO-40-implementation-plan.md), [E2_M2](E2_M2_XpAwardAndLevelEngine.md); label **`E2.M2`** on NEO-37–NEO-40, NEO-41–NEO-43 | +| E2.M2 | In Progress | **NEO-37 landed:** versioned **`GET /game/players/{id}/skill-progression`** ([NEO-37](../../plans/NEO-37-implementation-plan.md)) — read model for every registered skill; known-player gate via `IPositionStateStore`; [server README — Skill progression snapshot (NEO-37)](../../../server/README.md#skill-progression-snapshot-neo-37); manual QA [`NEO-37`](../../manual-qa/NEO-37.md). **NEO-38 landed:** **`POST`** same path — grant apply, persistence (`IPlayerSkillProgressionStore`, `V003` migration), structured denies + **`levelUps`** ([NEO-38](../../plans/NEO-38-implementation-plan.md)); manual QA [`NEO-38`](../../manual-qa/NEO-38.md); Bruno `bruno/neon-sprawl-server/skill-progression/`; [server README — Skill progression grant (NEO-38)](../../../server/README.md#skill-progression-grant-neo-38). **NEO-39 landed:** data-driven `LevelCurve` content + schema + CI validation (`*_level_curve.json`) with fail-fast startup schema checks; progression GET/POST level resolution now uses `ISkillLevelCurve` content-backed thresholds ([NEO-39](../../plans/NEO-39-implementation-plan.md)); manual QA [`NEO-39`](../../manual-qa/NEO-39.md). **NEO-40 landed:** comment-only telemetry hook sites in [`SkillProgressionSnapshotApi.cs`](../../../server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotApi.cs) on **`POST …/skill-progression`** for future **`xp_grant`** / **`level_up`** ([NEO-40](../../plans/NEO-40-implementation-plan.md), [`NEO-40` manual QA](../../manual-qa/NEO-40.md)). **NEO-41 landed:** gather prototype — **`POST …/interact`** with **`kind: resource_node`** grants **`salvage`** + **`activity`** (10 XP) via [`SkillProgressionGrantOperations`](../../../server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantOperations.cs) ([NEO-41](../../plans/NEO-41-implementation-plan.md), [`NEO-41` manual QA](../../manual-qa/NEO-41.md)); [server README — Interaction](../../../server/README.md#interaction-neo-9). **Slice 3 integration** — [NEO-42](https://linear.app/neon-sprawl/issue/NEO-42) / [NEO-43](https://linear.app/neon-sprawl/issue/NEO-43); [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44). See [epic_02 — E2.M2 + Slice 3](../epics/epic_02_skills_and_progression.md). | [NEO-37](../../plans/NEO-37-implementation-plan.md), [NEO-38](../../plans/NEO-38-implementation-plan.md), [NEO-39](../../plans/NEO-39-implementation-plan.md), [NEO-40](../../plans/NEO-40-implementation-plan.md), [NEO-41](../../plans/NEO-41-implementation-plan.md), [E2_M2](E2_M2_XpAwardAndLevelEngine.md); label **`E2.M2`** on NEO-37–NEO-41, NEO-42–NEO-43 | +| E3.M1 | In Progress | **NEO-41 landed (prototype):** `POST …/interact` success on **`resource_node`** (`prototype_resource_node_alpha`) applies **`salvage`** skill XP (**`sourceKind: activity`**, 10 XP) via shared NEO-38 grant operations. **Still planned:** `GatherResult`, yields, inventory per [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md). | [NEO-41](../../plans/NEO-41-implementation-plan.md), [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md); `server/NeonSprawl.Server/Game/Interaction/`, `Game/Skills/` | --- diff --git a/docs/decomposition/modules/module_dependency_register.md b/docs/decomposition/modules/module_dependency_register.md index 05071cb..9065803 100644 --- a/docs/decomposition/modules/module_dependency_register.md +++ b/docs/decomposition/modules/module_dependency_register.md @@ -40,7 +40,7 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen | Module ID | Module Name | Depends On | Contract Needed | Phase Required | Status | |---|---|---|---|---|---| -| E3.M1 | ResourceNodeAndGatherLoop | E1.M3, E2.M2 | ResourceNodeDef, GatherResult, ResourceYieldTable | Prototype | Planned | +| E3.M1 | ResourceNodeAndGatherLoop | E1.M3, E2.M2 | ResourceNodeDef, GatherResult, ResourceYieldTable | Prototype | In Progress | | E3.M2 | RefinementAndRecipeExecution | E3.M1, E3.M3 | RecipeDef, CraftRequest, CraftResult | Prototype | Planned | | E3.M3 | ItemizationAndInventorySchema | None | ItemDef, ItemInstance, InventorySlot | Prototype | Planned | | E3.M4 | SinkAndDurabilityLifecycle | E3.M3, E8.M3 | DurabilityState, ItemSinkEvent, RepairCostRule | Pre-production | Planned | diff --git a/docs/manual-qa/NEO-41.md b/docs/manual-qa/NEO-41.md new file mode 100644 index 0000000..ae69e47 --- /dev/null +++ b/docs/manual-qa/NEO-41.md @@ -0,0 +1,55 @@ +# Manual QA — NEO-41 (gather → skill XP via interact) + +Reference: [implementation plan](../plans/NEO-41-implementation-plan.md), [NEO-38](./NEO-38.md) (skill progression grant), [NEO-9 README — Interaction](../../server/README.md#interaction-neo-9). + +## Preconditions + +- Run `NeonSprawl.Server` from `server/NeonSprawl.Server` (`dotnet run`; default `http://localhost:5253`). +- Player **`dev-local-1`** (default seed). + +```bash +BASE=http://localhost:5253 +ID=dev-local-1 +``` + +## Move in range of the prototype resource node + +Anchor **(12, −6)** on X/Z, radius **3** m. Example stand at **(10, −6)**: + +```bash +curl -sS -i -X POST "${BASE}/game/players/${ID}/move" \ + -H "Content-Type: application/json" \ + -d '{"schemaVersion":1,"target":{"x":10,"y":0.9,"z":-6}}' +``` + +Expect **HTTP 200**. + +## Baseline skill progression (optional) + +```bash +curl -sS "${BASE}/game/players/${ID}/skill-progression" +``` + +Note **`salvage`** **`xp`** (often **0** on a fresh store). + +## Gather interact (prototype) + +```bash +curl -sS -i -X POST "${BASE}/game/players/${ID}/interact" \ + -H "Content-Type: application/json" \ + -d '{"schemaVersion":1,"interactableId":"prototype_resource_node_alpha"}' +``` + +Expect **HTTP 200**, **`allowed`:** **true**. + +## Verify XP + +```bash +curl -sS "${BASE}/game/players/${ID}/skill-progression" +``` + +**`salvage`** **`xp`** should increase by **10** per successful gather interact (repeat interact to see cumulative XP). + +## Control — terminal does not grant gather XP + +Move near **`prototype_terminal`** (e.g. target **`0.5`, `0.9`, `0.5`**), **`POST …/interact`** with **`prototype_terminal`**, then GET progression: **`salvage`** XP should match whatever you had before this control (no +10 from terminal alone). diff --git a/docs/plans/NEO-41-implementation-plan.md b/docs/plans/NEO-41-implementation-plan.md index c06504f..fd860f7 100644 --- a/docs/plans/NEO-41-implementation-plan.md +++ b/docs/plans/NEO-41-implementation-plan.md @@ -36,9 +36,9 @@ ## Acceptance criteria checklist -- [ ] Gather completion grants skill XP through the **same authoritative path** as NEO-38 (shared grant application; no ad-hoc client XP). -- [ ] Disallowed `sourceKind` / skill combinations **never** emitted from gather (defensive: only `salvage` + `activity` for `resource_node` kind; registry check before apply). -- [ ] Visible or **testable** progression: automated test (and manual QA) shows **`salvage`** XP increases after successful **`resource_node`** interact from in-range position. +- [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 @@ -61,10 +61,13 @@ | Path | Purpose | |------|---------| -| `server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantOperations.cs` (name flexible) | Shared NEO-38 grant apply + snapshot envelope for POST and gather hook. | -| `server/NeonSprawl.Server.Tests/Game/Interaction/InteractionResourceNodeGatherXpTests.cs` (or merge into `InteractionApiTests.cs`) | In-range `resource_node` interact increases `salvage` XP on GET snapshot; terminal interact does not. | +| `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. | | `docs/manual-qa/NEO-41.md` | curl: move near node → interact → GET skill-progression. | -| `bruno/neon-sprawl-server/interaction/Post interact prototype resource node gather.bru` (name follows existing `Post interact …` requests) | Bruno: move near node (reuse vars) → interact `prototype_resource_node_alpha` → optional GET skill-progression assertion. | +| `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 diff --git a/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionApiTests.cs index a229e4f..c2e380c 100644 --- a/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionApiTests.cs @@ -7,7 +7,7 @@ using Xunit; namespace NeonSprawl.Server.Tests.Game.Interaction; -/// Integration tests for (NS-18). +/// Integration tests for (NS-18). NEO-41: covers gather XP on resource_node kind. public class InteractionApiTests { [Fact] diff --git a/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionResourceNodeGatherXpTests.cs b/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionResourceNodeGatherXpTests.cs new file mode 100644 index 0000000..314c35b --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionResourceNodeGatherXpTests.cs @@ -0,0 +1,82 @@ +using System.Linq; +using System.Net; +using System.Net.Http.Json; +using NeonSprawl.Server.Game.Interaction; +using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Game.Skills; +using NeonSprawl.Server.Game.World; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Interaction; + +/// NEO-41: successful resource_node interact grants salvage XP via shared NEO-38 grant path. +public sealed class InteractionResourceNodeGatherXpTests +{ + [Fact] + public async Task PostInteract_PrototypeTerminal_ShouldNotIncreaseSalvageXp() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + var move = new MoveCommandRequest + { + SchemaVersion = MoveCommandRequest.CurrentSchemaVersion, + Target = new PositionVector { X = 0.5, Y = 0.9, Z = 0.5 }, + }; + await client.PostAsJsonAsync("/game/players/dev-local-1/move", move); + + // Act + var interact = await client.PostAsJsonAsync( + "/game/players/dev-local-1/interact", + new InteractionRequest + { + SchemaVersion = InteractionRequest.CurrentSchemaVersion, + InteractableId = PrototypeInteractableRegistry.PrototypeTerminalId, + }); + var snapshot = await client.GetAsync("/game/players/dev-local-1/skill-progression"); + + // Assert + Assert.Equal(HttpStatusCode.OK, interact.StatusCode); + Assert.Equal(HttpStatusCode.OK, snapshot.StatusCode); + var body = await snapshot.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + var salvage = body!.Skills!.Single(static s => s.Id == "salvage"); + Assert.Equal(0, salvage.Xp); + } + + [Fact] + public async Task PostInteract_ResourceNode_WhenInRange_ShouldGrantSalvageActivityXp_ViaProgressionSnapshot() + { + // Arrange — node at (12, -6), radius 3; stand at (10, -6) → horizontal distance 2 + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + var move = new MoveCommandRequest + { + SchemaVersion = MoveCommandRequest.CurrentSchemaVersion, + Target = new PositionVector { X = 10, Y = 0.9, Z = -6 }, + }; + await client.PostAsJsonAsync("/game/players/dev-local-1/move", move); + + // Act + var interact = await client.PostAsJsonAsync( + "/game/players/dev-local-1/interact", + new InteractionRequest + { + SchemaVersion = InteractionRequest.CurrentSchemaVersion, + InteractableId = PrototypeInteractableRegistry.PrototypeResourceNodeAlphaId, + }); + var snapshot = await client.GetAsync("/game/players/dev-local-1/skill-progression"); + + // Assert + Assert.Equal(HttpStatusCode.OK, interact.StatusCode); + var envelope = await interact.Content.ReadFromJsonAsync(); + Assert.NotNull(envelope); + Assert.True(envelope!.Allowed); + + Assert.Equal(HttpStatusCode.OK, snapshot.StatusCode); + var body = await snapshot.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + var salvage = body!.Skills!.Single(static s => s.Id == "salvage"); + Assert.Equal(GatherSkillXpConstants.ActivityXpPerResourceNodeGather, salvage.Xp); + } +} diff --git a/server/NeonSprawl.Server/Game/Interaction/InteractionApi.cs b/server/NeonSprawl.Server/Game/Interaction/InteractionApi.cs index 59b1c16..e8d421e 100644 --- a/server/NeonSprawl.Server/Game/Interaction/InteractionApi.cs +++ b/server/NeonSprawl.Server/Game/Interaction/InteractionApi.cs @@ -1,4 +1,5 @@ using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Game.World; namespace NeonSprawl.Server.Game.Interaction; @@ -13,7 +14,8 @@ public static class InteractionApi { app.MapPost( "/game/players/{id}/interact", - (string id, InteractionRequest? body, IPositionStateStore store) => + (string id, InteractionRequest? body, IPositionStateStore store, + ISkillDefinitionRegistry registry, IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve) => { if (body is null || body.SchemaVersion != InteractionRequest.CurrentSchemaVersion) { @@ -66,6 +68,19 @@ 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. + _ = SkillProgressionGrantOperations.TryApplyGrant( + playerKey, + GatherSkillXpConstants.SalvageSkillId, + GatherSkillXpConstants.ActivityXpPerResourceNodeGather, + GatherSkillXpConstants.ActivitySourceKind, + registry, + xpStore, + levelCurve); + } + return Results.Json( new InteractionResponse { diff --git a/server/NeonSprawl.Server/Game/Interaction/PrototypeInteractableRegistry.cs b/server/NeonSprawl.Server/Game/Interaction/PrototypeInteractableRegistry.cs index 460f26b..1bcf04c 100644 --- a/server/NeonSprawl.Server/Game/Interaction/PrototypeInteractableRegistry.cs +++ b/server/NeonSprawl.Server/Game/Interaction/PrototypeInteractableRegistry.cs @@ -12,11 +12,14 @@ public static class PrototypeInteractableRegistry /// Second stub row for multi-entity projection (NEO-25). public const string PrototypeResourceNodeAlphaId = "prototype_resource_node_alpha"; + /// for gather-anchor nodes (NEO-41). + public const string ResourceNodeKind = "resource_node"; + /// Source-of-truth anchor and radius; client discovers via GET /game/world/interactables (NEO-25). public static readonly PrototypeInteractableEntry PrototypeTerminal = new(0, 0.5, 0, 3.0, "terminal"); /// +X / −Z open floor (avoids overlap with scene Obstacle at ~(6,1,5)); same radius as terminal. - public static readonly PrototypeInteractableEntry PrototypeResourceNodeAlpha = new(12, 0.5, -6, 3.0, "resource_node"); + public static readonly PrototypeInteractableEntry PrototypeResourceNodeAlpha = new(12, 0.5, -6, 3.0, ResourceNodeKind); private static readonly Dictionary ById = new(StringComparer.Ordinal) { diff --git a/server/NeonSprawl.Server/Game/Skills/GatherSkillXpConstants.cs b/server/NeonSprawl.Server/Game/Skills/GatherSkillXpConstants.cs new file mode 100644 index 0000000..d41192d --- /dev/null +++ b/server/NeonSprawl.Server/Game/Skills/GatherSkillXpConstants.cs @@ -0,0 +1,11 @@ +namespace NeonSprawl.Server.Game.Skills; + +/// NEO-41: prototype gather (resource-node interact) → salvage skill XP via sourceKind: activity. +public static class GatherSkillXpConstants +{ + public const int ActivityXpPerResourceNodeGather = 10; + + public const string SalvageSkillId = "salvage"; + + public const string ActivitySourceKind = "activity"; +} diff --git a/server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantApplyOutcome.cs b/server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantApplyOutcome.cs new file mode 100644 index 0000000..107be9d --- /dev/null +++ b/server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantApplyOutcome.cs @@ -0,0 +1,19 @@ +namespace NeonSprawl.Server.Game.Skills; + +/// Result of (NEO-38 / NEO-41 shared path). +public enum SkillProgressionGrantApplyKind +{ + /// Structured deny — is populated. + Denied, + + /// returned false (e.g. player missing from progression store). + ProgressionStoreMissing, + + /// XP applied — is success envelope. + Granted, +} + +/// Deny or success JSON envelope; null only when is . +public readonly record struct SkillProgressionGrantApplyOutcome( + SkillProgressionGrantApplyKind Kind, + SkillProgressionGrantResponse? Response); diff --git a/server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantOperations.cs b/server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantOperations.cs new file mode 100644 index 0000000..e905526 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantOperations.cs @@ -0,0 +1,93 @@ +namespace NeonSprawl.Server.Game.Skills; + +/// Shared NEO-38 skill XP grant apply (used by HTTP POST and NEO-41 gather interact hook). +public static class SkillProgressionGrantOperations +{ + /// + /// Applies one grant if validation passes and succeeds. + /// Caller must ensure is already authorized for progression (e.g. known to position state). + /// + public static SkillProgressionGrantApplyOutcome TryApplyGrant( + string playerId, + string skillIdRaw, + int amount, + string sourceKindRaw, + ISkillDefinitionRegistry registry, + IPlayerSkillProgressionStore xpStore, + ISkillLevelCurve levelCurve) + { + static SkillProgressionGrantResponse Deny(SkillProgressionSnapshotResponse snapshot, string reason) => + new() + { + Granted = false, + ReasonCode = reason, + Progression = snapshot, + LevelUps = [], + }; + + var beforeSnapshot = SkillProgressionSnapshotApi.BuildSnapshot(playerId, registry, xpStore, levelCurve); + + if (amount <= 0) + { + return new SkillProgressionGrantApplyOutcome( + SkillProgressionGrantApplyKind.Denied, + Deny(beforeSnapshot, SkillProgressionSnapshotApi.ReasonInvalidAmount)); + } + + var skillLookup = skillIdRaw.Trim(); + if (skillLookup.Length == 0 || !registry.TryGetDefinition(skillLookup, out var def)) + { + return new SkillProgressionGrantApplyOutcome( + SkillProgressionGrantApplyKind.Denied, + Deny(beforeSnapshot, SkillProgressionSnapshotApi.ReasonUnknownSkill)); + } + + var source = sourceKindRaw.Trim(); + var allowed = def.AllowedXpSourceKinds.Any(k => + string.Equals(k, source, StringComparison.OrdinalIgnoreCase)); + if (!allowed) + { + return new SkillProgressionGrantApplyOutcome( + SkillProgressionGrantApplyKind.Denied, + Deny(beforeSnapshot, SkillProgressionSnapshotApi.ReasonSourceKindNotAllowed)); + } + + if (!xpStore.TryApplyXpDelta(playerId, def.Id, amount, out var prevXp, out var newXp)) + { + return new SkillProgressionGrantApplyOutcome(SkillProgressionGrantApplyKind.ProgressionStoreMissing, null); + } + + // --- Telemetry hook site (NEO-40): future E9.M1 catalog event `xp_grant` --- + // No ingest here until the telemetry catalog exists. Planned payload fields: playerId, + // skillId, amount, sourceKind; optional correlation/request ids when Slice 3 wires callers. + + var prevLevel = levelCurve.LevelFromTotalXp(prevXp); + var nextLevel = levelCurve.LevelFromTotalXp(newXp); + var levelUps = new List(); + if (nextLevel > prevLevel) + { + // --- Telemetry hook site (NEO-40): future E9.M1 catalog event `level_up` --- + // Planned fields: playerId, skillId, previousLevel, newLevel, prevXp, newXp (totals before/after this grant). No logging (comments-only). + // Time-to-first-level-up: needs first-seen / milestone persistence not present here; wire + // when catalog + storage exist (likely derived from `level_up` or a dedicated milestone). + + levelUps.Add( + new SkillLevelUpJson + { + SkillId = def.Id, + PreviousLevel = prevLevel, + NewLevel = nextLevel, + }); + } + + var afterSnapshot = SkillProgressionSnapshotApi.BuildSnapshot(playerId, registry, xpStore, levelCurve); + return new SkillProgressionGrantApplyOutcome( + SkillProgressionGrantApplyKind.Granted, + new SkillProgressionGrantResponse + { + Granted = true, + Progression = afterSnapshot, + LevelUps = levelUps, + }); + } +} diff --git a/server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotApi.cs b/server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotApi.cs index 65fd2a7..a9d1387 100644 --- a/server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotApi.cs +++ b/server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotApi.cs @@ -33,17 +33,6 @@ public static class SkillProgressionSnapshotApi (string id, SkillProgressionGrantRequest? body, IPositionStateStore positions, ISkillDefinitionRegistry registry, IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve) => { - static SkillProgressionGrantResponse Deny( - SkillProgressionSnapshotResponse snapshot, - string reason) => - new SkillProgressionGrantResponse - { - Granted = false, - ReasonCode = reason, - Progression = snapshot, - LevelUps = [], - }; - if (body is null || body.SchemaVersion != SkillProgressionGrantRequest.CurrentSchemaVersion) { return Results.BadRequest(); @@ -55,63 +44,22 @@ public static class SkillProgressionSnapshotApi return Results.NotFound(); } - var beforeSnapshot = BuildSnapshot(trimmedId, registry, xpStore, levelCurve); + var outcome = SkillProgressionGrantOperations.TryApplyGrant( + trimmedId, + body.SkillId, + body.Amount, + body.SourceKind, + registry, + xpStore, + levelCurve); - if (body.Amount <= 0) + return outcome.Kind switch { - return Results.Json(Deny(beforeSnapshot, ReasonInvalidAmount)); - } - - var skillLookup = body.SkillId.Trim(); - if (skillLookup.Length == 0 || !registry.TryGetDefinition(skillLookup, out var def)) - { - return Results.Json(Deny(beforeSnapshot, ReasonUnknownSkill)); - } - - var source = body.SourceKind.Trim(); - var allowed = def.AllowedXpSourceKinds.Any(k => - string.Equals(k, source, StringComparison.OrdinalIgnoreCase)); - if (!allowed) - { - return Results.Json(Deny(beforeSnapshot, ReasonSourceKindNotAllowed)); - } - - if (!xpStore.TryApplyXpDelta(trimmedId, def.Id, body.Amount, out var prevXp, out var newXp)) - { - return Results.NotFound(); - } - - // --- Telemetry hook site (NEO-40): future E9.M1 catalog event `xp_grant` --- - // No ingest here until the telemetry catalog exists. Planned payload fields: playerId, - // skillId, amount, sourceKind; optional correlation/request ids when Slice 3 wires callers. - - var prevLevel = levelCurve.LevelFromTotalXp(prevXp); - var nextLevel = levelCurve.LevelFromTotalXp(newXp); - var levelUps = new List(); - if (nextLevel > prevLevel) - { - // --- Telemetry hook site (NEO-40): future E9.M1 catalog event `level_up` --- - // Planned fields: playerId, skillId, previousLevel, newLevel, prevXp, newXp (totals before/after this grant). No logging (comments-only). - // Time-to-first-level-up: needs first-seen / milestone persistence not present here; wire - // when catalog + storage exist (likely derived from `level_up` or a dedicated milestone). - - levelUps.Add( - new SkillLevelUpJson - { - SkillId = def.Id, - PreviousLevel = prevLevel, - NewLevel = nextLevel, - }); - } - - var afterSnapshot = BuildSnapshot(trimmedId, registry, xpStore, levelCurve); - return Results.Json( - new SkillProgressionGrantResponse - { - Granted = true, - Progression = afterSnapshot, - LevelUps = levelUps, - }); + SkillProgressionGrantApplyKind.Denied => Results.Json(outcome.Response!), + SkillProgressionGrantApplyKind.ProgressionStoreMissing => Results.NotFound(), + SkillProgressionGrantApplyKind.Granted => Results.Json(outcome.Response!), + _ => throw new InvalidOperationException($"Unexpected grant outcome: {outcome.Kind}"), + }; }); return app; diff --git a/server/README.md b/server/README.md index a94f0e1..4053c56 100644 --- a/server/README.md +++ b/server/README.md @@ -174,6 +174,10 @@ curl -s -X POST http://localhost:5253/game/players/dev-local-1/interact \ When **`allowed` is `true`**, **`reasonCode` is omitted** (clients must not require it on success). Unknown player never returns this JSON — use **404**. +### Gather prototype → skill XP (NEO-41) + +When **`allowed` is `true`** and the interactable’s **`kind`** is **`resource_node`** (today: **`prototype_resource_node_alpha`** from **`GET /game/world/interactables`**), the server applies **10** **`salvage`** skill XP with **`sourceKind: activity`** using the **same validation and persistence** as **`POST /game/players/{id}/skill-progression`** ([NEO-38](#skill-progression-grant-neo-38)) via `SkillProgressionGrantOperations` — not a separate client-side XP bump. Move into horizontal range first (anchor **(12, −6)** m on X/Z, radius **3**). Plan: [NEO-41 implementation plan](../../docs/plans/NEO-41-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-41.md`](../../docs/manual-qa/NEO-41.md); Bruno: `bruno/neon-sprawl-server/position/Post move near prototype resource node.bru` then `bruno/neon-sprawl-server/interaction/Post interact prototype resource node gather.bru`. + Contract details and PR blurb: [NEO-9 implementation plan](../../docs/plans/NEO-9-implementation-plan.md). ## Interactable descriptors (NEO-25)