From 869d411b6f0998dc35c10029299bbbb5f0f9b982 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 24 May 2026 14:59:48 -0400 Subject: [PATCH] NEO-61: add implementation plan for node depletion store. Kickoff clarifications adopt split store/operations, lazy init, keep depleted rows at zero, and defer interactable registry expansion. --- docs/plans/NEO-61-implementation-plan.md | 146 +++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 docs/plans/NEO-61-implementation-plan.md diff --git a/docs/plans/NEO-61-implementation-plan.md b/docs/plans/NEO-61-implementation-plan.md new file mode 100644 index 0000000..b9c6475 --- /dev/null +++ b/docs/plans/NEO-61-implementation-plan.md @@ -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 + +- [ ] First gather on a fresh node succeeds; repeated gathers until capacity returns **`node_depleted`** without going negative. +- [ ] 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.