neon-sprawl/docs/plans/NEO-61-implementation-plan.md

147 lines
12 KiB
Markdown

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