diff --git a/docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md b/docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md index cdbc968..9274971 100644 --- a/docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md +++ b/docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md @@ -96,6 +96,8 @@ Epic 3 **Slice 2** — [gather nodes and outputs](../epics/epic_03_crafting_econ **Resource node definitions HTTP (NEO-60):** **`GET /game/world/resource-node-definitions`** — versioned read-only projection (`schemaVersion` **1**, **`nodes`**) backed by **`IResourceNodeDefinitionRegistry`**; each row includes nested **`yield`** summary. Bruno `bruno/neon-sprawl-server/resource-node-definitions/`. Plan: [NEO-60 implementation plan](../../plans/NEO-60-implementation-plan.md). +**Resource node instance depletion store (NEO-61):** **`IResourceNodeInstanceStore`** + **`ResourceNodeInstanceOperations`** — lazy-init remaining gathers from catalog **`maxGathers`**, atomic decrement, **`node_depleted`** / **`unknown_node`** reason codes; Postgres **`V006`** or in-memory fallback. Interact wiring deferred to **E3M1-07** ([NEO-63](https://linear.app/neon-sprawl/issue/NEO-63)). Plan: [NEO-61 implementation plan](../../plans/NEO-61-implementation-plan.md). + **Linear backlog (decomposed):** [E3M1-prototype-backlog.md](../../plans/E3M1-prototype-backlog.md) — **E3M1-01** [NEO-57](https://linear.app/neon-sprawl/issue/NEO-57) through **E3M1-08** [NEO-64](https://linear.app/neon-sprawl/issue/NEO-64). ## Source anchors diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index 93b8714..309724d 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -53,7 +53,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not | E2.M1 | In Progress | **NEO-33 landed:** frozen prototype trio **`salvage`** / **`refine`** / **`intrusion`** in [`content/skills/prototype_skills.json`](../../../content/skills/prototype_skills.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) enforce schema, duplicate `id`, exact trio ids, and category coverage; designer note on **`allowedXpSourceKinds`** in [E2_M1](E2_M1_SkillDefinitionRegistry.md) + [`content/README.md`](../../../content/README.md). **NEO-34 landed:** fail-fast server load of `content/skills/*.json` at startup — `server/NeonSprawl.Server/Game/Skills/` ([NEO-34](../../plans/NEO-34-implementation-plan.md)); config + discovery in [server README — Skill catalog](../../../server/README.md#skill-catalog-contentskills-neo-34). **NEO-35 landed:** `ISkillDefinitionRegistry` / `SkillDefinitionRegistry` + DI ([NEO-35](../../plans/NEO-35-implementation-plan.md)). **NEO-36 landed:** versioned **`GET /game/world/skill-definitions`** ([NEO-36](../../plans/NEO-36-implementation-plan.md)) — `SkillDefinitionsWorldApi` + `SkillDefinitionsListDtos` in `server/NeonSprawl.Server/Game/Skills/`; Bruno `bruno/neon-sprawl-server/skill-definitions/`; manual QA [`NEO-36`](../../manual-qa/NEO-36.md). **E2.M2 consumer (NEO-38 landed):** grant path validates `skillId` + `sourceKind` vs catalog **`allowedXpSourceKinds`** on **`POST …/skill-progression`** ([NEO-38](../../plans/NEO-38-implementation-plan.md)); see [server README — Skill progression grant](../../../server/README.md#skill-progression-grant-neo-38) and [E2_M2](E2_M2_XpAwardAndLevelEngine.md). | [NEO-33](../../plans/NEO-33-implementation-plan.md), [NEO-34](../../plans/NEO-34-implementation-plan.md), [NEO-35](../../plans/NEO-35-implementation-plan.md), [NEO-36](../../plans/NEO-36-implementation-plan.md); [E2_M1](E2_M1_SkillDefinitionRegistry.md); [module_dependency_register.md](module_dependency_register.md) **E2.M1 note**; `server/NeonSprawl.Server/Game/Skills/`; [`docs/manual-qa/NEO-36.md`](../../manual-qa/NEO-36.md); [server README — Skill definitions (NEO-36)](../../../server/README.md#skill-definitions-neo-36) | | E2.M3 | In Progress | **NEO-45 landed:** prototype **`salvage`** mastery catalog + CI gates (see [NEO-45 plan](../../plans/NEO-45-implementation-plan.md)). **NEO-46 landed:** fail-fast server load under `server/NeonSprawl.Server/Game/Mastery/` — `MasteryCatalogLoader`, `IMasteryCatalogRegistry`, cross-check vs `ISkillDefinitionRegistry`, Slice 4 + **`tierIndex`** 1..N gate; see [NEO-46 plan](../../plans/NEO-46-implementation-plan.md). **NEO-47 landed:** `PerkUnlockEngine`, `IPlayerPerkStateStore` + **`V004`**, level-up hook in skill XP grants; see [NEO-47 plan](../../plans/NEO-47-implementation-plan.md). **NEO-49 landed:** comment-only **`perk_unlock`** telemetry hook site in [`PerkUnlockEngine.TryUnlockPerks`](../../../server/NeonSprawl.Server/Game/Mastery/PerkUnlockEngine.cs) ([NEO-49 plan](../../plans/NEO-49-implementation-plan.md), [`NEO-49` manual QA](../../manual-qa/NEO-49.md)); [server README — Perk unlock telemetry (NEO-49)](../../../server/README.md#perk-unlock-engine-and-telemetry-hooks-neo-47-neo-49). **NEO-48 landed:** **`GET`/`POST /game/players/{id}/perk-state`** — `PerkStateApi` + DTOs in `Game/Mastery/` ([NEO-48](../../plans/NEO-48-implementation-plan.md), [`NEO-48` manual QA](../../manual-qa/NEO-48.md)); [server README — Perk state (NEO-48)](../../../server/README.md#perk-state-neo-48); Bruno `bruno/neon-sprawl-server/perk-state/`. | [NEO-45](../../plans/NEO-45-implementation-plan.md), [NEO-46](../../plans/NEO-46-implementation-plan.md), [NEO-47](../../plans/NEO-47-implementation-plan.md), [NEO-48](../../plans/NEO-48-implementation-plan.md), [NEO-49](../../plans/NEO-49-implementation-plan.md), [E2M3-pre-production-backlog](../../plans/E2M3-pre-production-backlog.md), [E2_M3](E2_M3_MasteryAndPerkUnlocks.md) | | E2.M2 | In Progress | **NEO-37 landed:** versioned **`GET /game/players/{id}/skill-progression`** ([NEO-37](../../plans/NEO-37-implementation-plan.md)) — read model for every registered skill; known-player gate via `IPositionStateStore`; [server README — Skill progression snapshot (NEO-37)](../../../server/README.md#skill-progression-snapshot-neo-37); manual QA [`NEO-37`](../../manual-qa/NEO-37.md). **NEO-38 landed:** **`POST`** same path — grant apply, persistence (`IPlayerSkillProgressionStore`, `V003` migration), structured denies + **`levelUps`** ([NEO-38](../../plans/NEO-38-implementation-plan.md)); manual QA [`NEO-38`](../../manual-qa/NEO-38.md); Bruno `bruno/neon-sprawl-server/skill-progression/`; [server README — Skill progression grant (NEO-38)](../../../server/README.md#skill-progression-grant-neo-38). **NEO-39 landed:** data-driven `LevelCurve` content + schema + CI validation (`*_level_curve.json`) with fail-fast startup schema checks; progression GET/POST level resolution now uses `ISkillLevelCurve` content-backed thresholds ([NEO-39](../../plans/NEO-39-implementation-plan.md)); manual QA [`NEO-39`](../../manual-qa/NEO-39.md). **NEO-40 landed:** comment-only telemetry hook sites in [`SkillProgressionSnapshotApi.cs`](../../../server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotApi.cs) on **`POST …/skill-progression`** for future **`xp_grant`** / **`level_up`** ([NEO-40](../../plans/NEO-40-implementation-plan.md), [`NEO-40` manual QA](../../manual-qa/NEO-40.md)). **NEO-41 landed:** gather prototype — **`POST …/interact`** with **`kind: resource_node`** grants **`salvage`** + **`activity`** (10 XP) via [`SkillProgressionGrantOperations`](../../../server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantOperations.cs) ([NEO-41](../../plans/NEO-41-implementation-plan.md), [`NEO-41` manual QA](../../manual-qa/NEO-41.md)); [server README — Interaction](../../../server/README.md#interaction-neo-9). **NEO-42 landed (prep):** **`RefineActivitySkillXpGrant`** / **`RefineSkillXpConstants`** + deny-registry factory + tests — same NEO-38 grant stack; **E3.M2** must invoke on craft/refine success ([NEO-42](../../plans/NEO-42-implementation-plan.md), [`NEO-42` manual QA](../../manual-qa/NEO-42.md)); [server README — Craft / refine hook (NEO-42)](../../../server/README.md#craft--refine-hook--skill-xp-neo-42). **NEO-43 landed (prep):** **`MissionRewardSkillXpGrant`** / **`MissionRewardSkillXpConstants`** + deny-registry factory + tests — same NEO-38 grant stack with fixed **`sourceKind: mission_reward`**; **E7.M2** must invoke from quest hand-in ([NEO-43](../../plans/NEO-43-implementation-plan.md), [`NEO-43` manual QA](../../manual-qa/NEO-43.md)); [server README — Mission / quest reward (NEO-43)](../../../server/README.md#mission--quest-reward--skill-xp-neo-43). **Slice 3 still open:** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44). See [epic_02 — E2.M2 + Slice 3](../epics/epic_02_skills_and_progression.md). | [NEO-37](../../plans/NEO-37-implementation-plan.md), [NEO-38](../../plans/NEO-38-implementation-plan.md), [NEO-39](../../plans/NEO-39-implementation-plan.md), [NEO-40](../../plans/NEO-40-implementation-plan.md), [NEO-41](../../plans/NEO-41-implementation-plan.md), [NEO-42](../../plans/NEO-42-implementation-plan.md), [NEO-43](../../plans/NEO-43-implementation-plan.md), [E2_M2](E2_M2_XpAwardAndLevelEngine.md); label **`E2.M2`** on NEO-37–NEO-41, NEO-42–NEO-44 | -| E3.M1 | In Progress | **NEO-41 landed (prototype):** `POST …/interact` success on **`resource_node`** (`prototype_resource_node_alpha`) applies **`salvage`** skill XP (**`sourceKind: activity`**, 10 XP) via shared NEO-38 grant operations. **NEO-57 landed:** frozen four-node catalog in [`content/resource-nodes/`](../../../content/resource-nodes/); [`resource-node-def.schema.json`](../../../content/schemas/resource-node-def.schema.json), [`resource-yield-row.schema.json`](../../../content/schemas/resource-yield-row.schema.json); CI gates in [`validate_content.py`](../../../scripts/validate_content.py). **NEO-58 landed:** fail-fast server load of `content/resource-nodes/*` at startup — `server/NeonSprawl.Server/Game/Gathering/` ([NEO-58](../../plans/NEO-58-implementation-plan.md)); [server README — Resource-node catalog](../../../server/README.md#resource-node-catalog-contentresource-nodes-neo-58). **NEO-59 landed:** injectable **`IResourceNodeDefinitionRegistry`** + lookup tests ([NEO-59](../../plans/NEO-59-implementation-plan.md)). **NEO-60 landed:** **`GET /game/world/resource-node-definitions`** — `ResourceNodeDefinitionsWorldApi` + DTOs in `Game/Gathering/` ([NEO-60](../../plans/NEO-60-implementation-plan.md), [`NEO-60` manual QA](../../manual-qa/NEO-60.md)); [server README — Resource node definitions (NEO-60)](../../../server/README.md#resource-node-definitions-neo-60); Bruno `bruno/neon-sprawl-server/resource-node-definitions/`. **Still planned:** `GatherResult`, depletion, inventory grants per [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md). | [NEO-41](../../plans/NEO-41-implementation-plan.md), [NEO-57](../../plans/NEO-57-implementation-plan.md), [NEO-58](../../plans/NEO-58-implementation-plan.md), [NEO-59](../../plans/NEO-59-implementation-plan.md), [NEO-60](../../plans/NEO-60-implementation-plan.md), [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md); `content/resource-nodes/`, `server/NeonSprawl.Server/Game/Gathering/`, `server/NeonSprawl.Server/Game/Interaction/` | +| E3.M1 | In Progress | **NEO-41 landed (prototype):** `POST …/interact` success on **`resource_node`** (`prototype_resource_node_alpha`) applies **`salvage`** skill XP (**`sourceKind: activity`**, 10 XP) via shared NEO-38 grant operations. **NEO-57 landed:** frozen four-node catalog in [`content/resource-nodes/`](../../../content/resource-nodes/); [`resource-node-def.schema.json`](../../../content/schemas/resource-node-def.schema.json), [`resource-yield-row.schema.json`](../../../content/schemas/resource-yield-row.schema.json); CI gates in [`validate_content.py`](../../../scripts/validate_content.py). **NEO-58 landed:** fail-fast server load of `content/resource-nodes/*` at startup — `server/NeonSprawl.Server/Game/Gathering/` ([NEO-58](../../plans/NEO-58-implementation-plan.md)); [server README — Resource-node catalog](../../../server/README.md#resource-node-catalog-contentresource-nodes-neo-58). **NEO-59 landed:** injectable **`IResourceNodeDefinitionRegistry`** + lookup tests ([NEO-59](../../plans/NEO-59-implementation-plan.md)). **NEO-60 landed:** **`GET /game/world/resource-node-definitions`** — `ResourceNodeDefinitionsWorldApi` + DTOs in `Game/Gathering/` ([NEO-60](../../plans/NEO-60-implementation-plan.md), [`NEO-60` manual QA](../../manual-qa/NEO-60.md)); [server README — Resource node definitions (NEO-60)](../../../server/README.md#resource-node-definitions-neo-60); Bruno `bruno/neon-sprawl-server/resource-node-definitions/`. **NEO-61 landed:** **`IResourceNodeInstanceStore`** + **`ResourceNodeInstanceOperations`** — lazy-init depletion, **`V006`** migration ([NEO-61](../../plans/NEO-61-implementation-plan.md)); [server README — Resource node instance depletion (NEO-61)](../../../server/README.md#resource-node-instance-depletion-neo-61). **Still planned:** `GatherResult`, inventory grants, interact wiring per [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md). | [NEO-41](../../plans/NEO-41-implementation-plan.md), [NEO-57](../../plans/NEO-57-implementation-plan.md), [NEO-58](../../plans/NEO-58-implementation-plan.md), [NEO-59](../../plans/NEO-59-implementation-plan.md), [NEO-60](../../plans/NEO-60-implementation-plan.md), [NEO-61](../../plans/NEO-61-implementation-plan.md), [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md); `content/resource-nodes/`, `server/NeonSprawl.Server/Game/Gathering/`, `server/NeonSprawl.Server/Game/Interaction/` | | E3.M3 | In Progress | **NEO-50 landed:** frozen prototype six-item catalog in [`content/items/prototype_items.json`](../../../content/items/prototype_items.json); [`item-def.schema.json`](../../../content/schemas/item-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py). **NEO-51 landed:** fail-fast server load of `content/items/*_items.json` at startup — `server/NeonSprawl.Server/Game/Items/` ([NEO-51](../../plans/NEO-51-implementation-plan.md)); [server README — Item catalog](../../../server/README.md#item-catalog-contentitems-neo-51). **NEO-52 landed:** injectable **`IItemDefinitionRegistry`** + lookup tests ([NEO-52](../../plans/NEO-52-implementation-plan.md)). **NEO-53 landed:** **`GET /game/world/item-definitions`** — `ItemDefinitionsWorldApi` + DTOs in `Game/Items/` ([NEO-53](../../plans/NEO-53-implementation-plan.md), [`NEO-53` manual QA](../../manual-qa/NEO-53.md)); [server README — Item definitions (NEO-53)](../../../server/README.md#item-definitions-neo-53); Bruno `bruno/neon-sprawl-server/item-definitions/`. **NEO-54 landed:** **`IPlayerInventoryStore`** + **`PlayerInventoryOperations`** — 24 bag + 1 equipment slots, stack rules, `V005` migration ([NEO-54](../../plans/NEO-54-implementation-plan.md)). **NEO-55 landed:** **`GET`/`POST /game/players/{id}/inventory`** — `PlayerInventoryApi` + DTOs in `Game/Items/` ([NEO-55](../../plans/NEO-55-implementation-plan.md)); [server README — Player inventory (NEO-54 store, NEO-55 HTTP)](../../../server/README.md#player-inventory-neo-54-store-neo-55-http); Bruno `bruno/neon-sprawl-server/inventory/`. **NEO-56 landed:** comment-only **`item_created`** / **`inventory_transfer_denied`** telemetry hook sites in [`PlayerInventoryOperations`](../../../server/NeonSprawl.Server/Game/Items/PlayerInventoryOperations.cs) ([NEO-56](../../plans/NEO-56-implementation-plan.md), [`NEO-56` manual QA](../../manual-qa/NEO-56.md)); no E9.M1 ingest. | [NEO-50](../../plans/NEO-50-implementation-plan.md), [NEO-51](../../plans/NEO-51-implementation-plan.md), [NEO-52](../../plans/NEO-52-implementation-plan.md), [NEO-53](../../plans/NEO-53-implementation-plan.md), [NEO-54](../../plans/NEO-54-implementation-plan.md), [NEO-55](../../plans/NEO-55-implementation-plan.md), [NEO-56](../../plans/NEO-56-implementation-plan.md), [E3M3-prototype-backlog](../../plans/E3M3-prototype-backlog.md), [E3_M3](E3_M3_ItemizationAndInventorySchema.md) | --- diff --git a/docs/plans/E3M1-prototype-backlog.md b/docs/plans/E3M1-prototype-backlog.md index be8b775..710a73f 100644 --- a/docs/plans/E3M1-prototype-backlog.md +++ b/docs/plans/E3M1-prototype-backlog.md @@ -167,8 +167,10 @@ Working backlog for **Epic 3 — Slice 2** ([gather nodes and outputs](../decomp **Acceptance criteria** -- [ ] First gather on a fresh node succeeds; repeated gathers until capacity returns **`node_depleted`** without going negative. -- [ ] Postgres + in-memory parity tests. +- [x] First gather on a fresh node succeeds; repeated gathers until capacity returns **`node_depleted`** without going negative. +- [x] Postgres + in-memory parity tests. + +**Landed:** [NEO-61](https://linear.app/neon-sprawl/issue/NEO-61) — instance depletion store; gather engine is E3M1-06. --- diff --git a/docs/plans/NEO-61-implementation-plan.md b/docs/plans/NEO-61-implementation-plan.md index b9c6475..e2d5d07 100644 --- a/docs/plans/NEO-61-implementation-plan.md +++ b/docs/plans/NEO-61-implementation-plan.md @@ -43,8 +43,8 @@ ## 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. +- [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 diff --git a/server/NeonSprawl.Server.Tests/Game/Gathering/InMemoryResourceNodeInstanceStoreTests.cs b/server/NeonSprawl.Server.Tests/Game/Gathering/InMemoryResourceNodeInstanceStoreTests.cs new file mode 100644 index 0000000..76a20d2 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Gathering/InMemoryResourceNodeInstanceStoreTests.cs @@ -0,0 +1,76 @@ +using NeonSprawl.Server.Game.Gathering; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Gathering; + +public sealed class InMemoryResourceNodeInstanceStoreTests +{ + private const string NodeId = "prototype_resource_node_alpha"; + + [Fact] + public void TryEnsureInitialized_ShouldBeIdempotent() + { + // Arrange + var store = new InMemoryResourceNodeInstanceStore(); + + // Act + var first = store.TryEnsureInitialized(NodeId, initialRemainingGathers: 10); + var second = store.TryEnsureInitialized(NodeId, initialRemainingGathers: 99); + store.TryGetRemainingGathers(NodeId, out var snapshot); + + // Assert + Assert.True(first); + Assert.True(second); + Assert.Equal(10, snapshot.RemainingGathers); + } + + [Fact] + public void TryDecrementRemainingGathers_FromOne_ShouldReachZeroAndDenySecondDecrement() + { + // Arrange + var store = new InMemoryResourceNodeInstanceStore(); + store.TryEnsureInitialized(NodeId, initialRemainingGathers: 1); + + // Act + var first = store.TryDecrementRemainingGathers(NodeId, out var previous, out var remaining); + var second = store.TryDecrementRemainingGathers(NodeId, out _, out var afterDeny); + + // Assert + Assert.True(first); + Assert.Equal(1, previous); + Assert.Equal(0, remaining); + Assert.False(second); + Assert.Equal(0, afterDeny); + store.TryGetRemainingGathers(NodeId, out var persisted); + Assert.Equal(0, persisted.RemainingGathers); + } + + [Fact] + public void TryGetRemainingGathers_ShouldNormalizeInteractableId() + { + // Arrange + var store = new InMemoryResourceNodeInstanceStore(); + store.TryEnsureInitialized(NodeId, initialRemainingGathers: 7); + + // Act + var found = store.TryGetRemainingGathers($" {NodeId.ToUpperInvariant()} ", out var snapshot); + + // Assert + Assert.True(found); + Assert.Equal(NodeId, snapshot.InteractableId); + Assert.Equal(7, snapshot.RemainingGathers); + } + + [Fact] + public void TryGetRemainingGathers_ForUnknownId_ShouldReturnFalse() + { + // Arrange + var store = new InMemoryResourceNodeInstanceStore(); + + // Act + var found = store.TryGetRemainingGathers("missing-node", out _); + + // Assert + Assert.False(found); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Gathering/ResourceNodeInstanceOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Gathering/ResourceNodeInstanceOperationsTests.cs new file mode 100644 index 0000000..7ea881a --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Gathering/ResourceNodeInstanceOperationsTests.cs @@ -0,0 +1,96 @@ +using Microsoft.Extensions.DependencyInjection; +using NeonSprawl.Server.Game.Gathering; +using NeonSprawl.Server.Tests; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Gathering; + +public sealed class ResourceNodeInstanceOperationsTests +{ + private const string AlphaId = "prototype_resource_node_alpha"; + private const string DeltaId = "prototype_urban_bulk_delta"; + + [Fact] + public void TryCommitSuccessfulGatherDecrement_OnFreshNode_ShouldLazyInitAndDecrementToNine() + { + // Arrange + using var factory = new InMemoryWebApplicationFactory(); + var registry = factory.Services.GetRequiredService(); + var store = factory.Services.GetRequiredService(); + + // Act + var outcome = ResourceNodeInstanceOperations.TryCommitSuccessfulGatherDecrement(AlphaId, registry, store); + + // Assert + Assert.Equal(ResourceNodeInstanceMutationKind.Applied, outcome.Kind); + Assert.Null(outcome.ReasonCode); + Assert.NotNull(outcome.Snapshot); + Assert.Equal(AlphaId, outcome.Snapshot!.Value.InteractableId); + Assert.Equal(9, outcome.Snapshot.Value.RemainingGathers); + } + + [Fact] + public void TryCommitSuccessfulGatherDecrement_RepeatedUntilCapacity_ShouldReturnNodeDepletedWithoutGoingNegative() + { + // Arrange + using var factory = new InMemoryWebApplicationFactory(); + var registry = factory.Services.GetRequiredService(); + var store = factory.Services.GetRequiredService(); + + // Act — ten successful gathers (10 → 0) + ResourceNodeInstanceMutationOutcome lastApplied = default; + for (var i = 0; i < 10; i++) + { + lastApplied = ResourceNodeInstanceOperations.TryCommitSuccessfulGatherDecrement(AlphaId, registry, store); + Assert.Equal(ResourceNodeInstanceMutationKind.Applied, lastApplied.Kind); + Assert.True(lastApplied.Snapshot!.Value.RemainingGathers >= 0); + } + + var depleted = ResourceNodeInstanceOperations.TryCommitSuccessfulGatherDecrement(AlphaId, registry, store); + + // Assert + Assert.Equal(0, lastApplied.Snapshot!.Value.RemainingGathers); + Assert.Equal(ResourceNodeInstanceMutationKind.Denied, depleted.Kind); + Assert.Equal(ResourceNodeInstanceReasonCodes.NodeDepleted, depleted.ReasonCode); + Assert.NotNull(depleted.Snapshot); + Assert.Equal(0, depleted.Snapshot!.Value.RemainingGathers); + store.TryGetRemainingGathers(AlphaId, out var persisted); + Assert.Equal(0, persisted.RemainingGathers); + } + + [Fact] + public void TryCommitSuccessfulGatherDecrement_ForUnknownNode_ShouldDenyWithoutCreatingRow() + { + // Arrange + using var factory = new InMemoryWebApplicationFactory(); + var registry = factory.Services.GetRequiredService(); + var store = factory.Services.GetRequiredService(); + const string unknownId = "not_a_real_node_def"; + + // Act + var outcome = ResourceNodeInstanceOperations.TryCommitSuccessfulGatherDecrement(unknownId, registry, store); + var rowExists = store.TryGetRemainingGathers(unknownId, out _); + + // Assert + Assert.Equal(ResourceNodeInstanceMutationKind.Denied, outcome.Kind); + Assert.Equal(ResourceNodeInstanceReasonCodes.UnknownNode, outcome.ReasonCode); + Assert.Null(outcome.Snapshot); + Assert.False(rowExists); + } + + [Fact] + public void TryCommitSuccessfulGatherDecrement_ForDeltaNode_ShouldLazyInitAtTen() + { + // Arrange + using var factory = new InMemoryWebApplicationFactory(); + var registry = factory.Services.GetRequiredService(); + var store = factory.Services.GetRequiredService(); + + // Act + var outcome = ResourceNodeInstanceOperations.TryCommitSuccessfulGatherDecrement(DeltaId, registry, store); + + // Assert + Assert.Equal(ResourceNodeInstanceMutationKind.Applied, outcome.Kind); + Assert.Equal(9, outcome.Snapshot!.Value.RemainingGathers); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Gathering/ResourceNodeInstancePersistenceIntegrationTests.cs b/server/NeonSprawl.Server.Tests/Game/Gathering/ResourceNodeInstancePersistenceIntegrationTests.cs new file mode 100644 index 0000000..133e266 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Gathering/ResourceNodeInstancePersistenceIntegrationTests.cs @@ -0,0 +1,104 @@ +using Microsoft.Extensions.DependencyInjection; +using NeonSprawl.Server.Game.Gathering; +using NeonSprawl.Server.Tests.Game.PositionState; +using Npgsql; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Gathering; + +[Collection("Postgres integration")] +public sealed class ResourceNodeInstancePersistenceIntegrationTests(PostgresIntegrationHarness harness) +{ + private PostgresWebApplicationFactory Factory => harness.Factory; + + private const string AlphaId = "prototype_resource_node_alpha"; + + [RequirePostgresFact] + public async Task TryCommitSuccessfulGatherDecrementAcrossNewFactory_ShouldPersistRemainingGathers() + { + // Arrange + await ResetInstanceTableAsync(); + ResourceNodeInstanceMutationOutcome firstOutcome; + + // Act — write through first host + using (var firstScope = Factory.Services.CreateScope()) + { + var registry = firstScope.ServiceProvider.GetRequiredService(); + var store = firstScope.ServiceProvider.GetRequiredService(); + firstOutcome = ResourceNodeInstanceOperations.TryCommitSuccessfulGatherDecrement(AlphaId, registry, store); + } + + // Act — read back through a fresh host + await using var secondFactory = new PostgresWebApplicationFactory(); + using var secondScope = secondFactory.Services.CreateScope(); + var readStore = secondScope.ServiceProvider.GetRequiredService(); + var readBack = readStore.TryGetRemainingGathers(AlphaId, out var snapshot); + + // Assert + Assert.Equal(ResourceNodeInstanceMutationKind.Applied, firstOutcome.Kind); + Assert.Equal(9, firstOutcome.Snapshot!.Value.RemainingGathers); + Assert.True(readBack); + Assert.Equal(9, snapshot.RemainingGathers); + } + + [RequirePostgresFact] + public async Task TryCommitSuccessfulGatherDecrement_WhenDepleted_ShouldPersistZeroRow() + { + // Arrange + await ResetInstanceTableAsync(); + using (var seedScope = Factory.Services.CreateScope()) + { + var registry = seedScope.ServiceProvider.GetRequiredService(); + var store = seedScope.ServiceProvider.GetRequiredService(); + for (var i = 0; i < 10; i++) + { + _ = ResourceNodeInstanceOperations.TryCommitSuccessfulGatherDecrement(AlphaId, registry, store); + } + } + + // Act + ResourceNodeInstanceMutationOutcome denyOutcome; + await using var secondFactory = new PostgresWebApplicationFactory(); + using (var scope = secondFactory.Services.CreateScope()) + { + var registry = scope.ServiceProvider.GetRequiredService(); + var store = scope.ServiceProvider.GetRequiredService(); + denyOutcome = ResourceNodeInstanceOperations.TryCommitSuccessfulGatherDecrement(AlphaId, registry, store); + store.TryGetRemainingGathers(AlphaId, out var snapshot); + + // Assert + Assert.Equal(ResourceNodeInstanceMutationKind.Denied, denyOutcome.Kind); + Assert.Equal(ResourceNodeInstanceReasonCodes.NodeDepleted, denyOutcome.ReasonCode); + Assert.Equal(0, snapshot.RemainingGathers); + } + } + + private async Task ResetInstanceTableAsync() + { + var cs = Environment.GetEnvironmentVariable("ConnectionStrings__NeonSprawl"); + if (string.IsNullOrWhiteSpace(cs)) + { + throw new InvalidOperationException("ConnectionStrings__NeonSprawl is not set."); + } + + _ = Factory.Services; + var ddlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V006__resource_node_instance.sql"); + if (!File.Exists(ddlPath)) + { + throw new FileNotFoundException($"Test DDL not found at '{ddlPath}'.", ddlPath); + } + + var ddl = await File.ReadAllTextAsync(ddlPath); + await using var conn = new NpgsqlConnection(cs); + await conn.OpenAsync(); + await using (var apply = new NpgsqlCommand(ddl, conn)) + { + await apply.ExecuteNonQueryAsync(); + } + + await using (var truncate = new NpgsqlCommand("TRUNCATE resource_node_instance;", conn)) + { + await truncate.ExecuteNonQueryAsync(); + } + } +} diff --git a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs index 0129796..8713388 100644 --- a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs @@ -53,6 +53,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); }); diff --git a/server/NeonSprawl.Server/Game/Gathering/IResourceNodeInstanceStore.cs b/server/NeonSprawl.Server/Game/Gathering/IResourceNodeInstanceStore.cs new file mode 100644 index 0000000..e96a6d0 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Gathering/IResourceNodeInstanceStore.cs @@ -0,0 +1,22 @@ +namespace NeonSprawl.Server.Game.Gathering; + +/// Persisted remaining gathers per world resource-node instance (NEO-61). +public interface IResourceNodeInstanceStore +{ + /// Reads the current snapshot when a row exists for . + bool TryGetRemainingGathers(string interactableId, out ResourceNodeInstanceSnapshot snapshot); + + /// + /// Creates a row at when absent; no-op when a row already exists. + /// + bool TryEnsureInitialized(string interactableId, int initialRemainingGathers); + + /// + /// Atomically decrements when remaining > 0. Returns false when the row is missing or already + /// depleted. + /// + bool TryDecrementRemainingGathers( + string interactableId, + out int previousRemaining, + out int remainingGathers); +} diff --git a/server/NeonSprawl.Server/Game/Gathering/InMemoryResourceNodeInstanceStore.cs b/server/NeonSprawl.Server/Game/Gathering/InMemoryResourceNodeInstanceStore.cs new file mode 100644 index 0000000..96abe62 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Gathering/InMemoryResourceNodeInstanceStore.cs @@ -0,0 +1,99 @@ +using System.Collections.Concurrent; + +namespace NeonSprawl.Server.Game.Gathering; + +/// Thread-safe in-memory node instance capacity; empty at startup — lazy rows only (NEO-61). +public sealed class InMemoryResourceNodeInstanceStore : IResourceNodeInstanceStore +{ + private readonly ConcurrentDictionary remainingById = new(StringComparer.Ordinal); + + private readonly ConcurrentDictionary idLocks = new(StringComparer.Ordinal); + + /// + public bool TryGetRemainingGathers(string interactableId, out ResourceNodeInstanceSnapshot snapshot) + { + var key = NormalizeInteractableId(interactableId); + if (key.Length == 0) + { + snapshot = default; + return false; + } + + lock (idLocks.GetOrAdd(key, _ => new object())) + { + if (!remainingById.TryGetValue(key, out var remaining)) + { + snapshot = default; + return false; + } + + snapshot = new ResourceNodeInstanceSnapshot(key, remaining); + return true; + } + } + + /// + public bool TryEnsureInitialized(string interactableId, int initialRemainingGathers) + { + var key = NormalizeInteractableId(interactableId); + if (key.Length == 0 || initialRemainingGathers < 0) + { + return false; + } + + lock (idLocks.GetOrAdd(key, _ => new object())) + { + if (remainingById.ContainsKey(key)) + { + return true; + } + + remainingById[key] = initialRemainingGathers; + return true; + } + } + + /// + public bool TryDecrementRemainingGathers( + string interactableId, + out int previousRemaining, + out int remainingGathers) + { + previousRemaining = 0; + remainingGathers = 0; + var key = NormalizeInteractableId(interactableId); + if (key.Length == 0) + { + return false; + } + + lock (idLocks.GetOrAdd(key, _ => new object())) + { + if (!remainingById.TryGetValue(key, out var current) || current <= 0) + { + if (current == 0) + { + remainingGathers = 0; + } + + return false; + } + + previousRemaining = current; + remainingGathers = current - 1; + remainingById[key] = remainingGathers; + return true; + } + } + + internal static string NormalizeInteractableId(string? interactableId) + { + var trimmed = interactableId?.Trim(); + if (string.IsNullOrEmpty(trimmed)) + { + return string.Empty; + } + + return trimmed.ToLowerInvariant(); + } +} diff --git a/server/NeonSprawl.Server/Game/Gathering/PostgresResourceNodeInstanceBootstrap.cs b/server/NeonSprawl.Server/Game/Gathering/PostgresResourceNodeInstanceBootstrap.cs new file mode 100644 index 0000000..8ba1705 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Gathering/PostgresResourceNodeInstanceBootstrap.cs @@ -0,0 +1,39 @@ +namespace NeonSprawl.Server.Game.Gathering; + +/// Applies NEO-61 resource-node instance table DDL once per process. +public static class PostgresResourceNodeInstanceBootstrap +{ + private static readonly string DdlRelativePath = Path.Combine("db", "migrations", "V006__resource_node_instance.sql"); + + private static readonly object SchemaGate = new(); + + private static int _schemaReady; + + public static void EnsureSchema(Npgsql.NpgsqlDataSource dataSource) + { + if (System.Threading.Volatile.Read(ref _schemaReady) != 0) + { + return; + } + + lock (SchemaGate) + { + if (System.Threading.Volatile.Read(ref _schemaReady) != 0) + { + return; + } + + var ddlPath = Path.Combine(AppContext.BaseDirectory, DdlRelativePath); + if (!File.Exists(ddlPath)) + { + throw new FileNotFoundException($"NEO-61 DDL not found at '{ddlPath}'.", ddlPath); + } + + var ddl = File.ReadAllText(ddlPath); + using var conn = dataSource.OpenConnection(); + using var cmd = new Npgsql.NpgsqlCommand(ddl, conn); + cmd.ExecuteNonQuery(); + System.Threading.Volatile.Write(ref _schemaReady, 1); + } + } +} diff --git a/server/NeonSprawl.Server/Game/Gathering/PostgresResourceNodeInstanceStore.cs b/server/NeonSprawl.Server/Game/Gathering/PostgresResourceNodeInstanceStore.cs new file mode 100644 index 0000000..f3d636b --- /dev/null +++ b/server/NeonSprawl.Server/Game/Gathering/PostgresResourceNodeInstanceStore.cs @@ -0,0 +1,100 @@ +namespace NeonSprawl.Server.Game.Gathering; + +/// PostgreSQL-backed world node instance capacity keyed by interactable id (NEO-61). +public sealed class PostgresResourceNodeInstanceStore(Npgsql.NpgsqlDataSource dataSource) : IResourceNodeInstanceStore +{ + /// + public bool TryGetRemainingGathers(string interactableId, out ResourceNodeInstanceSnapshot snapshot) + { + var key = InMemoryResourceNodeInstanceStore.NormalizeInteractableId(interactableId); + if (key.Length == 0) + { + snapshot = default; + return false; + } + + PostgresResourceNodeInstanceBootstrap.EnsureSchema(dataSource); + using var conn = dataSource.OpenConnection(); + using var cmd = new Npgsql.NpgsqlCommand( + "SELECT remaining_gathers FROM resource_node_instance WHERE interactable_id = @id;", + conn); + cmd.Parameters.AddWithValue("id", key); + var scalar = cmd.ExecuteScalar(); + if (scalar is null or DBNull) + { + snapshot = default; + return false; + } + + var remaining = Convert.ToInt32(scalar); + snapshot = new ResourceNodeInstanceSnapshot(key, remaining); + return true; + } + + /// + public bool TryEnsureInitialized(string interactableId, int initialRemainingGathers) + { + var key = InMemoryResourceNodeInstanceStore.NormalizeInteractableId(interactableId); + if (key.Length == 0 || initialRemainingGathers < 0) + { + return false; + } + + PostgresResourceNodeInstanceBootstrap.EnsureSchema(dataSource); + using var conn = dataSource.OpenConnection(); + using var cmd = new Npgsql.NpgsqlCommand( + """ + INSERT INTO resource_node_instance (interactable_id, remaining_gathers, updated_at) + VALUES (@id, @remaining, now()) + ON CONFLICT (interactable_id) DO NOTHING; + """, + conn); + cmd.Parameters.AddWithValue("id", key); + cmd.Parameters.AddWithValue("remaining", initialRemainingGathers); + cmd.ExecuteNonQuery(); + return true; + } + + /// + public bool TryDecrementRemainingGathers( + string interactableId, + out int previousRemaining, + out int remainingGathers) + { + previousRemaining = 0; + remainingGathers = 0; + var key = InMemoryResourceNodeInstanceStore.NormalizeInteractableId(interactableId); + if (key.Length == 0) + { + return false; + } + + PostgresResourceNodeInstanceBootstrap.EnsureSchema(dataSource); + using var conn = dataSource.OpenConnection(); + using var cmd = new Npgsql.NpgsqlCommand( + """ + UPDATE resource_node_instance + SET remaining_gathers = remaining_gathers - 1, + updated_at = now() + WHERE interactable_id = @id + AND remaining_gathers > 0 + RETURNING remaining_gathers + 1 AS previous_remaining, remaining_gathers; + """, + conn); + cmd.Parameters.AddWithValue("id", key); + using var reader = cmd.ExecuteReader(); + if (!reader.Read()) + { + if (TryGetRemainingGathers(key, out var depleted) && depleted.RemainingGathers == 0) + { + remainingGathers = 0; + } + + return false; + } + + previousRemaining = reader.GetInt32(0); + remainingGathers = reader.GetInt32(1); + return true; + } +} diff --git a/server/NeonSprawl.Server/Game/Gathering/ResourceNodeCatalogServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Gathering/ResourceNodeCatalogServiceCollectionExtensions.cs index 2fea0b1..248cb1e 100644 --- a/server/NeonSprawl.Server/Game/Gathering/ResourceNodeCatalogServiceCollectionExtensions.cs +++ b/server/NeonSprawl.Server/Game/Gathering/ResourceNodeCatalogServiceCollectionExtensions.cs @@ -51,6 +51,8 @@ public static class ResourceNodeCatalogServiceCollectionExtensions services.AddSingleton(sp => new ResourceNodeDefinitionRegistry(sp.GetRequiredService())); + services.AddResourceNodeInstanceStore(configuration); + return services; } } diff --git a/server/NeonSprawl.Server/Game/Gathering/ResourceNodeInstanceMutationOutcome.cs b/server/NeonSprawl.Server/Game/Gathering/ResourceNodeInstanceMutationOutcome.cs new file mode 100644 index 0000000..8c1f0bb --- /dev/null +++ b/server/NeonSprawl.Server/Game/Gathering/ResourceNodeInstanceMutationOutcome.cs @@ -0,0 +1,18 @@ +namespace NeonSprawl.Server.Game.Gathering; + +/// Result of (NEO-61). +public enum ResourceNodeInstanceMutationKind +{ + /// Validation or depletion deny — capacity unchanged when already depleted. + Denied, + + /// Decrement applied and persisted. + Applied, +} + +/// Populated when is . +/// Authoritative snapshot after apply, or current snapshot on depletion deny; null for unknown node. +public readonly record struct ResourceNodeInstanceMutationOutcome( + ResourceNodeInstanceMutationKind Kind, + string? ReasonCode, + ResourceNodeInstanceSnapshot? Snapshot); diff --git a/server/NeonSprawl.Server/Game/Gathering/ResourceNodeInstanceOperations.cs b/server/NeonSprawl.Server/Game/Gathering/ResourceNodeInstanceOperations.cs new file mode 100644 index 0000000..8c16594 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Gathering/ResourceNodeInstanceOperations.cs @@ -0,0 +1,49 @@ +namespace NeonSprawl.Server.Game.Gathering; + +/// Registry-backed lazy init and atomic depletion commits for world node instances (NEO-61). +public static class ResourceNodeInstanceOperations +{ + /// + /// Decrements remaining gathers by one after lazy-init from catalog maxGathers. Interact wiring (NEO-63) + /// maps to denied interact responses. + /// + public static ResourceNodeInstanceMutationOutcome TryCommitSuccessfulGatherDecrement( + string interactableId, + IResourceNodeDefinitionRegistry registry, + IResourceNodeInstanceStore store) + { + var key = InMemoryResourceNodeInstanceStore.NormalizeInteractableId(interactableId); + if (key.Length == 0 || !registry.TryGetDefinition(key, out var definition)) + { + return new ResourceNodeInstanceMutationOutcome( + ResourceNodeInstanceMutationKind.Denied, + ResourceNodeInstanceReasonCodes.UnknownNode, + null); + } + + if (!store.TryGetRemainingGathers(key, out _)) + { + if (!store.TryEnsureInitialized(key, definition.MaxGathers)) + { + return new ResourceNodeInstanceMutationOutcome( + ResourceNodeInstanceMutationKind.Denied, + ResourceNodeInstanceReasonCodes.UnknownNode, + null); + } + } + + if (store.TryDecrementRemainingGathers(key, out _, out var remaining)) + { + return new ResourceNodeInstanceMutationOutcome( + ResourceNodeInstanceMutationKind.Applied, + null, + new ResourceNodeInstanceSnapshot(key, remaining)); + } + + store.TryGetRemainingGathers(key, out var current); + return new ResourceNodeInstanceMutationOutcome( + ResourceNodeInstanceMutationKind.Denied, + ResourceNodeInstanceReasonCodes.NodeDepleted, + current.InteractableId.Length > 0 ? current : new ResourceNodeInstanceSnapshot(key, 0)); + } +} diff --git a/server/NeonSprawl.Server/Game/Gathering/ResourceNodeInstanceReasonCodes.cs b/server/NeonSprawl.Server/Game/Gathering/ResourceNodeInstanceReasonCodes.cs new file mode 100644 index 0000000..c3d7a70 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Gathering/ResourceNodeInstanceReasonCodes.cs @@ -0,0 +1,8 @@ +namespace NeonSprawl.Server.Game.Gathering; + +/// Stable deny reason codes for resource-node instance depletion (NEO-61). +public static class ResourceNodeInstanceReasonCodes +{ + public const string NodeDepleted = "node_depleted"; + public const string UnknownNode = "unknown_node"; +} diff --git a/server/NeonSprawl.Server/Game/Gathering/ResourceNodeInstanceServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Gathering/ResourceNodeInstanceServiceCollectionExtensions.cs new file mode 100644 index 0000000..927b2ef --- /dev/null +++ b/server/NeonSprawl.Server/Game/Gathering/ResourceNodeInstanceServiceCollectionExtensions.cs @@ -0,0 +1,22 @@ +using NeonSprawl.Server.Game.PositionState; + +namespace NeonSprawl.Server.Game.Gathering; + +/// Registers resource-node instance persistence: PostgreSQL when configured, otherwise in-memory (NEO-61). +public static class ResourceNodeInstanceServiceCollectionExtensions +{ + public static IServiceCollection AddResourceNodeInstanceStore(this IServiceCollection services, IConfiguration configuration) + { + var cs = configuration.GetConnectionString(PositionStateServiceCollectionExtensions.NeonSprawlConnectionStringName); + if (!string.IsNullOrWhiteSpace(cs)) + { + services.AddSingleton(); + } + else + { + services.AddSingleton(); + } + + return services; + } +} diff --git a/server/NeonSprawl.Server/Game/Gathering/ResourceNodeInstanceSnapshot.cs b/server/NeonSprawl.Server/Game/Gathering/ResourceNodeInstanceSnapshot.cs new file mode 100644 index 0000000..49ae12f --- /dev/null +++ b/server/NeonSprawl.Server/Game/Gathering/ResourceNodeInstanceSnapshot.cs @@ -0,0 +1,6 @@ +namespace NeonSprawl.Server.Game.Gathering; + +/// Server-authoritative remaining gather capacity for one world node instance (NEO-61). +/// Lowercase canonical id (prototype: same as nodeDefId). +/// Successful gathers still allowed before . +public readonly record struct ResourceNodeInstanceSnapshot(string InteractableId, int RemainingGathers); diff --git a/server/README.md b/server/README.md index ac6e649..6e2b16f 100644 --- a/server/README.md +++ b/server/README.md @@ -74,6 +74,17 @@ On success, **Information** logs include the resolved resource-nodes directory p curl -sS -i "http://localhost:5253/game/world/resource-node-definitions" ``` +## Resource node instance depletion (NEO-61) + +World-wide **remaining gathers** per **`interactableId`** live in **`Game/Gathering/`** as **`IResourceNodeInstanceStore`** + **`ResourceNodeInstanceOperations`**. Capacity is **lazy-initialized** from catalog **`maxGathers`** on first successful-gather commit; prototype wiring uses **`interactableId == nodeDefId`**. **`TryCommitSuccessfulGatherDecrement`** atomically decrements and returns stable deny codes: + +| Reason code | When | +|-------------|------| +| **`node_depleted`** | Row exists and **`remainingGathers`** is already **0** (decrement rejected). | +| **`unknown_node`** | **`interactableId`** not found in **`IResourceNodeDefinitionRegistry`**. | + +**Persistence:** same **[NEO-29-style](#position-persistence-neo-8) split** as inventory — **`resource_node_instance`** in PostgreSQL when **`ConnectionStrings:NeonSprawl`** is set (DDL [`V006__resource_node_instance.sql`](../db/migrations/V006__resource_node_instance.sql)), otherwise in-memory fallback (empty at startup). Interact deny wiring is **[NEO-63](https://linear.app/neon-sprawl/issue/NEO-63)**; plan: [NEO-61 implementation plan](../../docs/plans/NEO-61-implementation-plan.md). + ## Item definitions (NEO-53) **`GET /game/world/item-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`items`**) backed by **`IItemDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Plan: [NEO-53 implementation plan](../../docs/plans/NEO-53-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-53.md`](../../docs/manual-qa/NEO-53.md); Bruno: `bruno/neon-sprawl-server/item-definitions/`. diff --git a/server/db/migrations/V006__resource_node_instance.sql b/server/db/migrations/V006__resource_node_instance.sql new file mode 100644 index 0000000..12f57dc --- /dev/null +++ b/server/db/migrations/V006__resource_node_instance.sql @@ -0,0 +1,8 @@ +-- NEO-61: per-interactable remaining gather capacity (world-wide; not per-player). +CREATE TABLE IF NOT EXISTS resource_node_instance ( + interactable_id TEXT PRIMARY KEY, + remaining_gathers INTEGER NOT NULL CHECK (remaining_gathers >= 0), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +COMMENT ON TABLE resource_node_instance IS 'Persisted remaining gathers per world resource-node instance (NEO-61); lazy-init from catalog maxGathers.';