Merge pull request #97 from ViPro-Technologies/NEO-63-e3m1-interact-path-integration-registry-expansion-bruno
NEO-62/NEO-63: GatherResult engine + interact gather wiringpull/98/head
commit
052dd7bc8a
|
|
@ -0,0 +1,63 @@
|
|||
meta {
|
||||
name: POST interact depleted node deny
|
||||
type: http
|
||||
seq: 13
|
||||
}
|
||||
|
||||
docs {
|
||||
NEO-63: uses prototype_urban_bulk_delta (not alpha) so earlier interaction requests in this collection do not pre-deplete capacity. Pre-request exhausts the node (10 successful gathers), then this interact should deny with node_depleted.
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
const axios = require("axios");
|
||||
const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl");
|
||||
const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId");
|
||||
const nodeId = "prototype_urban_bulk_delta";
|
||||
await axios.post(
|
||||
`${baseUrl}/game/players/${playerId}/move`,
|
||||
{
|
||||
schemaVersion: 1,
|
||||
target: { x: 16, y: 0.9, z: 0 },
|
||||
},
|
||||
{ headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const res = await axios.post(
|
||||
`${baseUrl}/game/players/${playerId}/interact`,
|
||||
{
|
||||
schemaVersion: 1,
|
||||
interactableId: nodeId,
|
||||
},
|
||||
{ headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
if (!res.data.allowed) {
|
||||
throw new Error(`expected gather ${i + 1}/10 to succeed, got ${res.data.reasonCode}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/game/players/{{playerId}}/interact
|
||||
body: json
|
||||
auth: none
|
||||
}
|
||||
|
||||
headers {
|
||||
content-type: application/json
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"interactableId": "prototype_urban_bulk_delta"
|
||||
}
|
||||
}
|
||||
|
||||
tests {
|
||||
test("depleted node interact denied with node_depleted", function () {
|
||||
expect(res.getStatus()).to.equal(200);
|
||||
const body = res.getBody();
|
||||
expect(body.allowed).to.equal(false);
|
||||
expect(body.reasonCode).to.equal("node_depleted");
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
meta {
|
||||
name: POST interact gather then GET inventory
|
||||
type: http
|
||||
seq: 12
|
||||
}
|
||||
|
||||
docs {
|
||||
NEO-63 happy path: move near prototype_resource_node_alpha, interact once, then GET inventory and assert scrap_metal_bulk quantity >= 1.
|
||||
}
|
||||
|
||||
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" } },
|
||||
);
|
||||
await axios.post(
|
||||
`${baseUrl}/game/players/${playerId}/interact`,
|
||||
{
|
||||
schemaVersion: 1,
|
||||
interactableId: "prototype_resource_node_alpha",
|
||||
},
|
||||
{ headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseUrl}}/game/players/{{playerId}}/inventory
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
tests {
|
||||
test("inventory includes scrap_metal_bulk after gather interact", function () {
|
||||
expect(res.getStatus()).to.equal(200);
|
||||
const body = res.getBody();
|
||||
const total = body.bagSlots
|
||||
.filter((s) => s.itemId === "scrap_metal_bulk")
|
||||
.reduce((sum, s) => sum + s.quantity, 0);
|
||||
expect(total).to.be.at.least(1);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,12 +1,11 @@
|
|||
meta {
|
||||
name: POST interact - prototype_resource_node_alpha (NEO-41 gather XP)
|
||||
name: POST interact - prototype_resource_node_alpha (NEO-63 gather)
|
||||
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).
|
||||
NEO-63: successful interact on `resource_node` kind runs GatherOperations — inventory grant (scrap_metal_bulk), salvage XP (activity, 10), and depletion. Pre-request moves in range of alpha anchor (12, -6). Optional: GET inventory / skill-progression after this request.
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
|
|
|
|||
|
|
@ -81,10 +81,10 @@ The **first shipped four-node spine** is **frozen** for downstream references (r
|
|||
|
||||
| `nodeDefId` / `interactableId` | `gatherLens` | Notes |
|
||||
|-------------------------------|--------------|--------|
|
||||
| **`prototype_resource_node_alpha`** | `consumer_salvage` | Existing NEO-9/NEO-41 anchor (12, −6) |
|
||||
| **`prototype_subsurface_vein_beta`** | `subsurface` | New anchor — plan PR sets X/Z |
|
||||
| **`prototype_bio_mat_gamma`** | `bio` | New anchor — plan PR sets X/Z |
|
||||
| **`prototype_urban_bulk_delta`** | `urban_bulk` | New anchor — plan PR sets X/Z |
|
||||
| **`prototype_resource_node_alpha`** | `consumer_salvage` | NEO-9/NEO-41 anchor **(12, −6)** |
|
||||
| **`prototype_subsurface_vein_beta`** | `subsurface` | NEO-63 anchor **(18, −6)** |
|
||||
| **`prototype_bio_mat_gamma`** | `bio` | NEO-63 anchor **(12, 0)** |
|
||||
| **`prototype_urban_bulk_delta`** | `urban_bulk` | NEO-63 anchor **(18, 0)** |
|
||||
|
||||
**Rules:** Do **not** rename these ids after ship. CI enforces **exactly four** node defs and valid yield item references. Relaxing the gate belongs in a new Linear issue when the roster grows.
|
||||
|
||||
|
|
@ -92,11 +92,13 @@ The **first shipped four-node spine** is **frozen** for downstream references (r
|
|||
|
||||
Epic 3 **Slice 2** — [gather nodes and outputs](../epics/epic_03_crafting_economy.md#epic-3-slice-2): 4–6 node types, yield tables, `resource_gathered`, `gather_node_depleted`.
|
||||
|
||||
**Prototype implementation anchor (NEO-41):** until **`GatherResult`** lands, a **successful** **`POST …/interact`** on **`kind: resource_node`** grants **`salvage`** skill XP only (`activity`, 10 XP) via `SkillProgressionGrantOperations` — **no** inventory or depletion. **E3M1-07** migrates XP into the gather engine and adds item + capacity behavior ([E3M1-prototype-backlog](../../plans/E3M1-prototype-backlog.md)).
|
||||
**Interact path (NEO-63):** **`POST …/interact`** on **`kind: resource_node`** delegates to **`GatherOperations`** — success grants inventory + **`salvage`** XP + depletion; denies return **`allowed: false`** + **`reasonCode`** (`node_depleted`, `inventory_full`, …). **Four** anchors in **`PrototypeInteractableRegistry`**. Plan: [NEO-63 implementation plan](../../plans/NEO-63-implementation-plan.md); [server README — Gather via interact (NEO-63)](../../../server/README.md#gather-via-interact-neo-63).
|
||||
|
||||
**Gather engine (NEO-62):** **`GatherOperations.TryGather`** → **`GatherResult`** (`success`, `reasonCode`, `grantsApplied[]`, `remainingGathers`, optional `xpGrantSummary`) — inventory + **`salvage`** XP + depletion commit in one orchestrated path; server-internal until promoted to wire. Plan: [NEO-62 implementation plan](../../plans/NEO-62-implementation-plan.md); [server README — Gather engine (NEO-62)](../../../server/README.md#gather-engine-neo-62).
|
||||
|
||||
**Resource node definitions HTTP (NEO-60):** **`GET /game/world/resource-node-definitions`** — versioned read-only projection (`schemaVersion` **1**, **`nodes`**) backed by **`IResourceNodeDefinitionRegistry`**; each row includes nested **`yield`** summary. Bruno `bruno/neon-sprawl-server/resource-node-definitions/`. Plan: [NEO-60 implementation plan](../../plans/NEO-60-implementation-plan.md).
|
||||
|
||||
**Resource node instance depletion store (NEO-61):** **`IResourceNodeInstanceStore`** + **`ResourceNodeInstanceOperations`** — lazy-init remaining gathers from catalog **`maxGathers`**, atomic decrement, **`node_depleted`** / **`unknown_node`** reason codes; Postgres **`V006`** or in-memory fallback. Interact wiring deferred to **E3M1-07** ([NEO-63](https://linear.app/neon-sprawl/issue/NEO-63)). Plan: [NEO-61 implementation plan](../../plans/NEO-61-implementation-plan.md).
|
||||
**Resource node instance depletion store (NEO-61):** **`IResourceNodeInstanceStore`** + **`ResourceNodeInstanceOperations`** — lazy-init remaining gathers from catalog **`maxGathers`**, atomic decrement, **`node_depleted`** / **`unknown_node`** reason codes; Postgres **`V006`** or in-memory fallback. Plan: [NEO-61 implementation plan](../../plans/NEO-61-implementation-plan.md).
|
||||
|
||||
**Linear backlog (decomposed):** [E3M1-prototype-backlog.md](../../plans/E3M1-prototype-backlog.md) — **E3M1-01** [NEO-57](https://linear.app/neon-sprawl/issue/NEO-57) through **E3M1-08** [NEO-64](https://linear.app/neon-sprawl/issue/NEO-64).
|
||||
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not
|
|||
| 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.M3 | In Progress | **NEO-45 landed:** prototype **`salvage`** mastery catalog + CI gates (see [NEO-45 plan](../../plans/NEO-45-implementation-plan.md)). **NEO-46 landed:** fail-fast server load under `server/NeonSprawl.Server/Game/Mastery/` — `MasteryCatalogLoader`, `IMasteryCatalogRegistry`, cross-check vs `ISkillDefinitionRegistry`, Slice 4 + **`tierIndex`** 1..N gate; see [NEO-46 plan](../../plans/NEO-46-implementation-plan.md). **NEO-47 landed:** `PerkUnlockEngine`, `IPlayerPerkStateStore` + **`V004`**, level-up hook in skill XP grants; see [NEO-47 plan](../../plans/NEO-47-implementation-plan.md). **NEO-49 landed:** comment-only **`perk_unlock`** telemetry hook site in [`PerkUnlockEngine.TryUnlockPerks`](../../../server/NeonSprawl.Server/Game/Mastery/PerkUnlockEngine.cs) ([NEO-49 plan](../../plans/NEO-49-implementation-plan.md), [`NEO-49` manual QA](../../manual-qa/NEO-49.md)); [server README — Perk unlock telemetry (NEO-49)](../../../server/README.md#perk-unlock-engine-and-telemetry-hooks-neo-47-neo-49). **NEO-48 landed:** **`GET`/`POST /game/players/{id}/perk-state`** — `PerkStateApi` + DTOs in `Game/Mastery/` ([NEO-48](../../plans/NEO-48-implementation-plan.md), [`NEO-48` manual QA](../../manual-qa/NEO-48.md)); [server README — Perk state (NEO-48)](../../../server/README.md#perk-state-neo-48); Bruno `bruno/neon-sprawl-server/perk-state/`. | [NEO-45](../../plans/NEO-45-implementation-plan.md), [NEO-46](../../plans/NEO-46-implementation-plan.md), [NEO-47](../../plans/NEO-47-implementation-plan.md), [NEO-48](../../plans/NEO-48-implementation-plan.md), [NEO-49](../../plans/NEO-49-implementation-plan.md), [E2M3-pre-production-backlog](../../plans/E2M3-pre-production-backlog.md), [E2_M3](E2_M3_MasteryAndPerkUnlocks.md) |
|
||||
| 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). **NEO-43 landed (prep):** **`MissionRewardSkillXpGrant`** / **`MissionRewardSkillXpConstants`** + deny-registry factory + tests — same NEO-38 grant stack with fixed **`sourceKind: mission_reward`**; **E7.M2** must invoke from quest hand-in ([NEO-43](../../plans/NEO-43-implementation-plan.md), [`NEO-43` manual QA](../../manual-qa/NEO-43.md)); [server README — Mission / quest reward (NEO-43)](../../../server/README.md#mission--quest-reward--skill-xp-neo-43). **Slice 3 still open:** [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), [NEO-43](../../plans/NEO-43-implementation-plan.md), [E2_M2](E2_M2_XpAwardAndLevelEngine.md); label **`E2.M2`** on NEO-37–NEO-41, NEO-42–NEO-44 |
|
||||
| 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. **NEO-57 landed:** frozen four-node catalog in [`content/resource-nodes/`](../../../content/resource-nodes/); [`resource-node-def.schema.json`](../../../content/schemas/resource-node-def.schema.json), [`resource-yield-row.schema.json`](../../../content/schemas/resource-yield-row.schema.json); CI gates in [`validate_content.py`](../../../scripts/validate_content.py). **NEO-58 landed:** fail-fast server load of `content/resource-nodes/*` at startup — `server/NeonSprawl.Server/Game/Gathering/` ([NEO-58](../../plans/NEO-58-implementation-plan.md)); [server README — Resource-node catalog](../../../server/README.md#resource-node-catalog-contentresource-nodes-neo-58). **NEO-59 landed:** injectable **`IResourceNodeDefinitionRegistry`** + lookup tests ([NEO-59](../../plans/NEO-59-implementation-plan.md)). **NEO-60 landed:** **`GET /game/world/resource-node-definitions`** — `ResourceNodeDefinitionsWorldApi` + DTOs in `Game/Gathering/` ([NEO-60](../../plans/NEO-60-implementation-plan.md), [`NEO-60` manual QA](../../manual-qa/NEO-60.md)); [server README — Resource node definitions (NEO-60)](../../../server/README.md#resource-node-definitions-neo-60); Bruno `bruno/neon-sprawl-server/resource-node-definitions/`. **NEO-61 landed:** **`IResourceNodeInstanceStore`** + **`ResourceNodeInstanceOperations`** — lazy-init depletion, **`V006`** migration ([NEO-61](../../plans/NEO-61-implementation-plan.md)); [server README — Resource node instance depletion (NEO-61)](../../../server/README.md#resource-node-instance-depletion-neo-61). **Still planned:** `GatherResult`, inventory grants, interact wiring per [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md). | [NEO-41](../../plans/NEO-41-implementation-plan.md), [NEO-57](../../plans/NEO-57-implementation-plan.md), [NEO-58](../../plans/NEO-58-implementation-plan.md), [NEO-59](../../plans/NEO-59-implementation-plan.md), [NEO-60](../../plans/NEO-60-implementation-plan.md), [NEO-61](../../plans/NEO-61-implementation-plan.md), [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md); `content/resource-nodes/`, `server/NeonSprawl.Server/Game/Gathering/`, `server/NeonSprawl.Server/Game/Interaction/` |
|
||||
| E3.M1 | In Progress | **NEO-41 landed (prototype, superseded on interact by NEO-63):** inline XP on **`resource_node`** interact. **NEO-57–NEO-61** — content, registry, HTTP defs, depletion store (see prior alignment). **NEO-62 landed:** **`GatherOperations`** + **`GatherResult`**. **NEO-63 landed:** **`POST …/interact`** **`resource_node`** → gather engine; **four** **`PrototypeInteractableRegistry`** anchors; Bruno gather → inventory + depletion deny ([NEO-63](../../plans/NEO-63-implementation-plan.md), [`NEO-63` manual QA](../../manual-qa/NEO-63.md)); [server README — Gather via interact (NEO-63)](../../../server/README.md#gather-via-interact-neo-63). **Still planned:** telemetry hooks ([NEO-64](https://linear.app/neon-sprawl/issue/NEO-64)). | [NEO-41](../../plans/NEO-41-implementation-plan.md) … [NEO-63](../../plans/NEO-63-implementation-plan.md), [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md); `server/NeonSprawl.Server/Game/Gathering/`, `Game/Interaction/` |
|
||||
| E3.M3 | In Progress | **NEO-50 landed:** frozen prototype six-item catalog in [`content/items/prototype_items.json`](../../../content/items/prototype_items.json); [`item-def.schema.json`](../../../content/schemas/item-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py). **NEO-51 landed:** fail-fast server load of `content/items/*_items.json` at startup — `server/NeonSprawl.Server/Game/Items/` ([NEO-51](../../plans/NEO-51-implementation-plan.md)); [server README — Item catalog](../../../server/README.md#item-catalog-contentitems-neo-51). **NEO-52 landed:** injectable **`IItemDefinitionRegistry`** + lookup tests ([NEO-52](../../plans/NEO-52-implementation-plan.md)). **NEO-53 landed:** **`GET /game/world/item-definitions`** — `ItemDefinitionsWorldApi` + DTOs in `Game/Items/` ([NEO-53](../../plans/NEO-53-implementation-plan.md), [`NEO-53` manual QA](../../manual-qa/NEO-53.md)); [server README — Item definitions (NEO-53)](../../../server/README.md#item-definitions-neo-53); Bruno `bruno/neon-sprawl-server/item-definitions/`. **NEO-54 landed:** **`IPlayerInventoryStore`** + **`PlayerInventoryOperations`** — 24 bag + 1 equipment slots, stack rules, `V005` migration ([NEO-54](../../plans/NEO-54-implementation-plan.md)). **NEO-55 landed:** **`GET`/`POST /game/players/{id}/inventory`** — `PlayerInventoryApi` + DTOs in `Game/Items/` ([NEO-55](../../plans/NEO-55-implementation-plan.md)); [server README — Player inventory (NEO-54 store, NEO-55 HTTP)](../../../server/README.md#player-inventory-neo-54-store-neo-55-http); Bruno `bruno/neon-sprawl-server/inventory/`. **NEO-56 landed:** comment-only **`item_created`** / **`inventory_transfer_denied`** telemetry hook sites in [`PlayerInventoryOperations`](../../../server/NeonSprawl.Server/Game/Items/PlayerInventoryOperations.cs) ([NEO-56](../../plans/NEO-56-implementation-plan.md), [`NEO-56` manual QA](../../manual-qa/NEO-56.md)); no E9.M1 ingest. | [NEO-50](../../plans/NEO-50-implementation-plan.md), [NEO-51](../../plans/NEO-51-implementation-plan.md), [NEO-52](../../plans/NEO-52-implementation-plan.md), [NEO-53](../../plans/NEO-53-implementation-plan.md), [NEO-54](../../plans/NEO-54-implementation-plan.md), [NEO-55](../../plans/NEO-55-implementation-plan.md), [NEO-56](../../plans/NEO-56-implementation-plan.md), [E3M3-prototype-backlog](../../plans/E3M3-prototype-backlog.md), [E3_M3](E3_M3_ItemizationAndInventorySchema.md) |
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen
|
|||
| E3.M4 | SinkAndDurabilityLifecycle | E3.M3, E8.M3 | DurabilityState, ItemSinkEvent, RepairCostRule | Pre-production | Planned |
|
||||
| E3.M5 | EconomyBalancePolicy | E3.M4, E9.M2 | EconomyPolicy, PriceBandRule, FaucetSinkRatio | Pre-production | Planned |
|
||||
|
||||
**E3.M1 note:** Epic 3 **Slice 2** backlog in Linear ([Epic 3 — Crafting, Gathering, and Itemization Economy](https://linear.app/neon-sprawl/project/epic-3-crafting-gathering-and-itemization-economy-65785ed05bc2)): [NEO-57](https://linear.app/neon-sprawl/issue/NEO-57) → [NEO-64](https://linear.app/neon-sprawl/issue/NEO-64); label **`E3.M1`**. See [E3M1-prototype-backlog.md](../../plans/E3M1-prototype-backlog.md), [E3_M1_ResourceNodeAndGatherLoop.md](E3_M1_ResourceNodeAndGatherLoop.md). **NEO-57** (content + CI), **NEO-58** (server fail-fast load), **NEO-59** (`IResourceNodeDefinitionRegistry` + DI), **NEO-60** (`GET /game/world/resource-node-definitions`), **NEO-61** (`IResourceNodeInstanceStore` + depletion operations, `V006`) — E3M1-01–**E3M1-05** complete; register row **In Progress** until gather engine / interact wiring slices land. **NEO-41** (prototype gather XP on `resource_node` interact) remains until **E3M1-07** migrates into the gather engine. Remaining Slice 2 work adds **`GatherResult`**, inventory grants, and telemetry hooks **`resource_gathered`** / **`gather_node_depleted`**.
|
||||
**E3.M1 note:** Epic 3 **Slice 2** backlog in Linear ([Epic 3 — Crafting, Gathering, and Itemization Economy](https://linear.app/neon-sprawl/project/epic-3-crafting-gathering-and-itemization-economy-65785ed05bc2)): [NEO-57](https://linear.app/neon-sprawl/issue/NEO-57) → [NEO-64](https://linear.app/neon-sprawl/issue/NEO-64); label **`E3.M1`**. See [E3M1-prototype-backlog.md](../../plans/E3M1-prototype-backlog.md), [E3_M1_ResourceNodeAndGatherLoop.md](E3_M1_ResourceNodeAndGatherLoop.md). **NEO-57** (content + CI), **NEO-58** (server fail-fast load), **NEO-59** (`IResourceNodeDefinitionRegistry` + DI), **NEO-60** (`GET /game/world/resource-node-definitions`), **NEO-61** (`IResourceNodeInstanceStore` + depletion operations, `V006`), **NEO-62** (`GatherOperations` + `GatherResult`), **NEO-63** (interact → gather engine + four registry anchors + Bruno) — E3M1-01–**E3M1-07** complete; register row **In Progress** until telemetry slice lands. Remaining Slice 2 work: **`resource_gathered`** / **`gather_node_depleted`** hook sites ([NEO-64](https://linear.app/neon-sprawl/issue/NEO-64)).
|
||||
|
||||
**E3.M3 note:** Epic 3 **Slice 1** backlog in Linear ([Epic 3 — Crafting, Gathering, and Itemization Economy](https://linear.app/neon-sprawl/project/epic-3-crafting-gathering-and-itemization-economy-65785ed05bc2)): [NEO-50](https://linear.app/neon-sprawl/issue/NEO-50) → [NEO-56](https://linear.app/neon-sprawl/issue/NEO-56); label **`E3.M3`**. See [E3M3-prototype-backlog.md](../../plans/E3M3-prototype-backlog.md), [E3_M3_ItemizationAndInventorySchema.md](E3_M3_ItemizationAndInventorySchema.md). **NEO-50** (content + CI), **NEO-51** (server fail-fast load), **NEO-52** (`IItemDefinitionRegistry` + DI), **NEO-53** (`GET /game/world/item-definitions`), **NEO-54** (inventory store + stack/slot rules engine), **NEO-55** (`GET`/`POST /game/players/{id}/inventory`), and **NEO-56** (comment-only `item_created` / `inventory_transfer_denied` telemetry hook sites in `PlayerInventoryOperations`) — Epic 3 Slice 1 backlog **E3M3-01**–**E3M3-07** complete; register row **In Progress** until Slice 2+; later slices update the alignment table as they land.
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
# Manual QA — NEO-63 (gather via interact → inventory)
|
||||
|
||||
Reference: [implementation plan](../plans/NEO-63-implementation-plan.md), [NEO-62 implementation plan](../plans/NEO-62-implementation-plan.md) gather engine, [NEO-55](./NEO-55.md) inventory GET.
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
## Happy path — gather → inventory + XP
|
||||
|
||||
Move in range of **`prototype_resource_node_alpha`** (anchor **12, −6**; 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}}'
|
||||
```
|
||||
|
||||
Interact:
|
||||
|
||||
```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**.
|
||||
|
||||
Inventory should show **`scrap_metal_bulk`** quantity **1** (or +1 per repeat):
|
||||
|
||||
```bash
|
||||
curl -sS "${BASE}/game/players/${ID}/inventory"
|
||||
```
|
||||
|
||||
Skill progression should show **`salvage`** **`xp`** **+10** per successful gather:
|
||||
|
||||
```bash
|
||||
curl -sS "${BASE}/game/players/${ID}/skill-progression"
|
||||
```
|
||||
|
||||
## Depletion deny
|
||||
|
||||
Uses **`prototype_urban_bulk_delta`** (anchor **18, 0**) so earlier Bruno interaction requests on alpha do not pre-deplete capacity in the shared CI Postgres store. Move in range (**16, 0**), exhaust with **10** successful interacts on delta, then interact again:
|
||||
|
||||
```bash
|
||||
curl -sS -i -X POST "${BASE}/game/players/${ID}/move" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"schemaVersion":1,"target":{"x":16,"y":0.9,"z":0}}'
|
||||
```
|
||||
|
||||
(Repeat interact on **`prototype_urban_bulk_delta`** ten times with **`allowed: true`**, then:)
|
||||
|
||||
```bash
|
||||
curl -sS -i -X POST "${BASE}/game/players/${ID}/interact" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"schemaVersion":1,"interactableId":"prototype_urban_bulk_delta"}'
|
||||
```
|
||||
|
||||
Expect **HTTP 200**, **`allowed`:** **false**, **`reasonCode`:** **`node_depleted`**. Inventory and XP totals unchanged on that denied request.
|
||||
|
||||
## Control — terminal
|
||||
|
||||
Move near **`prototype_terminal`**, interact with **`prototype_terminal`**: **`allowed`:** **true**; no **`scrap_metal_bulk`** added; no gather XP from terminal alone.
|
||||
|
||||
## Bruno
|
||||
|
||||
Run folder `bruno/neon-sprawl-server/interaction/` — **`Post interact gather then get inventory.bru`** and **`Post interact depleted node deny.bru`**.
|
||||
|
|
@ -193,12 +193,12 @@ Working backlog for **Epic 3 — Slice 2** ([gather nodes and outputs](../decomp
|
|||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Successful gather adds configured **`scrap_metal_bulk`** quantity to player bag.
|
||||
- [ ] Full bag returns stable `reasonCode` without partial silent loss.
|
||||
- [ ] Skill XP grant uses same rules as NEO-38 / NEO-41 (`salvage`, `activity`, 10 XP).
|
||||
- [ ] Depletion store decrements atomically with successful grant.
|
||||
- [x] Successful gather adds configured **`scrap_metal_bulk`** quantity to player bag.
|
||||
- [x] Full bag returns stable `reasonCode` without partial silent loss.
|
||||
- [x] Skill XP grant uses same rules as NEO-38 / NEO-41 (`salvage`, `activity`, 10 XP).
|
||||
- [x] Depletion store decrements atomically with successful grant.
|
||||
|
||||
---
|
||||
**Landed:** [NEO-62](https://linear.app/neon-sprawl/issue/NEO-62) — gather engine; interact wiring is E3M1-07.
|
||||
|
||||
### E3M1-07 — Interact path integration + registry expansion + Bruno
|
||||
|
||||
|
|
@ -219,10 +219,12 @@ Working backlog for **Epic 3 — Slice 2** ([gather nodes and outputs](../decomp
|
|||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] In-range interact on fresh node increases inventory quantity and **`salvage`** XP.
|
||||
- [ ] Depleted node returns **`node_depleted`** without inventory or XP change.
|
||||
- [ ] Terminal interact unchanged (no gather side effects).
|
||||
- [ ] Bruno documents happy path + depletion deny for at least one node.
|
||||
- [x] In-range interact on fresh node increases inventory quantity and **`salvage`** XP.
|
||||
- [x] Depleted node returns **`node_depleted`** without inventory or XP change.
|
||||
- [x] Terminal interact unchanged (no gather side effects).
|
||||
- [x] Bruno documents happy path + depletion deny for at least one node.
|
||||
|
||||
**Landed:** [NEO-63](https://linear.app/neon-sprawl/issue/NEO-63) — interact gather wiring; telemetry is E3M1-08.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,133 @@
|
|||
# NEO-62 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-62 |
|
||||
| **Title** | E3.M1: GatherResult engine (yields + inventory + skill XP) |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-62/e3m1-gatherresult-engine-yields-inventory-skill-xp |
|
||||
| **Module** | [E3.M1 — ResourceNodeAndGatherLoop](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md) · Epic 3 Slice 2 · backlog **E3M1-06** |
|
||||
| **Branch** | `NEO-62-e3m1-gatherresult-engine-yields-inventory-skill-xp` |
|
||||
| **Precursors** | [NEO-59](https://linear.app/neon-sprawl/issue/NEO-59) — `IResourceNodeDefinitionRegistry` + DI (**Done**); [NEO-61](https://linear.app/neon-sprawl/issue/NEO-61) — `IResourceNodeInstanceStore` + depletion ops (**Done**); [NEO-54](https://linear.app/neon-sprawl/issue/NEO-54) — `PlayerInventoryOperations` (**Done**); [NEO-38](https://linear.app/neon-sprawl/issue/NEO-38) / [NEO-41](https://linear.app/neon-sprawl/issue/NEO-41) — skill XP grant path (**Done**) |
|
||||
| **Pattern** | [NEO-61](https://linear.app/neon-sprawl/issue/NEO-61) — static `*Operations` orchestrator over injectable stores; [NEO-54](https://linear.app/neon-sprawl/issue/NEO-54) — subsystem reason codes surfaced on gather deny |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **Side-effect order** | Inventory / XP / depletion commit sequence? | **Inventory add → XP grant → depletion decrement last** — depletion only after successful grant; avoids consuming capacity on `inventory_full` (NEO-54 all-or-nothing). | **User:** inventory → XP → depletion last. |
|
||||
| **XP failure after inventory** | Roll back items or partial success? | **Deny gather + compensating `TryRemoveStack`** — gather success must include items and XP per AC; NEO-41 silent XP no-op stays on interact until NEO-63. | **User:** rollback + deny. |
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Single server operation resolving **`GatherResult`**: validate node + capacity, grant configured yield via **`PlayerInventoryOperations`**, award **`salvage`** XP via **`SkillProgressionGrantOperations`**, commit depletion via **`ResourceNodeInstanceOperations`** — engine-only, ready for interact wiring (NEO-63).
|
||||
|
||||
**In scope (from Linear + [E3M1-06](E3M1-prototype-backlog.md#e3m1-06--gatherresult-engine-yields--inventory--skill-xp)):**
|
||||
|
||||
- **`GatherOperations`** / **`GatherResult`** types in `server/NeonSprawl.Server/Game/Gathering/`.
|
||||
- Stable deny **`reasonCode`** passthrough: `node_depleted`, `inventory_full`, `unknown_node`, plus gather-specific `progression_store_missing` when XP rollback path runs.
|
||||
- Unit / integration tests (AAA) via **`InMemoryWebApplicationFactory`** with real catalogs and stores.
|
||||
- Document **`GatherResult`** shape in module doc (server-internal until promoted to wire).
|
||||
|
||||
**Out of scope (from Linear + backlog):**
|
||||
|
||||
- **`InteractionApi`** wiring and duplicate NEO-41 XP removal ([NEO-63](https://linear.app/neon-sprawl/issue/NEO-63)).
|
||||
- Client HUD, Bruno, HTTP gather route.
|
||||
- Regen / respawn timers ([E4.M2](../decomposition/modules/E4_M2_SpawnEcologyController.md)).
|
||||
- Telemetry hook sites ([NEO-64](https://linear.app/neon-sprawl/issue/NEO-64)).
|
||||
- Cross-store transactions (prototype relies on ordered commits + compensating remove on XP failure).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] Successful gather adds configured **`scrap_metal_bulk`** quantity to player bag.
|
||||
- [x] Full bag returns stable **`inventory_full`** without partial silent loss or depletion change.
|
||||
- [x] Skill XP grant uses same rules as NEO-38 / NEO-41 (`salvage`, `activity`, 10 XP via **`GatherSkillXpConstants`**).
|
||||
- [x] Depletion store decrements atomically with successful grant (decrement **last**, only when inventory + XP both succeed).
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **Entry point:** **`GatherOperations.TryGather(...)`** — static orchestrator accepting `playerId`, `interactableId`, and dependencies:
|
||||
- **`IResourceNodeDefinitionRegistry`** (def + yield),
|
||||
- **`IItemDefinitionRegistry`** + **`IPlayerInventoryStore`**,
|
||||
- **`ISkillDefinitionRegistry`**, **`IPlayerSkillProgressionStore`**, **`ISkillLevelCurve`**, **`PerkUnlockEngine`**,
|
||||
- **`IResourceNodeInstanceStore`**.
|
||||
|
||||
2. **Normalize key:** **`ResourceNodeInstanceIds.Normalize(interactableId)`** (trim + lowercase); empty → **`unknown_node`**.
|
||||
|
||||
3. **Definition + yield resolve:** **`TryGetDefinition`** and **`TryGetYield`** on normalized id. Either miss → **`Denied`** + **`GatherReasonCodes.UnknownNode`** (no writes).
|
||||
|
||||
4. **Capacity pre-check (read-only, no decrement):**
|
||||
- **`store.TryEnsureInitialized(key, definition.MaxGathers)`** (lazy init mirror of NEO-61).
|
||||
- **`store.TryGetRemainingGathers(key, out snapshot)`** — if **`remainingGathers <= 0`** → **`Denied`** + **`GatherReasonCodes.NodeDepleted`** with snapshot (no inventory / XP / decrement).
|
||||
|
||||
5. **Inventory grant (first mutation):** **`PlayerInventoryOperations.TryAddStack(playerId, yield.ItemId, yield.Quantity, itemRegistry, inventoryStore)`**.
|
||||
- **`Denied`** + **`inventory_full`** (passthrough) → return immediately; depletion unchanged.
|
||||
- **`StoreMissing`** → **`GatherReasonCodes.InventoryStoreMissing`** (gather surface; unlikely in tests).
|
||||
- **`Applied`** → continue.
|
||||
|
||||
6. **Skill XP (second mutation):** **`SkillProgressionGrantOperations.TryApplyGrant`** with **`GatherSkillXpConstants.SalvageSkillId`**, **`ActivityXpPerResourceNodeGather`**, **`ActivitySourceKind`**.
|
||||
- **`Granted`** → continue.
|
||||
- **`ProgressionStoreMissing`** or **`Denied`** → **compensating rollback:** **`PlayerInventoryOperations.TryRemoveStack`** same **`itemId`** / **`quantity`**; return **`Denied`** + **`GatherReasonCodes.ProgressionStoreMissing`** (or passthrough skill deny **`reasonCode`** from response when **`Denied`**). Depletion unchanged.
|
||||
|
||||
7. **Depletion commit (last mutation):** **`ResourceNodeInstanceOperations.TryCommitSuccessfulGatherDecrement(key, nodeRegistry, store)`**.
|
||||
- Expect **`Applied`** given step 4 pre-check; on unexpected **`Denied`**, attempt compensating inventory remove + surface **`node_depleted`** (defensive; document as prototype race risk — no XP rollback in this edge case).
|
||||
|
||||
8. **Success envelope — `GatherResult`:**
|
||||
- **`Success = true`**, **`ReasonCode = null`**
|
||||
- **`GrantsApplied`**: single entry `{ itemId, quantity }` from yield row (prototype: one yield per node).
|
||||
- **`RemainingGathers`**: from depletion outcome snapshot.
|
||||
- **`XpGrantSummary`**: optional compact summary from grant response (e.g. **`skillId`**, **`amount`**, **`sourceKind`**) for tests / future wire.
|
||||
|
||||
9. **Reason codes (`GatherReasonCodes`):** Re-export stable subsystem strings where applicable:
|
||||
- **`unknown_node`**, **`node_depleted`**, **`inventory_full`** — same literals as NEO-61 / NEO-54.
|
||||
- **`progression_store_missing`**, **`inventory_store_missing`** — gather-layer codes for store-missing paths.
|
||||
|
||||
10. **Yield quantities (content, fixed):** Alpha **1**, beta **2**, gamma **3**, delta **5** `scrap_metal_bulk` per [`prototype_resource_yields.json`](../../content/resource-nodes/prototype_resource_yields.json). Tests spot-check alpha (1) and optionally delta (5).
|
||||
|
||||
11. **Docs (on land):** Update [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md) **Related implementation slices** + **`GatherResult`** contract note; [documentation_and_implementation_alignment.md](documentation_and_implementation_alignment.md) E3.M1 row; E3M1-06 checkboxes in [E3M1-prototype-backlog.md](E3M1-prototype-backlog.md); brief **`server/README.md`** gather-engine subsection (engine-only; interact in NEO-63).
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `server/NeonSprawl.Server/Game/Gathering/GatherReasonCodes.cs` | Stable gather deny codes (passthrough + store-missing). |
|
||||
| `server/NeonSprawl.Server/Game/Gathering/GatherGrantApplied.cs` | One applied yield row in success envelope. |
|
||||
| `server/NeonSprawl.Server/Game/Gathering/GatherXpGrantSummary.cs` | Compact XP summary on successful gather. |
|
||||
| `server/NeonSprawl.Server/Game/Gathering/GatherResult.cs` | Success/deny envelope: grants, remainingGathers, reasonCode, xp summary. |
|
||||
| `server/NeonSprawl.Server/Game/Gathering/GatherOperations.cs` | Orchestrator: pre-check → inventory → XP → depletion; rollback on XP failure. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Gathering/GatherOperationsTests.cs` | AAA: success path, inventory_full, node_depleted, unknown_node, XP rollback, quantity per yield. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/README.md` | Gather engine subsection: `GatherOperations`, commit order, reason codes; note interact wiring in NEO-63. |
|
||||
| `docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md` | **Related implementation slices** — gather engine bullet (NEO-62); **`GatherResult`** field table alignment. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E3.M1 row — note NEO-62 gather engine when landed. |
|
||||
| `docs/plans/E3M1-prototype-backlog.md` | E3M1-06 acceptance checkboxes when landed. |
|
||||
|
||||
## Tests
|
||||
|
||||
| Test file | What it covers |
|
||||
|-----------|----------------|
|
||||
| `server/NeonSprawl.Server.Tests/Game/Gathering/GatherOperationsTests.cs` | **Integration-style (DI factory):** **`TryGather`** on **`prototype_resource_node_alpha`** — **`Success`**, bag quantity +1 **`scrap_metal_bulk`**, **`salvage`** XP +10, **`RemainingGathers == 9`**. **Full bag:** pre-fill 24 bag slots (`survey_drone_kit` ×1 each) → **`Denied`**, **`inventory_full`**, depletion row unchanged (still lazy-uninitialized or full capacity). **Depletion loop:** exhaust alpha (10 gathers) → next **`node_depleted`**, no inventory/XP change. **`unknown_node`** for bogus id. **XP failure:** custom test factory or stub progression store that returns **`ProgressionStoreMissing`** after inventory add → gather denied, inventory restored to pre-gather snapshot. **Optional:** delta node grants quantity **5**. **AAA** per [csharp-style](../../.cursor/rules/csharp-style.md). |
|
||||
|
||||
No Bruno or HTTP tests — out of scope; manual gather flow waits for NEO-63.
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|----------------------|--------|
|
||||
| **NEO-61 on `main`** | Branch from **`main`** where NEO-59/60/61 are merged (confirmed at kickoff). | **adopted** |
|
||||
| **Duplicate XP via `InteractionApi`** | Leave NEO-41 interact hook until **NEO-63** removes it; engine-only scope for NEO-62. | **adopted** |
|
||||
| **Depletion fails after inventory+XP** | Extremely unlikely after pre-check; defensive inventory rollback, no XP undo — document; no cross-store transaction in prototype. | **deferred** |
|
||||
| **Per-lens yield quantities in tests** | Spot-check alpha (1) + delta (5); full four-node matrix optional. | **adopted** |
|
||||
|
||||
None blocking.
|
||||
|
||||
## Decisions (kickoff)
|
||||
|
||||
- **Commit order:** inventory add → XP grant → depletion decrement **last**.
|
||||
- **XP failure:** deny gather + compensating **`TryRemoveStack`**; depletion not committed.
|
||||
- **Reason codes:** passthrough subsystem literals on gather deny surface; gather-specific codes for store-missing only.
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
# NEO-63 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-63 |
|
||||
| **Title** | E3.M1: Interact path integration + registry expansion + Bruno |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-63/e3m1-interact-path-integration-registry-expansion-bruno |
|
||||
| **Module** | [E3.M1 — ResourceNodeAndGatherLoop](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md) · Epic 3 Slice 2 · backlog **E3M1-07** |
|
||||
| **Branch** | `NEO-63-e3m1-interact-path-integration-registry-expansion-bruno` |
|
||||
| **Precursors** | [NEO-62](https://linear.app/neon-sprawl/issue/NEO-62) — `GatherOperations` + `GatherResult` (**Done** on story branch); [NEO-41](https://linear.app/neon-sprawl/issue/NEO-41) — inline XP on interact (superseded) |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **New node anchors** | X/Z for beta, gamma, delta? | **Beta (18, −6)**, **gamma (12, 0)**, **delta (18, 0)** — spread from alpha **(12, −6)** and terminal **(0, 0)**, same radius **3**. | **Adopted** (no blocking questions at kickoff; user said **begin**). |
|
||||
| **Gather deny on interact** | `Allowed: false` + `reasonCode` vs NEO-41 silent success? | **`Allowed: false`** + passthrough gather **`reasonCode`** per E3M1-07 backlog. | **Adopted**. |
|
||||
| **InteractionResponse payload** | Extend with gather fields? | **Keep placeholder** — verify via GET inventory / skill-progression (module doc). | **Adopted**. |
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Wire **`POST …/interact`** **`resource_node`** path to **`GatherOperations`**; register **four** prototype nodes; Bruno + manual QA for gather → inventory and depletion deny.
|
||||
|
||||
**In scope:** `InteractionApi`, `PrototypeInteractableRegistry` expansion, integration tests, Bruno, README, manual QA.
|
||||
|
||||
**Out of scope:** Godot HUD, gather HTTP route, telemetry hooks (NEO-64).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] In-range interact on fresh node increases inventory quantity and **`salvage`** XP.
|
||||
- [x] Depleted node returns **`node_depleted`** without inventory or XP change.
|
||||
- [x] Terminal interact unchanged (no gather side effects).
|
||||
- [x] Bruno documents happy path + depletion deny for at least one node.
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **`InteractionApi`:** On **`resource_node`** kind after range check, call **`GatherOperations.TryGather`**. Success → **`Allowed: true`**; deny → **`Allowed: false`** + **`ReasonCode`** from **`GatherResult`**. Remove NEO-41 inline **`SkillProgressionGrantOperations`** block.
|
||||
|
||||
2. **Registry:** Add beta/gamma/delta ids + anchors (see kickoff table). **`GET …/interactables`** returns **5** rows (terminal + four nodes).
|
||||
|
||||
3. **Tests:** New **`InteractionResourceNodeGatherIntegrationTests`** — inventory + XP success, depletion deny, terminal control, stub-registry deny. Update **`InteractablesWorldApiTests`** for four nodes. Trim NEO-41 XP-only defensive test (migrated to integration deny test). Fix **`SalvageActivityDeniedRegistryWebApplicationFactory`** for gather DI (items + resource nodes + inventory + instance stores).
|
||||
|
||||
4. **Bruno:** Update alpha gather request; add gather → GET inventory; add depletion deny (pre-request 10 gathers).
|
||||
|
||||
5. **Docs:** README interaction reason codes + gather subsection; E3.M1 module freeze table anchors; alignment + backlog checkboxes.
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `server/NeonSprawl.Server.Tests/Game/Interaction/InteractionResourceNodeGatherIntegrationTests.cs` | HTTP integration: inventory, XP, depletion, terminal, deny registry. |
|
||||
| `bruno/neon-sprawl-server/interaction/Post interact gather then get inventory.bru` | Happy path: interact + assert scrap in bag. |
|
||||
| `bruno/neon-sprawl-server/interaction/Post interact depleted node deny.bru` | Pre-loop 10 gathers on **`prototype_urban_bulk_delta`** (isolated from alpha requests earlier in collection); 11th **`node_depleted`**. |
|
||||
| `docs/manual-qa/NEO-63.md` | curl gather → inventory + depletion deny. |
|
||||
| `docs/plans/NEO-63-implementation-plan.md` | This plan. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/NeonSprawl.Server/Game/Interaction/InteractionApi.cs` | Delegate **`resource_node`** to **`GatherOperations`**; map denies. |
|
||||
| `server/NeonSprawl.Server/Game/Interaction/PrototypeInteractableRegistry.cs` | Three new node anchors + registry entries. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Interaction/InteractablesWorldApiTests.cs` | Assert **5** interactables + new anchor coords. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Interaction/InteractionResourceNodeGatherXpTests.cs` | Remove obsolete NEO-41 silent-deny test; keep terminal + XP smoke. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs` | Full in-memory gather dependencies for deny test host. |
|
||||
| `bruno/neon-sprawl-server/interaction/Post interact prototype resource node gather.bru` | NEO-63 docs (inventory + XP via gather engine). |
|
||||
| `server/README.md` | Gather via interact; reason codes; four anchors. |
|
||||
| `docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md` | Anchor coords; NEO-63 slice bullet. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E3.M1 row — NEO-63 landed. |
|
||||
| `docs/decomposition/modules/module_dependency_register.md` | E3.M1 note — E3M1-07 complete. |
|
||||
| `docs/plans/E3M1-prototype-backlog.md` | E3M1-07 checkboxes. |
|
||||
|
||||
## Tests
|
||||
|
||||
| Test file | What it covers |
|
||||
|-----------|----------------|
|
||||
| `InteractionResourceNodeGatherIntegrationTests.cs` | Success: +1 scrap, +10 salvage XP. Depletion: 10 gathers then **`node_depleted`**. Terminal: no side effects. Stub registry: deny + no grants. |
|
||||
| `InteractablesWorldApiTests.cs` | **5** descriptors; beta/gamma/delta anchors. |
|
||||
| `InteractionResourceNodeGatherXpTests.cs` | Terminal + alpha XP smoke (regression). |
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|----------------------|--------|
|
||||
| **Branch stacks NEO-62** | PR base includes gather engine commits until NEO-62 merges. | **adopted** |
|
||||
| **Client world props** | Godot **`interactable_world_builder`** picks up new ids from GET; no scene edit required for server QA. | **deferred** |
|
||||
|
||||
None blocking.
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
# Code review — NEO-63 interact path integration + NEO-62 gather engine
|
||||
|
||||
**Date:** 2026-05-24
|
||||
**Scope:** Branch `NEO-63-e3m1-interact-path-integration-registry-expansion-bruno` · commits `acf9fe1`–`01dc48a` vs `origin/main` (includes stacked **NEO-62** gather engine commits)
|
||||
**Base:** `origin/main`
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits**
|
||||
|
||||
## Summary
|
||||
|
||||
This branch completes Epic 3 Slice 2 gather wiring: **NEO-62** adds **`GatherOperations.TryGather`** and **`GatherResult`** (inventory → XP → depletion commit, compensating rollback on XP failure); **NEO-63** replaces the NEO-41 silent inline XP path in **`InteractionApi`** with gather-engine delegation, expands **`PrototypeInteractableRegistry`** to four resource-node anchors, and adds HTTP integration tests, Bruno requests, and manual QA. Denies now surface as **`allowed: false`** with stable **`reasonCode`** passthrough (`node_depleted`, `inventory_full`, skill deny codes, etc.). Risk is moderate but well-contained: the orchestration touches inventory, progression, and depletion stores with ordered commits and rollback, but coverage is strong (6 engine tests, 4 new HTTP integration tests, registry/world API updates, Bruno depletion loop). Recommend merging **NEO-62** first or accepting a stacked PR with both stories called out in the PR title/body.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Document | Result |
|
||||
|----------|--------|
|
||||
| [`docs/plans/NEO-62-implementation-plan.md`](../plans/NEO-62-implementation-plan.md) | **Matches** — commit order (inventory → XP → depletion), rollback on XP failure, reason-code passthrough, **`GatherResult`** shape, acceptance checklist complete. |
|
||||
| [`docs/plans/NEO-63-implementation-plan.md`](../plans/NEO-63-implementation-plan.md) | **Matches** — **`InteractionApi`** delegates **`resource_node`** to gather engine; NEO-41 inline XP removed; four registry anchors at kickoff coords; integration + Bruno + manual QA; acceptance checklist complete. |
|
||||
| [`docs/plans/E3M1-prototype-backlog.md`](../plans/E3M1-prototype-backlog.md) | **Matches** — E3M1-06 and E3M1-07 landed notes and checkboxes updated. |
|
||||
| [`docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md`](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md) | **Matches** — four-node freeze table anchors, NEO-62 engine + NEO-63 interact slices, **`GatherResult`** field table, E1.M3 boundary (no separate gather HTTP). |
|
||||
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E3.M1 row notes NEO-62/NEO-63 landed; NEO-64 telemetry still planned. |
|
||||
| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | **Matches** — **E3.M1 note** extended through E3M1-07; row **In Progress** until NEO-64. |
|
||||
| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **Matches** — server resolves gather outcomes; client sends interact intent only. |
|
||||
| [`docs/manual-qa/NEO-63.md`](../manual-qa/NEO-63.md) | **Matches** — happy path, depletion deny, terminal control, Bruno pointers. |
|
||||
| [`server/README.md`](../../server/README.md) | **Matches** — gather engine subsection, gather-via-interact reason codes, four anchors. |
|
||||
|
||||
Register/tracking: alignment table and **E3.M1 note** updated for NEO-62/NEO-63.
|
||||
|
||||
## Blocking issues
|
||||
|
||||
None.
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**AAA layout in depletion integration test** — `InteractionResourceNodeGatherIntegrationTests.PostInteract_ResourceNode_WhenDepleted_ShouldDenyWithNodeDepletedWithoutGrants` asserts **`Allowed`** inside the Arrange loop (10 pre-gathers). Move loop invariants to **Act** (fire-and-forget or collect last response) and keep all expectations in **Assert**, per [csharp-style](../../.cursor/rules/csharp-style.md#unit-and-integration-tests-arrange-act-assert) — same pattern flagged on NEO-61.~~ **Done.** — pre-gather loop moved to **Act**; **`Assert.All`** on successful responses; depletion + totals asserted in **Assert**.
|
||||
|
||||
2. ~~**Tighten skill-deny integration assertion** — `PostInteract_ResourceNode_WhenSalvageDisallowsActivity_ShouldDenyWithoutGrants` only checks `ReasonCode` is non-empty. Assert the expected passthrough **`source_kind_not_allowed`** (from `SkillProgressionSnapshotApi.ReasonSourceKindNotAllowed`) so regressions in XP rollback + reason mapping are caught explicitly.~~ **Done.**
|
||||
|
||||
3. ~~**Optional HTTP `inventory_full` deny** — Engine test covers full-bag deny without depletion; no interact-level test yet. Low priority since **`GatherOperationsTests`** already locks the rule; add one HTTP test if you want parity with depletion deny at the wire boundary.~~ **Done.** — **`PostInteract_ResourceNode_WhenBagFull_ShouldDenyWithInventoryFullWithoutDepletingNode`**.
|
||||
|
||||
4. ~~**Test duplication (optional cleanup)** — `InteractionResourceNodeGatherXpTests` overlaps with `InteractionResourceNodeGatherIntegrationTests` on terminal control and alpha XP smoke. Plan kept both for regression; consider trimming the legacy file to terminal-only once NEO-63 integration tests are trusted.~~ **Done.** — legacy file trimmed to terminal-only; **`InteractionApiTests`** summary points at NEO-63 integration tests.
|
||||
|
||||
## Nits
|
||||
|
||||
- ~~Nit: **`SkillProgressionGrantOperations`** summary still references “NEO-41 gather interact hook”; interact now routes through **`GatherOperations`** (NEO-63).~~ **Done.**
|
||||
|
||||
- ~~Nit: **`GatherOperations`** class comment says “Interact wiring is NEO-63” — wiring is landed; update to “wired from **`InteractionApi`** (NEO-63)”.~~ **Done.**
|
||||
|
||||
- Nit: Stacked branch — five commits include NEO-62 plan + engine before NEO-63 wiring. Call out both issue keys in the PR title/description so reviewers know scope.
|
||||
|
||||
- Nit: Beta/gamma/delta per-node yield quantities (2/3/5) are validated in **`GatherOperationsTests`** (delta) but not via HTTP interact on the new anchors; acceptable for prototype, optional Bruno spot-check on delta.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd server
|
||||
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj --filter "FullyQualifiedName~Gather|FullyQualifiedName~Interaction"
|
||||
# Passed! Failed: 0, Passed: 70
|
||||
|
||||
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj
|
||||
# Passed! Failed: 0, Passed: 292
|
||||
```
|
||||
|
||||
Manual / Bruno: run `docs/manual-qa/NEO-63.md` curl steps; Bruno folder `bruno/neon-sprawl-server/interaction/` — **`Post interact gather then get inventory.bru`**, **`Post interact depleted node deny.bru`**.
|
||||
|
||||
Postgres persistence (when `ConnectionStrings__NeonSprawl` is set): gather depletion rows should persist across restarts via existing NEO-61 store (not re-tested in this diff).
|
||||
|
|
@ -0,0 +1,311 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NeonSprawl.Server.Game.Gathering;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.Mastery;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
using NeonSprawl.Server.Tests;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Gathering;
|
||||
|
||||
public sealed class GatherOperationsTests
|
||||
{
|
||||
private const string PlayerId = "dev-local-1";
|
||||
private const string AlphaId = "prototype_resource_node_alpha";
|
||||
private const string DeltaId = "prototype_urban_bulk_delta";
|
||||
|
||||
[Fact]
|
||||
public void TryGather_OnFreshAlphaNode_ShouldGrantItemXpAndDecrementCapacity()
|
||||
{
|
||||
// Arrange
|
||||
using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveGatherDependencies(factory);
|
||||
|
||||
// Act
|
||||
var result = GatherOperations.TryGather(
|
||||
PlayerId,
|
||||
AlphaId,
|
||||
deps.NodeRegistry,
|
||||
deps.ItemRegistry,
|
||||
deps.InventoryStore,
|
||||
deps.SkillRegistry,
|
||||
deps.XpStore,
|
||||
deps.LevelCurve,
|
||||
deps.PerkUnlockEngine,
|
||||
deps.InstanceStore);
|
||||
|
||||
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
|
||||
var salvageXp = deps.XpStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.Null(result.ReasonCode);
|
||||
Assert.Single(result.GrantsApplied);
|
||||
Assert.Equal("scrap_metal_bulk", result.GrantsApplied[0].ItemId);
|
||||
Assert.Equal(1, result.GrantsApplied[0].Quantity);
|
||||
Assert.Equal(9, result.RemainingGathers);
|
||||
Assert.NotNull(result.XpGrantSummary);
|
||||
Assert.Equal("salvage", result.XpGrantSummary!.Value.SkillId);
|
||||
Assert.Equal(GatherSkillXpConstants.ActivityXpPerResourceNodeGather, result.XpGrantSummary.Value.Amount);
|
||||
Assert.Equal(GatherSkillXpConstants.ActivitySourceKind, result.XpGrantSummary.Value.SourceKind);
|
||||
Assert.Equal(1, CountItem(inventory!, "scrap_metal_bulk"));
|
||||
Assert.Equal(10, salvageXp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGather_WhenBagFull_ShouldDenyWithInventoryFullWithoutDepletingNode()
|
||||
{
|
||||
// Arrange
|
||||
using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveGatherDependencies(factory);
|
||||
for (var i = 0; i < PlayerInventorySnapshot.BagSlotCount; i++)
|
||||
{
|
||||
var add = PlayerInventoryOperations.TryAddStack(
|
||||
PlayerId,
|
||||
"survey_drone_kit",
|
||||
quantity: 1,
|
||||
deps.ItemRegistry,
|
||||
deps.InventoryStore);
|
||||
Assert.Equal(PlayerInventoryMutationKind.Applied, add.Kind);
|
||||
}
|
||||
|
||||
deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory);
|
||||
var salvageXpBefore = deps.XpStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage");
|
||||
|
||||
// Act
|
||||
var result = GatherOperations.TryGather(
|
||||
PlayerId,
|
||||
AlphaId,
|
||||
deps.NodeRegistry,
|
||||
deps.ItemRegistry,
|
||||
deps.InventoryStore,
|
||||
deps.SkillRegistry,
|
||||
deps.XpStore,
|
||||
deps.LevelCurve,
|
||||
deps.PerkUnlockEngine,
|
||||
deps.InstanceStore);
|
||||
|
||||
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
|
||||
var salvageXpAfter = deps.XpStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage");
|
||||
deps.InstanceStore.TryGetRemainingGathers(AlphaId, out var nodeSnapshot);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(GatherReasonCodes.InventoryFull, result.ReasonCode);
|
||||
Assert.Equal(TotalBagQuantity(beforeInventory!), TotalBagQuantity(afterInventory!));
|
||||
Assert.Equal(salvageXpBefore, salvageXpAfter);
|
||||
Assert.True(deps.InstanceStore.TryGetRemainingGathers(AlphaId, out nodeSnapshot));
|
||||
Assert.Equal(10, nodeSnapshot.RemainingGathers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGather_AfterCapacityExhausted_ShouldReturnNodeDepletedWithoutSideEffects()
|
||||
{
|
||||
// Arrange
|
||||
using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveGatherDependencies(factory);
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
var gather = GatherOperations.TryGather(
|
||||
PlayerId,
|
||||
AlphaId,
|
||||
deps.NodeRegistry,
|
||||
deps.ItemRegistry,
|
||||
deps.InventoryStore,
|
||||
deps.SkillRegistry,
|
||||
deps.XpStore,
|
||||
deps.LevelCurve,
|
||||
deps.PerkUnlockEngine,
|
||||
deps.InstanceStore);
|
||||
Assert.True(gather.Success);
|
||||
}
|
||||
|
||||
deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory);
|
||||
var salvageXpBefore = deps.XpStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage");
|
||||
|
||||
// Act
|
||||
var result = GatherOperations.TryGather(
|
||||
PlayerId,
|
||||
AlphaId,
|
||||
deps.NodeRegistry,
|
||||
deps.ItemRegistry,
|
||||
deps.InventoryStore,
|
||||
deps.SkillRegistry,
|
||||
deps.XpStore,
|
||||
deps.LevelCurve,
|
||||
deps.PerkUnlockEngine,
|
||||
deps.InstanceStore);
|
||||
|
||||
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
|
||||
var salvageXpAfter = deps.XpStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(GatherReasonCodes.NodeDepleted, result.ReasonCode);
|
||||
Assert.Equal(0, result.RemainingGathers);
|
||||
Assert.Equal(TotalBagQuantity(beforeInventory!), TotalBagQuantity(afterInventory!));
|
||||
Assert.Equal(salvageXpBefore, salvageXpAfter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGather_ForUnknownNode_ShouldDenyWithoutCreatingInstanceRow()
|
||||
{
|
||||
// Arrange
|
||||
using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveGatherDependencies(factory);
|
||||
const string unknownId = "not_a_real_node_def";
|
||||
|
||||
// Act
|
||||
var result = GatherOperations.TryGather(
|
||||
PlayerId,
|
||||
unknownId,
|
||||
deps.NodeRegistry,
|
||||
deps.ItemRegistry,
|
||||
deps.InventoryStore,
|
||||
deps.SkillRegistry,
|
||||
deps.XpStore,
|
||||
deps.LevelCurve,
|
||||
deps.PerkUnlockEngine,
|
||||
deps.InstanceStore);
|
||||
|
||||
var rowExists = deps.InstanceStore.TryGetRemainingGathers(unknownId, out _);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(GatherReasonCodes.UnknownNode, result.ReasonCode);
|
||||
Assert.False(rowExists);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGather_WhenProgressionStoreMissing_ShouldRollbackInventoryAndDeny()
|
||||
{
|
||||
// Arrange
|
||||
using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveGatherDependencies(factory);
|
||||
var failingXpStore = new ProgressionStoreAlwaysMissing();
|
||||
deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory);
|
||||
|
||||
// Act
|
||||
var result = GatherOperations.TryGather(
|
||||
PlayerId,
|
||||
AlphaId,
|
||||
deps.NodeRegistry,
|
||||
deps.ItemRegistry,
|
||||
deps.InventoryStore,
|
||||
deps.SkillRegistry,
|
||||
failingXpStore,
|
||||
deps.LevelCurve,
|
||||
deps.PerkUnlockEngine,
|
||||
deps.InstanceStore);
|
||||
|
||||
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
|
||||
deps.InstanceStore.TryGetRemainingGathers(AlphaId, out var nodeSnapshot);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(GatherReasonCodes.ProgressionStoreMissing, result.ReasonCode);
|
||||
Assert.Equal(TotalBagQuantity(beforeInventory!), TotalBagQuantity(afterInventory!));
|
||||
Assert.True(deps.InstanceStore.TryGetRemainingGathers(AlphaId, out nodeSnapshot));
|
||||
Assert.Equal(10, nodeSnapshot.RemainingGathers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGather_OnDeltaNode_ShouldGrantConfiguredYieldQuantity()
|
||||
{
|
||||
// Arrange
|
||||
using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveGatherDependencies(factory);
|
||||
|
||||
// Act
|
||||
var result = GatherOperations.TryGather(
|
||||
PlayerId,
|
||||
DeltaId,
|
||||
deps.NodeRegistry,
|
||||
deps.ItemRegistry,
|
||||
deps.InventoryStore,
|
||||
deps.SkillRegistry,
|
||||
deps.XpStore,
|
||||
deps.LevelCurve,
|
||||
deps.PerkUnlockEngine,
|
||||
deps.InstanceStore);
|
||||
|
||||
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.Single(result.GrantsApplied);
|
||||
Assert.Equal(5, result.GrantsApplied[0].Quantity);
|
||||
Assert.Equal(5, CountItem(inventory!, "scrap_metal_bulk"));
|
||||
}
|
||||
|
||||
private static GatherTestDependencies ResolveGatherDependencies(InMemoryWebApplicationFactory factory)
|
||||
{
|
||||
var services = factory.Services;
|
||||
return new GatherTestDependencies(
|
||||
services.GetRequiredService<IResourceNodeDefinitionRegistry>(),
|
||||
services.GetRequiredService<IItemDefinitionRegistry>(),
|
||||
services.GetRequiredService<IPlayerInventoryStore>(),
|
||||
services.GetRequiredService<ISkillDefinitionRegistry>(),
|
||||
services.GetRequiredService<IPlayerSkillProgressionStore>(),
|
||||
services.GetRequiredService<ISkillLevelCurve>(),
|
||||
services.GetRequiredService<PerkUnlockEngine>(),
|
||||
services.GetRequiredService<IResourceNodeInstanceStore>());
|
||||
}
|
||||
|
||||
private static int CountItem(PlayerInventorySnapshot snapshot, string itemId)
|
||||
{
|
||||
var total = 0;
|
||||
foreach (var slot in snapshot.BagSlots)
|
||||
{
|
||||
if (string.Equals(slot.ItemId, itemId, StringComparison.Ordinal))
|
||||
{
|
||||
total += slot.Quantity;
|
||||
}
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
private static int TotalBagQuantity(PlayerInventorySnapshot snapshot)
|
||||
{
|
||||
var total = 0;
|
||||
foreach (var slot in snapshot.BagSlots)
|
||||
{
|
||||
total += slot.Quantity;
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
private readonly record struct GatherTestDependencies(
|
||||
IResourceNodeDefinitionRegistry NodeRegistry,
|
||||
IItemDefinitionRegistry ItemRegistry,
|
||||
IPlayerInventoryStore InventoryStore,
|
||||
ISkillDefinitionRegistry SkillRegistry,
|
||||
IPlayerSkillProgressionStore XpStore,
|
||||
ISkillLevelCurve LevelCurve,
|
||||
PerkUnlockEngine PerkUnlockEngine,
|
||||
IResourceNodeInstanceStore InstanceStore);
|
||||
|
||||
private sealed class ProgressionStoreAlwaysMissing : IPlayerSkillProgressionStore
|
||||
{
|
||||
public IReadOnlyDictionary<string, int> GetXpTotals(string playerId) =>
|
||||
new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
|
||||
public bool TryApplyXpDelta(
|
||||
string playerId,
|
||||
string skillId,
|
||||
int amount,
|
||||
out int previousXp,
|
||||
out int newXp)
|
||||
{
|
||||
previousXp = 0;
|
||||
newXp = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TrySetSkillXpTotal(string playerId, string skillId, int xp) => false;
|
||||
|
||||
public bool TrySetSkillXpTotals(string playerId, IReadOnlyDictionary<string, int> skillXpTotals) => false;
|
||||
}
|
||||
}
|
||||
|
|
@ -25,7 +25,7 @@ public class InteractablesWorldApiTests
|
|||
Assert.NotNull(body);
|
||||
Assert.Equal(InteractablesListResponse.CurrentSchemaVersion, body!.SchemaVersion);
|
||||
Assert.NotNull(body.Interactables);
|
||||
Assert.True(body.Interactables.Count >= 2);
|
||||
Assert.Equal(5, body.Interactables.Count);
|
||||
|
||||
var ids = body.Interactables.Select(static x => x.InteractableId).ToList();
|
||||
Assert.Equal(ids.OrderBy(static x => x, StringComparer.Ordinal).ToList(), ids);
|
||||
|
|
@ -37,6 +37,21 @@ public class InteractablesWorldApiTests
|
|||
Assert.Equal(-6, alpha.Anchor.Z);
|
||||
Assert.Equal(3.0, alpha.InteractionRadius);
|
||||
|
||||
var beta = body.Interactables.Single(x => x.InteractableId == PrototypeInteractableRegistry.PrototypeSubsurfaceVeinBetaId);
|
||||
Assert.Equal("resource_node", beta.Kind);
|
||||
Assert.Equal(18, beta.Anchor.X);
|
||||
Assert.Equal(-6, beta.Anchor.Z);
|
||||
|
||||
var gamma = body.Interactables.Single(x => x.InteractableId == PrototypeInteractableRegistry.PrototypeBioMatGammaId);
|
||||
Assert.Equal("resource_node", gamma.Kind);
|
||||
Assert.Equal(12, gamma.Anchor.X);
|
||||
Assert.Equal(0, gamma.Anchor.Z);
|
||||
|
||||
var delta = body.Interactables.Single(x => x.InteractableId == PrototypeInteractableRegistry.PrototypeUrbanBulkDeltaId);
|
||||
Assert.Equal("resource_node", delta.Kind);
|
||||
Assert.Equal(18, delta.Anchor.X);
|
||||
Assert.Equal(0, delta.Anchor.Z);
|
||||
|
||||
var term = body.Interactables.Single(x => x.InteractableId == PrototypeInteractableRegistry.PrototypeTerminalId);
|
||||
Assert.Equal("terminal", term.Kind);
|
||||
Assert.Equal(0, term.Anchor.X);
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ using Xunit;
|
|||
|
||||
namespace NeonSprawl.Server.Tests.Game.Interaction;
|
||||
|
||||
/// <summary>Integration tests for <see cref="InteractionApi"/> (NS-18). NEO-41: <see cref="InteractionResourceNodeGatherXpTests"/> covers gather XP on <c>resource_node</c> kind.</summary>
|
||||
/// <summary>Integration tests for <see cref="InteractionApi"/> (NS-18). NEO-63: <see cref="InteractionResourceNodeGatherIntegrationTests"/> covers gather via interact.</summary>
|
||||
public class InteractionApiTests
|
||||
{
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,251 @@
|
|||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using NeonSprawl.Server.Game.Gathering;
|
||||
using NeonSprawl.Server.Game.Interaction;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
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-63: <c>resource_node</c> interact delegates to <see cref="GatherOperations"/>.</summary>
|
||||
public sealed class InteractionResourceNodeGatherIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task PostInteract_ResourceNode_WhenInRange_ShouldGrantInventoryAndSalvageXp()
|
||||
{
|
||||
// Arrange — node at (12, -6), radius 3; stand at (10, -6) → horizontal distance 2
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
await MoveNearAlphaAsync(client);
|
||||
|
||||
// Act
|
||||
var interact = await client.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/interact",
|
||||
new InteractionRequest
|
||||
{
|
||||
SchemaVersion = InteractionRequest.CurrentSchemaVersion,
|
||||
InteractableId = PrototypeInteractableRegistry.PrototypeResourceNodeAlphaId,
|
||||
});
|
||||
var inventory = await client.GetAsync("/game/players/dev-local-1/inventory");
|
||||
var progression = 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(PrototypeInteractableRegistry.PrototypeResourceNodeAlphaId, envelope.InteractableId);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, inventory.StatusCode);
|
||||
var bag = await inventory.Content.ReadFromJsonAsync<PlayerInventorySnapshotResponse>();
|
||||
Assert.NotNull(bag);
|
||||
Assert.Equal(1, CountItem(bag!, "scrap_metal_bulk"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, progression.StatusCode);
|
||||
var skills = await progression.Content.ReadFromJsonAsync<SkillProgressionSnapshotResponse>();
|
||||
Assert.NotNull(skills);
|
||||
var salvage = skills!.Skills!.Single(static s => s.Id == "salvage");
|
||||
Assert.Equal(GatherSkillXpConstants.ActivityXpPerResourceNodeGather, salvage.Xp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostInteract_ResourceNode_WhenDepleted_ShouldDenyWithNodeDepletedWithoutGrants()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
await MoveNearAlphaAsync(client);
|
||||
var request = new InteractionRequest
|
||||
{
|
||||
SchemaVersion = InteractionRequest.CurrentSchemaVersion,
|
||||
InteractableId = PrototypeInteractableRegistry.PrototypeResourceNodeAlphaId,
|
||||
};
|
||||
|
||||
// Act — exhaust capacity, then one more interact
|
||||
var successfulGathers = new InteractionResponse[10];
|
||||
for (var i = 0; i < successfulGathers.Length; i++)
|
||||
{
|
||||
var gather = await client.PostAsJsonAsync("/game/players/dev-local-1/interact", request);
|
||||
successfulGathers[i] = (await gather.Content.ReadFromJsonAsync<InteractionResponse>())!;
|
||||
}
|
||||
|
||||
var depletedInteract = await client.PostAsJsonAsync("/game/players/dev-local-1/interact", request);
|
||||
var inventoryAfter = await client.GetAsync("/game/players/dev-local-1/inventory");
|
||||
var progressionAfter = await client.GetAsync("/game/players/dev-local-1/skill-progression");
|
||||
|
||||
// Assert
|
||||
Assert.All(successfulGathers, static r => Assert.True(r.Allowed));
|
||||
Assert.Equal(HttpStatusCode.OK, depletedInteract.StatusCode);
|
||||
var envelope = await depletedInteract.Content.ReadFromJsonAsync<InteractionResponse>();
|
||||
Assert.NotNull(envelope);
|
||||
Assert.False(envelope!.Allowed);
|
||||
Assert.Equal(GatherReasonCodes.NodeDepleted, envelope.ReasonCode);
|
||||
Assert.Equal(10, CountItem(
|
||||
(await inventoryAfter.Content.ReadFromJsonAsync<PlayerInventorySnapshotResponse>())!,
|
||||
"scrap_metal_bulk"));
|
||||
Assert.Equal(
|
||||
10 * GatherSkillXpConstants.ActivityXpPerResourceNodeGather,
|
||||
(await progressionAfter.Content.ReadFromJsonAsync<SkillProgressionSnapshotResponse>())!
|
||||
.Skills!.Single(static s => s.Id == "salvage").Xp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostInteract_ResourceNode_WhenBagFull_ShouldDenyWithInventoryFullWithoutDepletingNode()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
for (var i = 0; i < PlayerInventorySnapshot.BagSlotCount; i++)
|
||||
{
|
||||
await client.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/inventory",
|
||||
new PlayerInventoryMutationRequest
|
||||
{
|
||||
SchemaVersion = PlayerInventoryMutationRequest.CurrentSchemaVersion,
|
||||
MutationKind = "add",
|
||||
ItemId = "survey_drone_kit",
|
||||
Quantity = 1,
|
||||
});
|
||||
}
|
||||
|
||||
await MoveNearAlphaAsync(client);
|
||||
var inventoryBefore = await client.GetAsync("/game/players/dev-local-1/inventory");
|
||||
var progressionBefore = await client.GetAsync("/game/players/dev-local-1/skill-progression");
|
||||
var scrapBefore = CountItem(
|
||||
(await inventoryBefore.Content.ReadFromJsonAsync<PlayerInventorySnapshotResponse>())!,
|
||||
"scrap_metal_bulk");
|
||||
var salvageBefore = (await progressionBefore.Content.ReadFromJsonAsync<SkillProgressionSnapshotResponse>())!
|
||||
.Skills!.Single(static s => s.Id == "salvage").Xp;
|
||||
|
||||
// Act
|
||||
var interact = await client.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/interact",
|
||||
new InteractionRequest
|
||||
{
|
||||
SchemaVersion = InteractionRequest.CurrentSchemaVersion,
|
||||
InteractableId = PrototypeInteractableRegistry.PrototypeResourceNodeAlphaId,
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, interact.StatusCode);
|
||||
var envelope = await interact.Content.ReadFromJsonAsync<InteractionResponse>();
|
||||
Assert.NotNull(envelope);
|
||||
Assert.False(envelope!.Allowed);
|
||||
Assert.Equal(GatherReasonCodes.InventoryFull, envelope.ReasonCode);
|
||||
Assert.Equal(
|
||||
scrapBefore,
|
||||
CountItem(
|
||||
(await (await client.GetAsync("/game/players/dev-local-1/inventory")).Content
|
||||
.ReadFromJsonAsync<PlayerInventorySnapshotResponse>())!,
|
||||
"scrap_metal_bulk"));
|
||||
Assert.Equal(
|
||||
salvageBefore,
|
||||
(await (await client.GetAsync("/game/players/dev-local-1/skill-progression")).Content
|
||||
.ReadFromJsonAsync<SkillProgressionSnapshotResponse>())!
|
||||
.Skills!.Single(static s => s.Id == "salvage").Xp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostInteract_PrototypeTerminal_ShouldNotGrantInventoryOrSalvageXp()
|
||||
{
|
||||
// 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 inventory = await client.GetAsync("/game/players/dev-local-1/inventory");
|
||||
var progression = 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);
|
||||
|
||||
var bag = await inventory.Content.ReadFromJsonAsync<PlayerInventorySnapshotResponse>();
|
||||
Assert.NotNull(bag);
|
||||
Assert.Equal(0, CountItem(bag!, "scrap_metal_bulk"));
|
||||
|
||||
var skills = await progression.Content.ReadFromJsonAsync<SkillProgressionSnapshotResponse>();
|
||||
Assert.NotNull(skills);
|
||||
var salvage = skills!.Skills!.Single(static s => s.Id == "salvage");
|
||||
Assert.Equal(0, salvage.Xp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostInteract_ResourceNode_WhenSalvageDisallowsActivity_ShouldDenyWithoutGrants()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new SalvageActivityDeniedRegistryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
await MoveNearAlphaAsync(client);
|
||||
|
||||
// Act
|
||||
var interact = await client.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/interact",
|
||||
new InteractionRequest
|
||||
{
|
||||
SchemaVersion = InteractionRequest.CurrentSchemaVersion,
|
||||
InteractableId = PrototypeInteractableRegistry.PrototypeResourceNodeAlphaId,
|
||||
});
|
||||
var inventory = await client.GetAsync("/game/players/dev-local-1/inventory");
|
||||
var progression = 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.False(envelope!.Allowed);
|
||||
Assert.Equal(SkillProgressionSnapshotApi.ReasonSourceKindNotAllowed, envelope.ReasonCode);
|
||||
|
||||
var bag = await inventory.Content.ReadFromJsonAsync<PlayerInventorySnapshotResponse>();
|
||||
Assert.NotNull(bag);
|
||||
Assert.Equal(0, CountItem(bag!, "scrap_metal_bulk"));
|
||||
|
||||
var skills = await progression.Content.ReadFromJsonAsync<SkillProgressionSnapshotResponse>();
|
||||
Assert.NotNull(skills);
|
||||
var salvage = skills!.Skills!.Single(static s => s.Id == "salvage");
|
||||
Assert.Equal(0, salvage.Xp);
|
||||
}
|
||||
|
||||
private static async Task MoveNearAlphaAsync(HttpClient client)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
private static int CountItem(PlayerInventorySnapshotResponse snapshot, string itemId)
|
||||
{
|
||||
var total = 0;
|
||||
foreach (var slot in snapshot.BagSlots!)
|
||||
{
|
||||
if (string.Equals(slot.ItemId, itemId, StringComparison.Ordinal))
|
||||
{
|
||||
total += slot.Quantity;
|
||||
}
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@ 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>
|
||||
/// <summary>NEO-41 terminal-only regression; gather side effects covered by NEO-63 integration tests.</summary>
|
||||
public sealed class InteractionResourceNodeGatherXpTests
|
||||
{
|
||||
[Fact]
|
||||
|
|
@ -43,76 +43,4 @@ public sealed class InteractionResourceNodeGatherXpTests
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ using Microsoft.Extensions.DependencyInjection;
|
|||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using NeonSprawl.Server.Game.AbilityInput;
|
||||
using NeonSprawl.Server.Game.Gathering;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.Mastery;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
|
|
@ -15,7 +17,7 @@ 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.
|
||||
/// where <c>salvage</c> does not allow <c>activity</c> — for gather deny tests without mutating disk catalog.
|
||||
/// </summary>
|
||||
public sealed class SalvageActivityDeniedRegistryWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
|
|
@ -29,8 +31,17 @@ public sealed class SalvageActivityDeniedRegistryWebApplicationFactory : WebAppl
|
|||
var masteryDir = MasteryCatalogPathResolution.TryDiscoverMasteryDirectory(AppContext.BaseDirectory)
|
||||
?? throw new InvalidOperationException(
|
||||
"Could not discover repo content/mastery from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||
var itemsDir = ItemCatalogPathResolution.TryDiscoverItemsDirectory(AppContext.BaseDirectory)
|
||||
?? throw new InvalidOperationException(
|
||||
"Could not discover repo content/items from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||
var resourceNodesDir = ResourceNodeCatalogPathResolution.TryDiscoverResourceNodesDirectory(AppContext.BaseDirectory)
|
||||
?? throw new InvalidOperationException(
|
||||
"Could not discover repo content/resource-nodes from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||
builder.UseSetting("Content:SkillsDirectory", skillsDir);
|
||||
builder.UseSetting("Content:MasteryDirectory", masteryDir);
|
||||
builder.UseSetting("Content:ItemsDirectory", itemsDir);
|
||||
builder.UseSetting("Content:ResourceNodesDirectory", resourceNodesDir);
|
||||
builder.UseSetting("Game:EnableMasteryFixtureApi", "true");
|
||||
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
|
|
@ -40,6 +51,8 @@ public sealed class SalvageActivityDeniedRegistryWebApplicationFactory : WebAppl
|
|||
if (d.ServiceType == typeof(IPositionStateStore) ||
|
||||
d.ServiceType == typeof(IPlayerHotbarLoadoutStore) ||
|
||||
d.ServiceType == typeof(IPlayerSkillProgressionStore) ||
|
||||
d.ServiceType == typeof(IPlayerInventoryStore) ||
|
||||
d.ServiceType == typeof(IResourceNodeInstanceStore) ||
|
||||
d.ServiceType == typeof(IPlayerPerkStateStore) ||
|
||||
d.ServiceType == typeof(TimeProvider) ||
|
||||
d.ServiceType == typeof(IPlayerAbilityCooldownStore) ||
|
||||
|
|
@ -58,6 +71,8 @@ public sealed class SalvageActivityDeniedRegistryWebApplicationFactory : WebAppl
|
|||
services.AddSingleton<IPositionStateStore, InMemoryPositionStateStore>();
|
||||
services.AddSingleton<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>();
|
||||
services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>();
|
||||
services.AddSingleton<IPlayerInventoryStore, InMemoryPlayerInventoryStore>();
|
||||
services.AddSingleton<IResourceNodeInstanceStore, InMemoryResourceNodeInstanceStore>();
|
||||
services.AddSingleton<IPlayerPerkStateStore, InMemoryPlayerPerkStateStore>();
|
||||
services.AddSingleton<IPlayerAbilityCooldownStore, InMemoryPlayerAbilityCooldownStore>();
|
||||
services.AddSingleton<ISkillDefinitionRegistry, SalvageActivityDeniedSkillRegistry>();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
namespace NeonSprawl.Server.Game.Gathering;
|
||||
|
||||
/// <summary>One item grant applied on a successful gather (NEO-62).</summary>
|
||||
public readonly record struct GatherGrantApplied(string ItemId, int Quantity);
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.Mastery;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Gathering;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates gather side effects: capacity pre-check, inventory grant, skill XP, depletion commit (NEO-62).
|
||||
/// Wired from <see cref="NeonSprawl.Server.Game.Interaction.InteractionApi"/> on <c>resource_node</c> interact (NEO-63).
|
||||
/// </summary>
|
||||
public static class GatherOperations
|
||||
{
|
||||
private static readonly GatherGrantApplied[] EmptyGrants = [];
|
||||
|
||||
/// <summary>
|
||||
/// Resolves one gather for <paramref name="playerId"/> at <paramref name="interactableId"/>.
|
||||
/// Mutations commit in order: inventory add → XP grant → depletion decrement (last).
|
||||
/// </summary>
|
||||
public static GatherResult TryGather(
|
||||
string playerId,
|
||||
string interactableId,
|
||||
IResourceNodeDefinitionRegistry nodeRegistry,
|
||||
IItemDefinitionRegistry itemRegistry,
|
||||
IPlayerInventoryStore inventoryStore,
|
||||
ISkillDefinitionRegistry skillRegistry,
|
||||
IPlayerSkillProgressionStore xpStore,
|
||||
ISkillLevelCurve levelCurve,
|
||||
PerkUnlockEngine perkUnlockEngine,
|
||||
IResourceNodeInstanceStore instanceStore)
|
||||
{
|
||||
var key = ResourceNodeInstanceIds.Normalize(interactableId);
|
||||
if (key.Length == 0 ||
|
||||
!nodeRegistry.TryGetDefinition(key, out var definition) ||
|
||||
!nodeRegistry.TryGetYield(key, out var yield))
|
||||
{
|
||||
return Deny(GatherReasonCodes.UnknownNode, null);
|
||||
}
|
||||
|
||||
if (!instanceStore.TryEnsureInitialized(key, definition.MaxGathers))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to initialize resource node instance '{key}' with maxGathers {definition.MaxGathers}.");
|
||||
}
|
||||
|
||||
if (!instanceStore.TryGetRemainingGathers(key, out var capacity) || capacity.RemainingGathers <= 0)
|
||||
{
|
||||
var remaining = capacity.RemainingGathers;
|
||||
return Deny(GatherReasonCodes.NodeDepleted, remaining);
|
||||
}
|
||||
|
||||
var inventoryOutcome = PlayerInventoryOperations.TryAddStack(
|
||||
playerId,
|
||||
yield.ItemId,
|
||||
yield.Quantity,
|
||||
itemRegistry,
|
||||
inventoryStore);
|
||||
|
||||
if (inventoryOutcome.Kind == PlayerInventoryMutationKind.StoreMissing)
|
||||
{
|
||||
return Deny(GatherReasonCodes.InventoryStoreMissing, capacity.RemainingGathers);
|
||||
}
|
||||
|
||||
if (inventoryOutcome.Kind == PlayerInventoryMutationKind.Denied)
|
||||
{
|
||||
return Deny(inventoryOutcome.ReasonCode ?? GatherReasonCodes.InventoryFull, capacity.RemainingGathers);
|
||||
}
|
||||
|
||||
var xpOutcome = SkillProgressionGrantOperations.TryApplyGrant(
|
||||
playerId,
|
||||
GatherSkillXpConstants.SalvageSkillId,
|
||||
GatherSkillXpConstants.ActivityXpPerResourceNodeGather,
|
||||
GatherSkillXpConstants.ActivitySourceKind,
|
||||
skillRegistry,
|
||||
xpStore,
|
||||
levelCurve,
|
||||
perkUnlockEngine);
|
||||
|
||||
if (xpOutcome.Kind != SkillProgressionGrantApplyKind.Granted)
|
||||
{
|
||||
var xpReason = xpOutcome.Kind == SkillProgressionGrantApplyKind.ProgressionStoreMissing
|
||||
? GatherReasonCodes.ProgressionStoreMissing
|
||||
: xpOutcome.Response?.ReasonCode ?? GatherReasonCodes.ProgressionStoreMissing;
|
||||
|
||||
_ = PlayerInventoryOperations.TryRemoveStack(
|
||||
playerId,
|
||||
yield.ItemId,
|
||||
yield.Quantity,
|
||||
itemRegistry,
|
||||
inventoryStore);
|
||||
|
||||
return Deny(xpReason, capacity.RemainingGathers);
|
||||
}
|
||||
|
||||
var depletionOutcome = ResourceNodeInstanceOperations.TryCommitSuccessfulGatherDecrement(
|
||||
key,
|
||||
nodeRegistry,
|
||||
instanceStore);
|
||||
|
||||
if (depletionOutcome.Kind != ResourceNodeInstanceMutationKind.Applied)
|
||||
{
|
||||
_ = PlayerInventoryOperations.TryRemoveStack(
|
||||
playerId,
|
||||
yield.ItemId,
|
||||
yield.Quantity,
|
||||
itemRegistry,
|
||||
inventoryStore);
|
||||
|
||||
var depletedRemaining = depletionOutcome.Snapshot?.RemainingGathers ?? 0;
|
||||
return Deny(
|
||||
depletionOutcome.ReasonCode ?? GatherReasonCodes.NodeDepleted,
|
||||
depletedRemaining);
|
||||
}
|
||||
|
||||
return new GatherResult(
|
||||
Success: true,
|
||||
ReasonCode: null,
|
||||
GrantsApplied: [new GatherGrantApplied(yield.ItemId, yield.Quantity)],
|
||||
RemainingGathers: depletionOutcome.Snapshot!.Value.RemainingGathers,
|
||||
XpGrantSummary: new GatherXpGrantSummary(
|
||||
GatherSkillXpConstants.SalvageSkillId,
|
||||
GatherSkillXpConstants.ActivityXpPerResourceNodeGather,
|
||||
GatherSkillXpConstants.ActivitySourceKind));
|
||||
}
|
||||
|
||||
private static GatherResult Deny(string reasonCode, int? remainingGathers) =>
|
||||
new(
|
||||
Success: false,
|
||||
ReasonCode: reasonCode,
|
||||
GrantsApplied: EmptyGrants,
|
||||
RemainingGathers: remainingGathers,
|
||||
XpGrantSummary: null);
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
using NeonSprawl.Server.Game.Items;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Gathering;
|
||||
|
||||
/// <summary>Stable deny reason codes for gather operations (NEO-62).</summary>
|
||||
public static class GatherReasonCodes
|
||||
{
|
||||
/// <summary>Passthrough from <see cref="ResourceNodeInstanceReasonCodes.UnknownNode"/>.</summary>
|
||||
public const string UnknownNode = ResourceNodeInstanceReasonCodes.UnknownNode;
|
||||
|
||||
/// <summary>Passthrough from <see cref="ResourceNodeInstanceReasonCodes.NodeDepleted"/>.</summary>
|
||||
public const string NodeDepleted = ResourceNodeInstanceReasonCodes.NodeDepleted;
|
||||
|
||||
/// <summary>Passthrough from <see cref="PlayerInventoryReasonCodes.InventoryFull"/>.</summary>
|
||||
public const string InventoryFull = PlayerInventoryReasonCodes.InventoryFull;
|
||||
|
||||
/// <summary><see cref="Skills.IPlayerSkillProgressionStore.TryApplyXpDelta"/> returned false.</summary>
|
||||
public const string ProgressionStoreMissing = "progression_store_missing";
|
||||
|
||||
/// <summary>Player inventory store could not write (e.g. unknown player bucket).</summary>
|
||||
public const string InventoryStoreMissing = "inventory_store_missing";
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
namespace NeonSprawl.Server.Game.Gathering;
|
||||
|
||||
/// <summary>
|
||||
/// Server-internal gather resolution envelope (NEO-62). May promote to wire JSON when the client needs explicit
|
||||
/// gather payloads; until then prototype verification uses inventory GET + interact response (NEO-63).
|
||||
/// </summary>
|
||||
/// <param name="GrantsApplied">Item grants on success; empty when denied.</param>
|
||||
/// <param name="RemainingGathers">Authoritative capacity after success, or current capacity on depletion deny.</param>
|
||||
/// <param name="XpGrantSummary">Skill XP applied on success; null when denied.</param>
|
||||
public readonly record struct GatherResult(
|
||||
bool Success,
|
||||
string? ReasonCode,
|
||||
IReadOnlyList<GatherGrantApplied> GrantsApplied,
|
||||
int? RemainingGathers,
|
||||
GatherXpGrantSummary? XpGrantSummary);
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
namespace NeonSprawl.Server.Game.Gathering;
|
||||
|
||||
/// <summary>Compact skill XP summary on a successful gather (NEO-62).</summary>
|
||||
public readonly record struct GatherXpGrantSummary(string SkillId, int Amount, string SourceKind);
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
using NeonSprawl.Server.Game.Gathering;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.Mastery;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
|
|
@ -16,8 +18,10 @@ public static class InteractionApi
|
|||
app.MapPost(
|
||||
"/game/players/{id}/interact",
|
||||
(string id, InteractionRequest? body, IPositionStateStore store,
|
||||
ISkillDefinitionRegistry registry, IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve,
|
||||
PerkUnlockEngine perkUnlockEngine) =>
|
||||
IResourceNodeDefinitionRegistry nodeRegistry, IItemDefinitionRegistry itemRegistry,
|
||||
IPlayerInventoryStore inventoryStore, ISkillDefinitionRegistry skillRegistry,
|
||||
IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve, PerkUnlockEngine perkUnlockEngine,
|
||||
IResourceNodeInstanceStore nodeInstanceStore) =>
|
||||
{
|
||||
if (body is null || body.SchemaVersion != InteractionRequest.CurrentSchemaVersion)
|
||||
{
|
||||
|
|
@ -72,17 +76,37 @@ 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(
|
||||
var gather = GatherOperations.TryGather(
|
||||
playerKey,
|
||||
GatherSkillXpConstants.SalvageSkillId,
|
||||
GatherSkillXpConstants.ActivityXpPerResourceNodeGather,
|
||||
GatherSkillXpConstants.ActivitySourceKind,
|
||||
registry,
|
||||
lookupKey,
|
||||
nodeRegistry,
|
||||
itemRegistry,
|
||||
inventoryStore,
|
||||
skillRegistry,
|
||||
xpStore,
|
||||
levelCurve,
|
||||
perkUnlockEngine);
|
||||
perkUnlockEngine,
|
||||
nodeInstanceStore);
|
||||
|
||||
if (!gather.Success)
|
||||
{
|
||||
return Results.Json(
|
||||
new InteractionResponse
|
||||
{
|
||||
SchemaVersion = InteractionResponse.CurrentSchemaVersion,
|
||||
Allowed = false,
|
||||
ReasonCode = gather.ReasonCode,
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Json(
|
||||
new InteractionResponse
|
||||
{
|
||||
SchemaVersion = InteractionResponse.CurrentSchemaVersion,
|
||||
Allowed = true,
|
||||
InteractableId = lookupKey,
|
||||
Payload = new InteractionPlaceholderPayload(),
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Json(
|
||||
|
|
|
|||
|
|
@ -12,6 +12,15 @@ public static class PrototypeInteractableRegistry
|
|||
/// <summary>Second stub row for multi-entity projection (NEO-25).</summary>
|
||||
public const string PrototypeResourceNodeAlphaId = "prototype_resource_node_alpha";
|
||||
|
||||
/// <summary>Subsurface lens gather anchor (NEO-63).</summary>
|
||||
public const string PrototypeSubsurfaceVeinBetaId = "prototype_subsurface_vein_beta";
|
||||
|
||||
/// <summary>Bio lens gather anchor (NEO-63).</summary>
|
||||
public const string PrototypeBioMatGammaId = "prototype_bio_mat_gamma";
|
||||
|
||||
/// <summary>Urban bulk lens gather anchor (NEO-63).</summary>
|
||||
public const string PrototypeUrbanBulkDeltaId = "prototype_urban_bulk_delta";
|
||||
|
||||
/// <summary><see cref="PrototypeInteractableEntry.Kind"/> for gather-anchor nodes (NEO-41).</summary>
|
||||
public const string ResourceNodeKind = "resource_node";
|
||||
|
||||
|
|
@ -21,10 +30,22 @@ public static class PrototypeInteractableRegistry
|
|||
/// <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, ResourceNodeKind);
|
||||
|
||||
/// <summary>6 m east of alpha on −Z band (NEO-63).</summary>
|
||||
public static readonly PrototypeInteractableEntry PrototypeSubsurfaceVeinBeta = new(18, 0.5, -6, 3.0, ResourceNodeKind);
|
||||
|
||||
/// <summary>6 m north (+Z) of alpha (NEO-63).</summary>
|
||||
public static readonly PrototypeInteractableEntry PrototypeBioMatGamma = new(12, 0.5, 0, 3.0, ResourceNodeKind);
|
||||
|
||||
/// <summary>Northeast open quadrant (NEO-63).</summary>
|
||||
public static readonly PrototypeInteractableEntry PrototypeUrbanBulkDelta = new(18, 0.5, 0, 3.0, ResourceNodeKind);
|
||||
|
||||
private static readonly Dictionary<string, PrototypeInteractableEntry> ById = new(StringComparer.Ordinal)
|
||||
{
|
||||
[PrototypeTerminalId] = PrototypeTerminal,
|
||||
[PrototypeResourceNodeAlphaId] = PrototypeResourceNodeAlpha,
|
||||
[PrototypeSubsurfaceVeinBetaId] = PrototypeSubsurfaceVeinBeta,
|
||||
[PrototypeBioMatGammaId] = PrototypeBioMatGamma,
|
||||
[PrototypeUrbanBulkDeltaId] = PrototypeUrbanBulkDelta,
|
||||
};
|
||||
|
||||
public static bool TryGet(string interactableIdLowercase, out PrototypeInteractableEntry entry) =>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ using NeonSprawl.Server.Game.Mastery;
|
|||
|
||||
namespace NeonSprawl.Server.Game.Skills;
|
||||
|
||||
/// <summary>Shared NEO-38 skill XP grant apply (used by HTTP POST and NEO-41 gather interact hook).</summary>
|
||||
/// <summary>Shared NEO-38 skill XP grant apply (HTTP POST, gather engine via NEO-63 interact, and other callers).</summary>
|
||||
public static class SkillProgressionGrantOperations
|
||||
{
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -83,7 +83,21 @@ World-wide **remaining gathers** per **`interactableId`** live in **`Game/Gather
|
|||
| **`node_depleted`** | Row exists and **`remainingGathers`** is already **0** (decrement rejected). |
|
||||
| **`unknown_node`** | **`interactableId`** not found in **`IResourceNodeDefinitionRegistry`**. |
|
||||
|
||||
**Persistence:** same **[NEO-29-style](#position-persistence-neo-8) split** as inventory — **`resource_node_instance`** in PostgreSQL when **`ConnectionStrings:NeonSprawl`** is set (DDL [`V006__resource_node_instance.sql`](../db/migrations/V006__resource_node_instance.sql)), otherwise in-memory fallback (empty at startup). Interact deny wiring is **[NEO-63](https://linear.app/neon-sprawl/issue/NEO-63)**; plan: [NEO-61 implementation plan](../../docs/plans/NEO-61-implementation-plan.md).
|
||||
**Persistence:** same **[NEO-29-style](#position-persistence-neo-8) split** as inventory — **`resource_node_instance`** in PostgreSQL when **`ConnectionStrings:NeonSprawl`** is set (DDL [`V006__resource_node_instance.sql`](../db/migrations/V006__resource_node_instance.sql)), otherwise in-memory fallback (empty at startup). Interact deny wiring: [NEO-63](#gather-via-interact-neo-63); plan: [NEO-61 implementation plan](../../docs/plans/NEO-61-implementation-plan.md).
|
||||
|
||||
## Gather engine (NEO-62)
|
||||
|
||||
**`GatherOperations.TryGather`** in **`Game/Gathering/`** resolves server-internal **`GatherResult`**: capacity pre-check (may **initialize** a depletion row without decrementing), inventory grant via **`PlayerInventoryOperations`**, **`salvage`** skill XP via **`SkillProgressionGrantOperations`** + **`GatherSkillXpConstants`**, then depletion commit via **`ResourceNodeInstanceOperations`** (decrement **last**, only when inventory + XP succeed). On XP failure after inventory add, the engine **rolls back** the item grant before denying.
|
||||
|
||||
| Reason code | When |
|
||||
|-------------|------|
|
||||
| **`unknown_node`** | **`interactableId`** not in **`IResourceNodeDefinitionRegistry`** or missing yield row. |
|
||||
| **`node_depleted`** | **`remainingGathers`** is **0** (or decrement rejected). |
|
||||
| **`inventory_full`** | Full yield quantity could not be placed (NEO-54 all-or-nothing). |
|
||||
| **`progression_store_missing`** | Skill progression store could not apply XP (compensating inventory remove). |
|
||||
| **`inventory_store_missing`** | Player inventory store could not write. |
|
||||
|
||||
**Interact wiring** (replace NEO-41 inline XP, map denies to **`InteractionResponse`**) is **[NEO-63](#gather-via-interact-neo-63)**; plan: [NEO-63 implementation plan](../../docs/plans/NEO-63-implementation-plan.md).
|
||||
|
||||
## Item definitions (NEO-53)
|
||||
|
||||
|
|
@ -298,12 +312,18 @@ curl -s -X POST http://localhost:5253/game/players/dev-local-1/interact \
|
|||
|------|------|
|
||||
| `out_of_range` | Horizontal distance on X/Z is **greater than** the interactable’s `interactionRadius` (on the radius is **allowed** — inclusive `<=`). |
|
||||
| `unknown_interactable` | Id not in the prototype registry (after trim + case-insensitive lookup). |
|
||||
| `node_depleted` | **`resource_node`** gather: instance capacity exhausted ([NEO-63](#gather-via-interact-neo-63)). |
|
||||
| `inventory_full` | **`resource_node`** gather: yield could not fit in bag (all-or-nothing; [NEO-54](#player-inventory-neo-54-store-neo-55-http)). |
|
||||
| `unknown_node` | **`resource_node`** gather: registry miss (defensive; should not occur for registered interactables). |
|
||||
| `progression_store_missing` | **`resource_node`** gather: skill progression store could not apply XP. |
|
||||
|
||||
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)
|
||||
### Gather via interact (NEO-63)
|
||||
|
||||
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`.
|
||||
When the interactable’s **`kind`** is **`resource_node`**, **`POST …/interact`** delegates to **`GatherOperations.TryGather`** ([NEO-62](#gather-engine-neo-62)): inventory grant, **`salvage`** skill XP (`activity`, 10 XP), and depletion commit. On success **`allowed: true`**; gather denies map to **`allowed: false`** + stable **`reasonCode`** (including **`node_depleted`**, **`inventory_full`**). **Four** prototype nodes are registered in **`PrototypeInteractableRegistry`** (alpha **(12, −6)**, beta **(18, −6)**, gamma **(12, 0)**, delta **(18, 0)** on X/Z, radius **3**). Verify with **`GET …/inventory`** and **`GET …/skill-progression`**. Plan: [NEO-63 implementation plan](../../docs/plans/NEO-63-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-63.md`](../../docs/manual-qa/NEO-63.md); Bruno: `bruno/neon-sprawl-server/interaction/Post interact prototype resource node gather.bru`, `Post interact gather then get inventory.bru`, `Post interact depleted node deny.bru`.
|
||||
|
||||
**Historical:** [NEO-41](#gather-via-interact-neo-63) previously granted XP only on allowed interact without inventory or depletion; superseded by NEO-63.
|
||||
|
||||
### Craft / refine hook → skill XP (NEO-42)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue