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