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

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-59IResourceNodeDefinitionRegistry + DI (Done on main); NEO-60 — definition HTTP (Done on main)
Pattern NEO-54IPlayerInventoryStore + PlayerInventoryOperations persistence policy

Kickoff clarifications

Topic Question Agent recommendation Answer
Architecture Split store + operations vs monolithic store? SplitIResourceNodeInstanceStore (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 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).
  • InteractionApi wiring and node_depleted on interact (NEO-63).
  • HTTP read model for instance state (definition catalog only is NEO-60).
  • Bruno, client HUD, regen / respawn (E4.M2).
  • 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); 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 0Denied + 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_nodeinteractableId 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 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 rowsremainingGathers = 0 persisted; subsequent commits return node_depleted.
  • Defer interactable registry expansion — NEO-63 adds beta/gamma/delta world anchors.