12 KiB
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 · Epic 3 Slice 2 · backlog E3M1-05 |
| Branch | NEO-61-e3m1-world-node-instance-depletion-store |
| Precursor | NEO-59 — IResourceNodeDefinitionRegistry + DI (Done on main); NEO-60 — definition HTTP (Done on main) |
| Pattern | 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 nodeDefIds 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):
IResourceNodeInstanceStore,InMemoryResourceNodeInstanceStore,PostgresResourceNodeInstanceStore,PostgresResourceNodeInstanceBootstrap,ResourceNodeInstanceServiceCollectionExtensions.ResourceNodeInstanceOperations— lazy init fromIResourceNodeDefinitionRegistry, atomic successful-gather decrement, stablereasonCodestrings.- Domain types:
ResourceNodeInstanceSnapshot,ResourceNodeInstanceMutationOutcome,ResourceNodeInstanceReasonCodes. - Migration
V006__resource_node_instance.sql(world-wide rows keyed byinteractable_id; not per-player). - Unit + Postgres parity integration tests (AAA).
Out of scope (from Linear + backlog):
- Item grants, skill XP,
GatherResultengine (NEO-62). InteractionApiwiring andnode_depletedon interact (NEO-63).- HTTP read model for instance state (definition catalog only is NEO-60).
- Bruno, client HUD, regen / respawn (E4.M2).
- Expanding
PrototypeInteractableRegistrybeyondprototype_resource_node_alpha(NEO-63).
Acceptance criteria checklist
- First gather on a fresh node succeeds; repeated gathers until capacity returns
node_depletedwithout going negative. - Postgres + in-memory parity tests.
Technical approach
-
Keying (prototype): Store rows keyed by lowercase
interactableId. Prototype wiring assumesinteractableId == nodeDefId(E3.M1 freeze); operations resolvemaxGathersviaIResourceNodeDefinitionRegistry.TryGetDefinition(interactableId, …). -
Lazy initialization: When
ResourceNodeInstanceOperations.TryCommitSuccessfulGatherDecrementruns 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).
- If registry miss → deny
-
Atomic decrement (store):
TryDecrementRemainingGathers(interactableId, out previous, out remaining):- Requires an existing row with
remaining > 0. - Sets
remaining = previous - 1in one compare-and-swap (in-memory per-id lock) or singleUPDATE … WHERE remaining_gathers > 0 RETURNING …(Postgres). - Returns
falsewhen row missing or already0(operations maps tonode_depletedafter lazy init path).
- Requires an existing row with
-
Operations entry point:
TryCommitSuccessfulGatherDecrement(interactableId, registry, store, out outcome):- Normalize id (trim + lowercase).
- Lazy-init as above.
- Call store decrement; on success →
Appliedwith snapshot{ interactableId, remainingGathers }. - On decrement fail with row at
0→Denied+node_depleted. - On registry miss →
Denied+unknown_node.
-
Depleted persistence: When decrement reaches
0, keep the row (remaining_gathers = 0). Subsequent decrements denynode_depletedwithout negative values. -
Persistence policy (NEO-54 / NEO-38 mirror):
AddResourceNodeInstanceStorechoosesPostgresResourceNodeInstanceStorewhenConnectionStrings__NeonSprawlis set; elseInMemoryResourceNodeInstanceStore(empty map at startup — no pre-seed).- Postgres:
resource_node_instancetable —interactable_id TEXT PRIMARY KEY,remaining_gathers INTEGER NOT NULL CHECK (remaining_gathers >= 0),updated_at TIMESTAMPTZ. - Bootstrap:
PostgresResourceNodeInstanceBootstrap.EnsureSchemaloads embeddedV006__resource_node_instance.sqlfromdb/migrations/. - Chain registration from
ResourceNodeCatalogServiceCollectionExtensions.AddResourceNodeCatalog(after registry) to keepProgram.csthin.
-
Reason codes (snake_case constants):
node_depleted— row exists andremainingGathers <= 0(or decrement rejected at zero).unknown_node—interactableIdnot inIResourceNodeDefinitionRegistry.- Document in code XML + brief
server/README.mddepletion subsection (persistence only; interact deny deferred to NEO-63).
-
Tests (primary node:
prototype_resource_node_alpha,maxGathers10):- Operations: first decrement after lazy init →
remainingGathers == 9; loop 10 successful decrements then next →node_depleted; assert never negative. - Spot-check
prototype_urban_bulk_deltalazy 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).
- Operations: first decrement after lazy init →
-
Docs (on land): Update E3_M1 Related implementation slices, documentation_and_implementation_alignment.md E3.M1 row, and E3M1-05 checkboxes in 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. |
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 = 0persisted; subsequent commits returnnode_depleted. - Defer interactable registry expansion — NEO-63 adds beta/gamma/delta world anchors.