Merge pull request #96 from ViPro-Technologies/NEO-61-e3m1-world-node-instance-depletion-store

NEO-61: E3.M1 world node instance depletion store
pull/98/head
VinPropane 2026-05-24 15:28:37 -04:00 committed by GitHub
commit ef5a577c4e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 891 additions and 4 deletions

View File

@ -96,6 +96,8 @@ Epic 3 **Slice 2** — [gather nodes and outputs](../epics/epic_03_crafting_econ
**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 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).
**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). **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).
## Source anchors ## Source anchors

View File

@ -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.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.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-37NEO-41, NEO-42NEO-44 | | 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-37NEO-41, NEO-42NEO-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/`. **Still planned:** `GatherResult`, depletion, inventory grants 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), [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):** `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.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) | | 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) |
--- ---

View File

@ -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.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.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`) — E3M1-01**E3M1-04** complete; register row **In Progress** until depletion / gather engine 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`**, depletion store, 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`) — 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.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. **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.

View File

@ -167,8 +167,10 @@ Working backlog for **Epic 3 — Slice 2** ([gather nodes and outputs](../decomp
**Acceptance criteria** **Acceptance criteria**
- [ ] First gather on a fresh node succeeds; repeated gathers until capacity returns **`node_depleted`** without going negative. - [x] First gather on a fresh node succeeds; repeated gathers until capacity returns **`node_depleted`** without going negative.
- [ ] Postgres + in-memory parity tests. - [x] Postgres + in-memory parity tests.
**Landed:** [NEO-61](https://linear.app/neon-sprawl/issue/NEO-61) — instance depletion store; gather engine is E3M1-06.
--- ---

View File

@ -0,0 +1,146 @@
# NEO-61 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-61 |
| **Title** | E3.M1: World node instance depletion store |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-61/e3m1-world-node-instance-depletion-store |
| **Module** | [E3.M1 — ResourceNodeAndGatherLoop](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md) · Epic 3 Slice 2 · backlog **E3M1-05** |
| **Branch** | `NEO-61-e3m1-world-node-instance-depletion-store` |
| **Precursor** | [NEO-59](https://linear.app/neon-sprawl/issue/NEO-59) — `IResourceNodeDefinitionRegistry` + DI (**Done** on `main`); [NEO-60](https://linear.app/neon-sprawl/issue/NEO-60) — definition HTTP (**Done** on `main`) |
| **Pattern** | [NEO-54](https://linear.app/neon-sprawl/issue/NEO-54) — `IPlayerInventoryStore` + `PlayerInventoryOperations` persistence policy |
## Kickoff clarifications
| Topic | Question | Agent recommendation | Answer |
|--------|----------|----------------------|--------|
| **Architecture** | Split store + operations vs monolithic store? | **Split**`IResourceNodeInstanceStore` (persistence) + `ResourceNodeInstanceOperations` (registry-backed lazy init, atomic decrement, reason codes); mirrors NEO-54. | **User:** split. |
| **Initialization** | Lazy init vs startup seed all four nodes? | **Lazy init** — first read/decrement creates a row at `maxGathers` from registry; sparse Postgres rows. | **User:** lazy init. |
| **Depleted rows** | Keep `remainingGathers = 0` vs delete row? | **Keep row at 0** — explicit depleted state for `node_depleted`; survives restarts. | **User:** keep zero. |
| **Interactable registry** | Expand `PrototypeInteractableRegistry` to four nodes now? | **Defer to NEO-63** — store-only scope; tests use catalog `nodeDefId`s as `interactableId` without world anchors. | **User:** defer. |
## Goal, scope, and out-of-scope
**Goal:** Server-authoritative **remaining gathers** per world node instance (`interactableId`), with structured deny **`node_depleted`** when capacity is exhausted — persistence + rules only, ready for the gather engine (NEO-62) and interact wiring (NEO-63).
**In scope (from Linear + [E3M1-05](E3M1-prototype-backlog.md#e3m1-05--world-node-instance-depletion-store)):**
- **`IResourceNodeInstanceStore`**, **`InMemoryResourceNodeInstanceStore`**, **`PostgresResourceNodeInstanceStore`**, **`PostgresResourceNodeInstanceBootstrap`**, **`ResourceNodeInstanceServiceCollectionExtensions`**.
- **`ResourceNodeInstanceOperations`** — lazy init from **`IResourceNodeDefinitionRegistry`**, atomic successful-gather decrement, stable **`reasonCode`** strings.
- Domain types: **`ResourceNodeInstanceSnapshot`**, **`ResourceNodeInstanceMutationOutcome`**, **`ResourceNodeInstanceReasonCodes`**.
- Migration **`V006__resource_node_instance.sql`** (world-wide rows keyed by **`interactable_id`**; not per-player).
- Unit + Postgres **parity** integration tests (AAA).
**Out of scope (from Linear + backlog):**
- Item grants, skill XP, **`GatherResult`** engine ([NEO-62](https://linear.app/neon-sprawl/issue/NEO-62)).
- **`InteractionApi`** wiring and **`node_depleted`** on interact ([NEO-63](https://linear.app/neon-sprawl/issue/NEO-63)).
- HTTP read model for instance state (definition catalog only is NEO-60).
- Bruno, client HUD, regen / respawn ([E4.M2](../decomposition/modules/E4_M2_SpawnEcologyController.md)).
- Expanding **`PrototypeInteractableRegistry`** beyond **`prototype_resource_node_alpha`** (NEO-63).
## Acceptance criteria checklist
- [x] First gather on a fresh node succeeds; repeated gathers until capacity returns **`node_depleted`** without going negative.
- [x] Postgres + in-memory parity tests.
## Technical approach
1. **Keying (prototype):** Store rows keyed by lowercase **`interactableId`**. Prototype wiring assumes **`interactableId == nodeDefId`** ([E3.M1 freeze](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md#prototype-slice-2-freeze-e3m1-01)); operations resolve **`maxGathers`** via **`IResourceNodeDefinitionRegistry.TryGetDefinition(interactableId, …)`**.
2. **Lazy initialization:** When **`ResourceNodeInstanceOperations.TryCommitSuccessfulGatherDecrement`** runs and the store has no row for the id:
- If registry miss → deny **`unknown_node`**, no write.
- Else **`TryEnsureInitialized(interactableId, definition.MaxGathers)`** on the store (create-if-absent only; idempotent when row already exists).
3. **Atomic decrement (store):** **`TryDecrementRemainingGathers(interactableId, out previous, out remaining)`**:
- Requires an existing row with **`remaining > 0`**.
- Sets **`remaining = previous - 1`** in one compare-and-swap (in-memory per-id lock) or single **`UPDATE … WHERE remaining_gathers > 0 RETURNING …`** (Postgres).
- Returns **`false`** when row missing or already **`0`** (operations maps to **`node_depleted`** after lazy init path).
4. **Operations entry point:** **`TryCommitSuccessfulGatherDecrement(interactableId, registry, store, out outcome)`**:
- Normalize id (trim + lowercase).
- Lazy-init as above.
- Call store decrement; on success → **`Applied`** with snapshot **`{ interactableId, remainingGathers }`**.
- On decrement fail with row at **`0`** → **`Denied`** + **`node_depleted`**.
- On registry miss → **`Denied`** + **`unknown_node`**.
5. **Depleted persistence:** When decrement reaches **`0`**, **keep the row** (`remaining_gathers = 0`). Subsequent decrements deny **`node_depleted`** without negative values.
6. **Persistence policy (NEO-54 / NEO-38 mirror):**
- **`AddResourceNodeInstanceStore`** chooses **`PostgresResourceNodeInstanceStore`** when **`ConnectionStrings__NeonSprawl`** is set; else **`InMemoryResourceNodeInstanceStore`** (empty map at startup — no pre-seed).
- Postgres: **`resource_node_instance`** table — **`interactable_id TEXT PRIMARY KEY`**, **`remaining_gathers INTEGER NOT NULL CHECK (remaining_gathers >= 0)`**, **`updated_at TIMESTAMPTZ`**.
- Bootstrap: **`PostgresResourceNodeInstanceBootstrap.EnsureSchema`** loads embedded **`V006__resource_node_instance.sql`** from **`db/migrations/`**.
- Chain registration from **`ResourceNodeCatalogServiceCollectionExtensions.AddResourceNodeCatalog`** (after registry) to keep **`Program.cs`** thin.
7. **Reason codes (snake_case constants):**
- **`node_depleted`** — row exists and **`remainingGathers <= 0`** (or decrement rejected at zero).
- **`unknown_node`** — **`interactableId`** not in **`IResourceNodeDefinitionRegistry`**.
- Document in code XML + brief **`server/README.md`** depletion subsection (persistence only; interact deny deferred to NEO-63).
8. **Tests (primary node: `prototype_resource_node_alpha`, `maxGathers` 10):**
- Operations: first decrement after lazy init → **`remainingGathers == 9`**; loop 10 successful decrements then next → **`node_depleted`**; assert never negative.
- Spot-check **`prototype_urban_bulk_delta`** lazy init at **10**.
- Unknown id → **`unknown_node`**, no row created.
- In-memory store: get/ensure/decrement concurrency hygiene.
- Postgres parity: decrement across two factory instances (mirror **`PlayerInventoryPersistenceIntegrationTests`**).
9. **Docs (on land):** Update [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md) **Related implementation slices**, [documentation_and_implementation_alignment.md](documentation_and_implementation_alignment.md) E3.M1 row, and E3M1-05 checkboxes in [E3M1-prototype-backlog.md](E3M1-prototype-backlog.md).
## Files to add
| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Gathering/ResourceNodeInstanceSnapshot.cs` | Instance state: `interactableId`, `remainingGathers`. |
| `server/NeonSprawl.Server/Game/Gathering/IResourceNodeInstanceStore.cs` | Persistence: get, ensure-initialized, atomic decrement. |
| `server/NeonSprawl.Server/Game/Gathering/InMemoryResourceNodeInstanceStore.cs` | Thread-safe in-memory map; lazy rows only. |
| `server/NeonSprawl.Server/Game/Gathering/PostgresResourceNodeInstanceStore.cs` | Postgres CRUD + conditional decrement. |
| `server/NeonSprawl.Server/Game/Gathering/PostgresResourceNodeInstanceBootstrap.cs` | One-time DDL ensure from `V006__resource_node_instance.sql`. |
| `server/NeonSprawl.Server/Game/Gathering/ResourceNodeInstanceServiceCollectionExtensions.cs` | Postgres vs in-memory registration from configuration. |
| `server/NeonSprawl.Server/Game/Gathering/ResourceNodeInstanceReasonCodes.cs` | Stable `node_depleted`, `unknown_node` constants. |
| `server/NeonSprawl.Server/Game/Gathering/ResourceNodeInstanceMutationOutcome.cs` | Applied/denied result types (`reasonCode`, snapshot). |
| `server/NeonSprawl.Server/Game/Gathering/ResourceNodeInstanceOperations.cs` | Lazy init, registry lookup, decrement orchestration. |
| `server/db/migrations/V006__resource_node_instance.sql` | Postgres table for per-instance remaining gathers. |
| `server/NeonSprawl.Server.Tests/Game/Gathering/ResourceNodeInstanceOperationsTests.cs` | AAA: lazy init, decrement loop to depletion, unknown_node, never negative. |
| `server/NeonSprawl.Server.Tests/Game/Gathering/InMemoryResourceNodeInstanceStoreTests.cs` | Store get/ensure/decrement, id normalization. |
| `server/NeonSprawl.Server.Tests/Game/Gathering/ResourceNodeInstancePersistenceIntegrationTests.cs` | Postgres: decrement, new factory, snapshot parity. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Game/Gathering/ResourceNodeCatalogServiceCollectionExtensions.cs` | Chain **`AddResourceNodeInstanceStore`** after registry registration. |
| `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | Force in-memory instance store; strip Postgres instance registration in tests. |
| `server/README.md` | Depletion store subsection: lazy init, reason codes, migration note (interact wiring in NEO-63). |
| `docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md` | **Related implementation slices** — instance depletion store bullet (NEO-61). |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E3.M1 row — note NEO-61 depletion store when landed. |
| `docs/plans/E3M1-prototype-backlog.md` | E3M1-05 acceptance checkboxes when landed. |
## Tests
| Test file | What it covers |
|-----------|----------------|
| `server/NeonSprawl.Server.Tests/Game/Gathering/ResourceNodeInstanceOperationsTests.cs` | **Integration-style (DI factory):** `TryCommitSuccessfulGatherDecrement` on **`prototype_resource_node_alpha`** — first call **`Applied`**, **`remainingGathers == 9`**; ten more successful decrements; eleventh → **`Denied`**, **`node_depleted`**; **`unknown_node`** for bogus id with no store row. **AAA** per [csharp-style](../../.cursor/rules/csharp-style.md). |
| `server/NeonSprawl.Server.Tests/Game/Gathering/InMemoryResourceNodeInstanceStoreTests.cs` | **Unit:** `TryEnsureInitialized` idempotent; decrement from 1→0; second decrement fails; normalization. |
| `server/NeonSprawl.Server.Tests/Game/Gathering/ResourceNodeInstancePersistenceIntegrationTests.cs` | **Postgres:** decrement via first host, read/decrement via fresh **`PostgresWebApplicationFactory`**; depleted row persists at 0; **`RequirePostgresFact`**. |
No Bruno or HTTP tests — out of scope; manual verification of interact deny waits for NEO-63.
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| **Linear blockedBy NEO-59** | Branch from **`main`** where NEO-59/60 are merged (confirmed at kickoff). | **adopted** |
| **Only alpha in interactable registry** | Defer registry expansion to NEO-63; store tests use catalog ids directly. | **adopted** |
| **World-wide vs per-player depletion** | **World-wide** — one capacity pool per `interactableId` (module doc: per-instance node capacity, not per-player). | **adopted** |
| **Regen after depletion** | Out of scope Slice 2; depleted rows stay at 0 until a future ecology story. | **deferred** |
None blocking.
## Decisions (kickoff)
- **Split store + operations** — persistence vs registry-backed rules and reason codes (NEO-54 mirror).
- **Lazy init** — rows created on first operations access at catalog **`maxGathers`** (prototype: **10** for all four nodes).
- **Keep depleted rows**`remainingGathers = 0` persisted; subsequent commits return **`node_depleted`**.
- **Defer interactable registry expansion** — NEO-63 adds beta/gamma/delta world anchors.

View File

@ -0,0 +1,62 @@
# Code review — NEO-61 world node instance depletion store
**Date:** 2026-05-24
**Scope:** Branch `NEO-61-e3m1-world-node-instance-depletion-store` · commits `869d411``6d2d3c0` vs `origin/main`
**Base:** `origin/main`
## Verdict
**Approve with nits**
## Summary
NEO-61 adds server-authoritative **remaining gathers** per world node instance: **`IResourceNodeInstanceStore`** (in-memory + Postgres **`V006`**) and **`ResourceNodeInstanceOperations.TryCommitSuccessfulGatherDecrement`** with registry-backed lazy init, atomic decrement, and stable **`node_depleted`** / **`unknown_node`** reason codes. The split-store pattern mirrors NEO-54; DI chains from **`AddResourceNodeCatalog`**, and **`InMemoryWebApplicationFactory`** forces the in-memory store in tests. Ten automated tests cover lazy init, depletion loop, unknown-node deny, store normalization/idempotency, and Postgres cross-factory persistence. Risk is low: no HTTP surface, no interact wiring, no inventory mutation — persistence + rules only, ready for NEO-62/NEO-63.
## Documentation checked
| Document | Result |
|----------|--------|
| [`docs/plans/NEO-61-implementation-plan.md`](../plans/NEO-61-implementation-plan.md) | **Matches** — split store + operations, lazy init, keep depleted rows at 0, reason codes, **`V006`**, DI chain, README, acceptance checklist complete. Minor: plan listed in-memory **concurrency tests** and an `out outcome` signature; implementation uses per-id locks (no concurrent test) and returns the outcome struct directly. |
| [`docs/plans/E3M1-prototype-backlog.md`](../plans/E3M1-prototype-backlog.md) | **Matches** — E3M1-05 acceptance items flipped with NEO-61 landed note. |
| [`docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md`](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md) | **Matches** — NEO-61 depletion-store bullet under **Related implementation slices**; per-instance capacity, world-wide keying, **`node_depleted`** align with **Purpose** / **Responsibilities**. |
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E3.M1 row notes NEO-61 landed with README link; **Still planned** correctly lists gather engine / interact wiring. |
| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | **Matches** — E3.M1 row **In Progress** is correct; **E3.M1 note** footnote updated for NEO-61 (E3M1-05). |
| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **Matches** — server-authoritative capacity; no client-trusted depletion state. |
| [`server/README.md`](../../server/README.md) | **Matches** — Resource node instance depletion subsection: lazy init, reason-code table, persistence split, NEO-63 deferral. |
Register/tracking: alignment table and **E3.M1 note** footnote updated for NEO-61.
## Blocking issues
None.
## Suggestions
1. ~~**AAA layout in stateful tests** — Two new test methods mix **Assert** into **Act**:~~ Done.
- ~~`ResourceNodeInstanceOperationsTests.TryCommitSuccessfulGatherDecrement_RepeatedUntilCapacity_ShouldReturnNodeDepletedWithoutGoingNegative` — assertions inside the decrement loop.~~
- ~~`ResourceNodeInstancePersistenceIntegrationTests.TryCommitSuccessfulGatherDecrement_WhenDepleted_ShouldPersistZeroRow` — assertions inside the final `using` Act block.~~
~~Move loop invariants / final expectations to a dedicated **Assert** section (collect last applied outcome during Act, assert after) per [csharp-style](../../.cursor/rules/csharp-style.md#unit-and-integration-tests-arrange-act-assert).~~
2. ~~**Dependency register footnote (optional)** — Extend **E3.M1 note** in [`module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) to include **NEO-61** (E3M1-05 depletion store) so the register matches the alignment table — same follow-up as NEO-60 for NEO-57NEO-60.~~ Done.
3. **Concurrency test gap (optional)** — Plan §8 listed in-memory get/ensure/decrement **concurrency hygiene** tests; implementation uses per-id locks but has no parallel test. NEO-54 inventory store also omits concurrent tests, so non-blocking; add a lightweight `Parallel.For` smoke test if you want plan parity.
## Nits
- ~~Nit: **`NormalizeInteractableId`** lives as `internal static` on **`InMemoryResourceNodeInstanceStore`** but is called from **`PostgresResourceNodeInstanceStore`** and **`ResourceNodeInstanceOperations`** — works, but a small shared helper (e.g. `ResourceNodeInstanceIds.Normalize`) would reduce cross-type coupling if this grows.~~ Done.
- ~~Nit: **`TryEnsureInitialized` failure → `unknown_node`** in `ResourceNodeInstanceOperations` (lines 2631) is unreachable after a registry hit with non-negative **`maxGathers`**; a distinct internal reason or `Debug.Assert` would clarify intent vs catalog miss.~~ Done — throws **`InvalidOperationException`** (programmer error after validated catalog **`maxGathers`**).
- Nit: Postgres **`TryDecrementRemainingGathers`** opens a second connection via **`TryGetRemainingGathers`** on decrement miss to distinguish depleted-at-zero — correct behavior, slightly extra round-trip on deny path only.
## Verification
```bash
cd server
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj --filter "FullyQualifiedName~ResourceNodeInstance"
# Passed! Failed: 0, Passed: 10, Skipped: 0
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj
```
Postgres integration (when `ConnectionStrings__NeonSprawl` is set): `ResourceNodeInstancePersistenceIntegrationTests` — cross-factory read and depleted-row-at-zero persistence.

View File

@ -0,0 +1,76 @@
using NeonSprawl.Server.Game.Gathering;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Gathering;
public sealed class InMemoryResourceNodeInstanceStoreTests
{
private const string NodeId = "prototype_resource_node_alpha";
[Fact]
public void TryEnsureInitialized_ShouldBeIdempotent()
{
// Arrange
var store = new InMemoryResourceNodeInstanceStore();
// Act
var first = store.TryEnsureInitialized(NodeId, initialRemainingGathers: 10);
var second = store.TryEnsureInitialized(NodeId, initialRemainingGathers: 99);
store.TryGetRemainingGathers(NodeId, out var snapshot);
// Assert
Assert.True(first);
Assert.True(second);
Assert.Equal(10, snapshot.RemainingGathers);
}
[Fact]
public void TryDecrementRemainingGathers_FromOne_ShouldReachZeroAndDenySecondDecrement()
{
// Arrange
var store = new InMemoryResourceNodeInstanceStore();
store.TryEnsureInitialized(NodeId, initialRemainingGathers: 1);
// Act
var first = store.TryDecrementRemainingGathers(NodeId, out var previous, out var remaining);
var second = store.TryDecrementRemainingGathers(NodeId, out _, out var afterDeny);
// Assert
Assert.True(first);
Assert.Equal(1, previous);
Assert.Equal(0, remaining);
Assert.False(second);
Assert.Equal(0, afterDeny);
store.TryGetRemainingGathers(NodeId, out var persisted);
Assert.Equal(0, persisted.RemainingGathers);
}
[Fact]
public void TryGetRemainingGathers_ShouldNormalizeInteractableId()
{
// Arrange
var store = new InMemoryResourceNodeInstanceStore();
store.TryEnsureInitialized(NodeId, initialRemainingGathers: 7);
// Act
var found = store.TryGetRemainingGathers($" {NodeId.ToUpperInvariant()} ", out var snapshot);
// Assert
Assert.True(found);
Assert.Equal(NodeId, snapshot.InteractableId);
Assert.Equal(7, snapshot.RemainingGathers);
}
[Fact]
public void TryGetRemainingGathers_ForUnknownId_ShouldReturnFalse()
{
// Arrange
var store = new InMemoryResourceNodeInstanceStore();
// Act
var found = store.TryGetRemainingGathers("missing-node", out _);
// Assert
Assert.False(found);
}
}

View File

@ -0,0 +1,100 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Gathering;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Gathering;
public sealed class ResourceNodeInstanceOperationsTests
{
private const string AlphaId = "prototype_resource_node_alpha";
private const string DeltaId = "prototype_urban_bulk_delta";
[Fact]
public void TryCommitSuccessfulGatherDecrement_OnFreshNode_ShouldLazyInitAndDecrementToNine()
{
// Arrange
using var factory = new InMemoryWebApplicationFactory();
var registry = factory.Services.GetRequiredService<IResourceNodeDefinitionRegistry>();
var store = factory.Services.GetRequiredService<IResourceNodeInstanceStore>();
// Act
var outcome = ResourceNodeInstanceOperations.TryCommitSuccessfulGatherDecrement(AlphaId, registry, store);
// Assert
Assert.Equal(ResourceNodeInstanceMutationKind.Applied, outcome.Kind);
Assert.Null(outcome.ReasonCode);
Assert.NotNull(outcome.Snapshot);
Assert.Equal(AlphaId, outcome.Snapshot!.Value.InteractableId);
Assert.Equal(9, outcome.Snapshot.Value.RemainingGathers);
}
[Fact]
public void TryCommitSuccessfulGatherDecrement_RepeatedUntilCapacity_ShouldReturnNodeDepletedWithoutGoingNegative()
{
// Arrange
using var factory = new InMemoryWebApplicationFactory();
var registry = factory.Services.GetRequiredService<IResourceNodeDefinitionRegistry>();
var store = factory.Services.GetRequiredService<IResourceNodeInstanceStore>();
// Act — ten successful gathers (10 → 0), then one depletion attempt
var appliedOutcomes = new ResourceNodeInstanceMutationOutcome[10];
for (var i = 0; i < appliedOutcomes.Length; i++)
{
appliedOutcomes[i] = ResourceNodeInstanceOperations.TryCommitSuccessfulGatherDecrement(AlphaId, registry, store);
}
var depleted = ResourceNodeInstanceOperations.TryCommitSuccessfulGatherDecrement(AlphaId, registry, store);
store.TryGetRemainingGathers(AlphaId, out var persisted);
// Assert
Assert.All(appliedOutcomes, static o =>
{
Assert.Equal(ResourceNodeInstanceMutationKind.Applied, o.Kind);
Assert.True(o.Snapshot!.Value.RemainingGathers >= 0);
});
var lastApplied = appliedOutcomes[^1];
Assert.Equal(0, lastApplied.Snapshot!.Value.RemainingGathers);
Assert.Equal(ResourceNodeInstanceMutationKind.Denied, depleted.Kind);
Assert.Equal(ResourceNodeInstanceReasonCodes.NodeDepleted, depleted.ReasonCode);
Assert.NotNull(depleted.Snapshot);
Assert.Equal(0, depleted.Snapshot!.Value.RemainingGathers);
Assert.Equal(0, persisted.RemainingGathers);
}
[Fact]
public void TryCommitSuccessfulGatherDecrement_ForUnknownNode_ShouldDenyWithoutCreatingRow()
{
// Arrange
using var factory = new InMemoryWebApplicationFactory();
var registry = factory.Services.GetRequiredService<IResourceNodeDefinitionRegistry>();
var store = factory.Services.GetRequiredService<IResourceNodeInstanceStore>();
const string unknownId = "not_a_real_node_def";
// Act
var outcome = ResourceNodeInstanceOperations.TryCommitSuccessfulGatherDecrement(unknownId, registry, store);
var rowExists = store.TryGetRemainingGathers(unknownId, out _);
// Assert
Assert.Equal(ResourceNodeInstanceMutationKind.Denied, outcome.Kind);
Assert.Equal(ResourceNodeInstanceReasonCodes.UnknownNode, outcome.ReasonCode);
Assert.Null(outcome.Snapshot);
Assert.False(rowExists);
}
[Fact]
public void TryCommitSuccessfulGatherDecrement_ForDeltaNode_ShouldLazyInitAtTen()
{
// Arrange
using var factory = new InMemoryWebApplicationFactory();
var registry = factory.Services.GetRequiredService<IResourceNodeDefinitionRegistry>();
var store = factory.Services.GetRequiredService<IResourceNodeInstanceStore>();
// Act
var outcome = ResourceNodeInstanceOperations.TryCommitSuccessfulGatherDecrement(DeltaId, registry, store);
// Assert
Assert.Equal(ResourceNodeInstanceMutationKind.Applied, outcome.Kind);
Assert.Equal(9, outcome.Snapshot!.Value.RemainingGathers);
}
}

View File

@ -0,0 +1,105 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Gathering;
using NeonSprawl.Server.Tests.Game.PositionState;
using Npgsql;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Gathering;
[Collection("Postgres integration")]
public sealed class ResourceNodeInstancePersistenceIntegrationTests(PostgresIntegrationHarness harness)
{
private PostgresWebApplicationFactory Factory => harness.Factory;
private const string AlphaId = "prototype_resource_node_alpha";
[RequirePostgresFact]
public async Task TryCommitSuccessfulGatherDecrementAcrossNewFactory_ShouldPersistRemainingGathers()
{
// Arrange
await ResetInstanceTableAsync();
ResourceNodeInstanceMutationOutcome firstOutcome;
// Act — write through first host
using (var firstScope = Factory.Services.CreateScope())
{
var registry = firstScope.ServiceProvider.GetRequiredService<IResourceNodeDefinitionRegistry>();
var store = firstScope.ServiceProvider.GetRequiredService<IResourceNodeInstanceStore>();
firstOutcome = ResourceNodeInstanceOperations.TryCommitSuccessfulGatherDecrement(AlphaId, registry, store);
}
// Act — read back through a fresh host
await using var secondFactory = new PostgresWebApplicationFactory();
using var secondScope = secondFactory.Services.CreateScope();
var readStore = secondScope.ServiceProvider.GetRequiredService<IResourceNodeInstanceStore>();
var readBack = readStore.TryGetRemainingGathers(AlphaId, out var snapshot);
// Assert
Assert.Equal(ResourceNodeInstanceMutationKind.Applied, firstOutcome.Kind);
Assert.Equal(9, firstOutcome.Snapshot!.Value.RemainingGathers);
Assert.True(readBack);
Assert.Equal(9, snapshot.RemainingGathers);
}
[RequirePostgresFact]
public async Task TryCommitSuccessfulGatherDecrement_WhenDepleted_ShouldPersistZeroRow()
{
// Arrange
await ResetInstanceTableAsync();
using (var seedScope = Factory.Services.CreateScope())
{
var registry = seedScope.ServiceProvider.GetRequiredService<IResourceNodeDefinitionRegistry>();
var store = seedScope.ServiceProvider.GetRequiredService<IResourceNodeInstanceStore>();
for (var i = 0; i < 10; i++)
{
_ = ResourceNodeInstanceOperations.TryCommitSuccessfulGatherDecrement(AlphaId, registry, store);
}
}
// Act
ResourceNodeInstanceMutationOutcome denyOutcome;
ResourceNodeInstanceSnapshot persisted;
await using var secondFactory = new PostgresWebApplicationFactory();
using (var scope = secondFactory.Services.CreateScope())
{
var registry = scope.ServiceProvider.GetRequiredService<IResourceNodeDefinitionRegistry>();
var store = scope.ServiceProvider.GetRequiredService<IResourceNodeInstanceStore>();
denyOutcome = ResourceNodeInstanceOperations.TryCommitSuccessfulGatherDecrement(AlphaId, registry, store);
store.TryGetRemainingGathers(AlphaId, out persisted);
}
// Assert
Assert.Equal(ResourceNodeInstanceMutationKind.Denied, denyOutcome.Kind);
Assert.Equal(ResourceNodeInstanceReasonCodes.NodeDepleted, denyOutcome.ReasonCode);
Assert.Equal(0, persisted.RemainingGathers);
}
private async Task ResetInstanceTableAsync()
{
var cs = Environment.GetEnvironmentVariable("ConnectionStrings__NeonSprawl");
if (string.IsNullOrWhiteSpace(cs))
{
throw new InvalidOperationException("ConnectionStrings__NeonSprawl is not set.");
}
_ = Factory.Services;
var ddlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V006__resource_node_instance.sql");
if (!File.Exists(ddlPath))
{
throw new FileNotFoundException($"Test DDL not found at '{ddlPath}'.", ddlPath);
}
var ddl = await File.ReadAllTextAsync(ddlPath);
await using var conn = new NpgsqlConnection(cs);
await conn.OpenAsync();
await using (var apply = new NpgsqlCommand(ddl, conn))
{
await apply.ExecuteNonQueryAsync();
}
await using (var truncate = new NpgsqlCommand("TRUNCATE resource_node_instance;", conn))
{
await truncate.ExecuteNonQueryAsync();
}
}
}

View File

@ -53,6 +53,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
d.ServiceType == typeof(IPlayerHotbarLoadoutStore) || d.ServiceType == typeof(IPlayerHotbarLoadoutStore) ||
d.ServiceType == typeof(IPlayerSkillProgressionStore) || d.ServiceType == typeof(IPlayerSkillProgressionStore) ||
d.ServiceType == typeof(IPlayerInventoryStore) || d.ServiceType == typeof(IPlayerInventoryStore) ||
d.ServiceType == typeof(IResourceNodeInstanceStore) ||
d.ServiceType == typeof(IPlayerPerkStateStore) || d.ServiceType == typeof(IPlayerPerkStateStore) ||
d.ServiceType == typeof(TimeProvider) || d.ServiceType == typeof(TimeProvider) ||
d.ServiceType == typeof(IPlayerAbilityCooldownStore) || d.ServiceType == typeof(IPlayerAbilityCooldownStore) ||
@ -72,6 +73,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
services.AddSingleton<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>(); services.AddSingleton<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>();
services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>(); services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>();
services.AddSingleton<IPlayerInventoryStore, InMemoryPlayerInventoryStore>(); services.AddSingleton<IPlayerInventoryStore, InMemoryPlayerInventoryStore>();
services.AddSingleton<IResourceNodeInstanceStore, InMemoryResourceNodeInstanceStore>();
services.AddSingleton<IPlayerPerkStateStore, InMemoryPlayerPerkStateStore>(); services.AddSingleton<IPlayerPerkStateStore, InMemoryPlayerPerkStateStore>();
services.AddSingleton<IPlayerAbilityCooldownStore, InMemoryPlayerAbilityCooldownStore>(); services.AddSingleton<IPlayerAbilityCooldownStore, InMemoryPlayerAbilityCooldownStore>();
}); });

View File

@ -0,0 +1,22 @@
namespace NeonSprawl.Server.Game.Gathering;
/// <summary>Persisted remaining gathers per world resource-node instance (NEO-61).</summary>
public interface IResourceNodeInstanceStore
{
/// <summary>Reads the current snapshot when a row exists for <paramref name="interactableId"/>.</summary>
bool TryGetRemainingGathers(string interactableId, out ResourceNodeInstanceSnapshot snapshot);
/// <summary>
/// Creates a row at <paramref name="initialRemainingGathers"/> when absent; no-op when a row already exists.
/// </summary>
bool TryEnsureInitialized(string interactableId, int initialRemainingGathers);
/// <summary>
/// Atomically decrements when <c>remaining &gt; 0</c>. Returns <c>false</c> when the row is missing or already
/// depleted.
/// </summary>
bool TryDecrementRemainingGathers(
string interactableId,
out int previousRemaining,
out int remainingGathers);
}

View File

@ -0,0 +1,88 @@
using System.Collections.Concurrent;
namespace NeonSprawl.Server.Game.Gathering;
/// <summary>Thread-safe in-memory node instance capacity; empty at startup — lazy rows only (NEO-61).</summary>
public sealed class InMemoryResourceNodeInstanceStore : IResourceNodeInstanceStore
{
private readonly ConcurrentDictionary<string, int> remainingById = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, object> idLocks = new(StringComparer.Ordinal);
/// <inheritdoc />
public bool TryGetRemainingGathers(string interactableId, out ResourceNodeInstanceSnapshot snapshot)
{
var key = ResourceNodeInstanceIds.Normalize(interactableId);
if (key.Length == 0)
{
snapshot = default;
return false;
}
lock (idLocks.GetOrAdd(key, _ => new object()))
{
if (!remainingById.TryGetValue(key, out var remaining))
{
snapshot = default;
return false;
}
snapshot = new ResourceNodeInstanceSnapshot(key, remaining);
return true;
}
}
/// <inheritdoc />
public bool TryEnsureInitialized(string interactableId, int initialRemainingGathers)
{
var key = ResourceNodeInstanceIds.Normalize(interactableId);
if (key.Length == 0 || initialRemainingGathers < 0)
{
return false;
}
lock (idLocks.GetOrAdd(key, _ => new object()))
{
if (remainingById.ContainsKey(key))
{
return true;
}
remainingById[key] = initialRemainingGathers;
return true;
}
}
/// <inheritdoc />
public bool TryDecrementRemainingGathers(
string interactableId,
out int previousRemaining,
out int remainingGathers)
{
previousRemaining = 0;
remainingGathers = 0;
var key = ResourceNodeInstanceIds.Normalize(interactableId);
if (key.Length == 0)
{
return false;
}
lock (idLocks.GetOrAdd(key, _ => new object()))
{
if (!remainingById.TryGetValue(key, out var current) || current <= 0)
{
if (current == 0)
{
remainingGathers = 0;
}
return false;
}
previousRemaining = current;
remainingGathers = current - 1;
remainingById[key] = remainingGathers;
return true;
}
}
}

View File

@ -0,0 +1,39 @@
namespace NeonSprawl.Server.Game.Gathering;
/// <summary>Applies NEO-61 resource-node instance table DDL once per process.</summary>
public static class PostgresResourceNodeInstanceBootstrap
{
private static readonly string DdlRelativePath = Path.Combine("db", "migrations", "V006__resource_node_instance.sql");
private static readonly object SchemaGate = new();
private static int _schemaReady;
public static void EnsureSchema(Npgsql.NpgsqlDataSource dataSource)
{
if (System.Threading.Volatile.Read(ref _schemaReady) != 0)
{
return;
}
lock (SchemaGate)
{
if (System.Threading.Volatile.Read(ref _schemaReady) != 0)
{
return;
}
var ddlPath = Path.Combine(AppContext.BaseDirectory, DdlRelativePath);
if (!File.Exists(ddlPath))
{
throw new FileNotFoundException($"NEO-61 DDL not found at '{ddlPath}'.", ddlPath);
}
var ddl = File.ReadAllText(ddlPath);
using var conn = dataSource.OpenConnection();
using var cmd = new Npgsql.NpgsqlCommand(ddl, conn);
cmd.ExecuteNonQuery();
System.Threading.Volatile.Write(ref _schemaReady, 1);
}
}
}

View File

@ -0,0 +1,100 @@
namespace NeonSprawl.Server.Game.Gathering;
/// <summary>PostgreSQL-backed world node instance capacity keyed by interactable id (NEO-61).</summary>
public sealed class PostgresResourceNodeInstanceStore(Npgsql.NpgsqlDataSource dataSource) : IResourceNodeInstanceStore
{
/// <inheritdoc />
public bool TryGetRemainingGathers(string interactableId, out ResourceNodeInstanceSnapshot snapshot)
{
var key = ResourceNodeInstanceIds.Normalize(interactableId);
if (key.Length == 0)
{
snapshot = default;
return false;
}
PostgresResourceNodeInstanceBootstrap.EnsureSchema(dataSource);
using var conn = dataSource.OpenConnection();
using var cmd = new Npgsql.NpgsqlCommand(
"SELECT remaining_gathers FROM resource_node_instance WHERE interactable_id = @id;",
conn);
cmd.Parameters.AddWithValue("id", key);
var scalar = cmd.ExecuteScalar();
if (scalar is null or DBNull)
{
snapshot = default;
return false;
}
var remaining = Convert.ToInt32(scalar);
snapshot = new ResourceNodeInstanceSnapshot(key, remaining);
return true;
}
/// <inheritdoc />
public bool TryEnsureInitialized(string interactableId, int initialRemainingGathers)
{
var key = ResourceNodeInstanceIds.Normalize(interactableId);
if (key.Length == 0 || initialRemainingGathers < 0)
{
return false;
}
PostgresResourceNodeInstanceBootstrap.EnsureSchema(dataSource);
using var conn = dataSource.OpenConnection();
using var cmd = new Npgsql.NpgsqlCommand(
"""
INSERT INTO resource_node_instance (interactable_id, remaining_gathers, updated_at)
VALUES (@id, @remaining, now())
ON CONFLICT (interactable_id) DO NOTHING;
""",
conn);
cmd.Parameters.AddWithValue("id", key);
cmd.Parameters.AddWithValue("remaining", initialRemainingGathers);
cmd.ExecuteNonQuery();
return true;
}
/// <inheritdoc />
public bool TryDecrementRemainingGathers(
string interactableId,
out int previousRemaining,
out int remainingGathers)
{
previousRemaining = 0;
remainingGathers = 0;
var key = ResourceNodeInstanceIds.Normalize(interactableId);
if (key.Length == 0)
{
return false;
}
PostgresResourceNodeInstanceBootstrap.EnsureSchema(dataSource);
using var conn = dataSource.OpenConnection();
using var cmd = new Npgsql.NpgsqlCommand(
"""
UPDATE resource_node_instance
SET remaining_gathers = remaining_gathers - 1,
updated_at = now()
WHERE interactable_id = @id
AND remaining_gathers > 0
RETURNING remaining_gathers + 1 AS previous_remaining, remaining_gathers;
""",
conn);
cmd.Parameters.AddWithValue("id", key);
using var reader = cmd.ExecuteReader();
if (!reader.Read())
{
if (TryGetRemainingGathers(key, out var depleted) && depleted.RemainingGathers == 0)
{
remainingGathers = 0;
}
return false;
}
previousRemaining = reader.GetInt32(0);
remainingGathers = reader.GetInt32(1);
return true;
}
}

View File

@ -51,6 +51,8 @@ public static class ResourceNodeCatalogServiceCollectionExtensions
services.AddSingleton<IResourceNodeDefinitionRegistry>(sp => services.AddSingleton<IResourceNodeDefinitionRegistry>(sp =>
new ResourceNodeDefinitionRegistry(sp.GetRequiredService<ResourceNodeCatalog>())); new ResourceNodeDefinitionRegistry(sp.GetRequiredService<ResourceNodeCatalog>()));
services.AddResourceNodeInstanceStore(configuration);
return services; return services;
} }
} }

View File

@ -0,0 +1,17 @@
namespace NeonSprawl.Server.Game.Gathering;
/// <summary>Canonical interactable id normalization for resource-node instance stores (NEO-61).</summary>
public static class ResourceNodeInstanceIds
{
/// <summary>Trim + lowercase; empty when input is null/whitespace.</summary>
public static string Normalize(string? interactableId)
{
var trimmed = interactableId?.Trim();
if (string.IsNullOrEmpty(trimmed))
{
return string.Empty;
}
return trimmed.ToLowerInvariant();
}
}

View File

@ -0,0 +1,18 @@
namespace NeonSprawl.Server.Game.Gathering;
/// <summary>Result of <see cref="ResourceNodeInstanceOperations.TryCommitSuccessfulGatherDecrement"/> (NEO-61).</summary>
public enum ResourceNodeInstanceMutationKind
{
/// <summary>Validation or depletion deny — capacity unchanged when already depleted.</summary>
Denied,
/// <summary>Decrement applied and persisted.</summary>
Applied,
}
/// <param name="ReasonCode">Populated when <see cref="Kind"/> is <see cref="ResourceNodeInstanceMutationKind.Denied"/>.</param>
/// <param name="Snapshot">Authoritative snapshot after apply, or current snapshot on depletion deny; null for unknown node.</param>
public readonly record struct ResourceNodeInstanceMutationOutcome(
ResourceNodeInstanceMutationKind Kind,
string? ReasonCode,
ResourceNodeInstanceSnapshot? Snapshot);

View File

@ -0,0 +1,51 @@
namespace NeonSprawl.Server.Game.Gathering;
/// <summary>Registry-backed lazy init and atomic depletion commits for world node instances (NEO-61).</summary>
public static class ResourceNodeInstanceOperations
{
/// <summary>
/// Decrements remaining gathers by one after lazy-init from catalog <c>maxGathers</c>. Interact wiring (NEO-63)
/// maps <see cref="ResourceNodeInstanceReasonCodes.NodeDepleted"/> to denied interact responses.
/// </summary>
public static ResourceNodeInstanceMutationOutcome TryCommitSuccessfulGatherDecrement(
string interactableId,
IResourceNodeDefinitionRegistry registry,
IResourceNodeInstanceStore store)
{
var key = ResourceNodeInstanceIds.Normalize(interactableId);
if (key.Length == 0 || !registry.TryGetDefinition(key, out var definition))
{
return new ResourceNodeInstanceMutationOutcome(
ResourceNodeInstanceMutationKind.Denied,
ResourceNodeInstanceReasonCodes.UnknownNode,
null);
}
if (!store.TryGetRemainingGathers(key, out _))
{
if (!store.TryEnsureInitialized(key, definition.MaxGathers))
{
throw new InvalidOperationException(
$"Failed to initialize resource node instance '{key}' with maxGathers {definition.MaxGathers}.");
}
}
if (store.TryDecrementRemainingGathers(key, out _, out var remaining))
{
return new ResourceNodeInstanceMutationOutcome(
ResourceNodeInstanceMutationKind.Applied,
null,
new ResourceNodeInstanceSnapshot(key, remaining));
}
if (!store.TryGetRemainingGathers(key, out var current))
{
current = new ResourceNodeInstanceSnapshot(key, 0);
}
return new ResourceNodeInstanceMutationOutcome(
ResourceNodeInstanceMutationKind.Denied,
ResourceNodeInstanceReasonCodes.NodeDepleted,
current);
}
}

View File

@ -0,0 +1,8 @@
namespace NeonSprawl.Server.Game.Gathering;
/// <summary>Stable deny reason codes for resource-node instance depletion (NEO-61).</summary>
public static class ResourceNodeInstanceReasonCodes
{
public const string NodeDepleted = "node_depleted";
public const string UnknownNode = "unknown_node";
}

View File

@ -0,0 +1,22 @@
using NeonSprawl.Server.Game.PositionState;
namespace NeonSprawl.Server.Game.Gathering;
/// <summary>Registers resource-node instance persistence: PostgreSQL when configured, otherwise in-memory (NEO-61).</summary>
public static class ResourceNodeInstanceServiceCollectionExtensions
{
public static IServiceCollection AddResourceNodeInstanceStore(this IServiceCollection services, IConfiguration configuration)
{
var cs = configuration.GetConnectionString(PositionStateServiceCollectionExtensions.NeonSprawlConnectionStringName);
if (!string.IsNullOrWhiteSpace(cs))
{
services.AddSingleton<IResourceNodeInstanceStore, PostgresResourceNodeInstanceStore>();
}
else
{
services.AddSingleton<IResourceNodeInstanceStore, InMemoryResourceNodeInstanceStore>();
}
return services;
}
}

View File

@ -0,0 +1,6 @@
namespace NeonSprawl.Server.Game.Gathering;
/// <summary>Server-authoritative remaining gather capacity for one world node instance (NEO-61).</summary>
/// <param name="InteractableId">Lowercase canonical id (prototype: same as <c>nodeDefId</c>).</param>
/// <param name="RemainingGathers">Successful gathers still allowed before <see cref="ResourceNodeInstanceReasonCodes.NodeDepleted"/>.</param>
public readonly record struct ResourceNodeInstanceSnapshot(string InteractableId, int RemainingGathers);

View File

@ -74,6 +74,17 @@ On success, **Information** logs include the resolved resource-nodes directory p
curl -sS -i "http://localhost:5253/game/world/resource-node-definitions" curl -sS -i "http://localhost:5253/game/world/resource-node-definitions"
``` ```
## Resource node instance depletion (NEO-61)
World-wide **remaining gathers** per **`interactableId`** live in **`Game/Gathering/`** as **`IResourceNodeInstanceStore`** + **`ResourceNodeInstanceOperations`**. Capacity is **lazy-initialized** from catalog **`maxGathers`** on first successful-gather commit; prototype wiring uses **`interactableId == nodeDefId`**. **`TryCommitSuccessfulGatherDecrement`** atomically decrements and returns stable deny codes:
| Reason code | When |
|-------------|------|
| **`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).
## Item definitions (NEO-53) ## Item definitions (NEO-53)
**`GET /game/world/item-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`items`**) backed by **`IItemDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Plan: [NEO-53 implementation plan](../../docs/plans/NEO-53-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-53.md`](../../docs/manual-qa/NEO-53.md); Bruno: `bruno/neon-sprawl-server/item-definitions/`. **`GET /game/world/item-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`items`**) backed by **`IItemDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Plan: [NEO-53 implementation plan](../../docs/plans/NEO-53-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-53.md`](../../docs/manual-qa/NEO-53.md); Bruno: `bruno/neon-sprawl-server/item-definitions/`.

View File

@ -0,0 +1,8 @@
-- NEO-61: per-interactable remaining gather capacity (world-wide; not per-player).
CREATE TABLE IF NOT EXISTS resource_node_instance (
interactable_id TEXT PRIMARY KEY,
remaining_gathers INTEGER NOT NULL CHECK (remaining_gathers >= 0),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE resource_node_instance IS 'Persisted remaining gathers per world resource-node instance (NEO-61); lazy-init from catalog maxGathers.';