From acf9fe125f76dd7e61f74770a82674ed6a8c6f56 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 24 May 2026 15:30:27 -0400 Subject: [PATCH 1/8] NEO-62: add gather engine implementation plan. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kickoff clarifications: inventory → XP → depletion commit order; compensating inventory rollback on XP failure. --- docs/plans/NEO-62-implementation-plan.md | 133 +++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 docs/plans/NEO-62-implementation-plan.md diff --git a/docs/plans/NEO-62-implementation-plan.md b/docs/plans/NEO-62-implementation-plan.md new file mode 100644 index 0000000..88cda64 --- /dev/null +++ b/docs/plans/NEO-62-implementation-plan.md @@ -0,0 +1,133 @@ +# NEO-62 — Implementation plan + +## Story reference + +| Field | Value | +|--------|--------| +| **Key** | NEO-62 | +| **Title** | E3.M1: GatherResult engine (yields + inventory + skill XP) | +| **Linear** | https://linear.app/neon-sprawl/issue/NEO-62/e3m1-gatherresult-engine-yields-inventory-skill-xp | +| **Module** | [E3.M1 — ResourceNodeAndGatherLoop](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md) · Epic 3 Slice 2 · backlog **E3M1-06** | +| **Branch** | `NEO-62-e3m1-gatherresult-engine-yields-inventory-skill-xp` | +| **Precursors** | [NEO-59](https://linear.app/neon-sprawl/issue/NEO-59) — `IResourceNodeDefinitionRegistry` + DI (**Done**); [NEO-61](https://linear.app/neon-sprawl/issue/NEO-61) — `IResourceNodeInstanceStore` + depletion ops (**Done**); [NEO-54](https://linear.app/neon-sprawl/issue/NEO-54) — `PlayerInventoryOperations` (**Done**); [NEO-38](https://linear.app/neon-sprawl/issue/NEO-38) / [NEO-41](https://linear.app/neon-sprawl/issue/NEO-41) — skill XP grant path (**Done**) | +| **Pattern** | [NEO-61](https://linear.app/neon-sprawl/issue/NEO-61) — static `*Operations` orchestrator over injectable stores; [NEO-54](https://linear.app/neon-sprawl/issue/NEO-54) — subsystem reason codes surfaced on gather deny | + +## Kickoff clarifications + +| Topic | Question | Agent recommendation | Answer | +|--------|----------|----------------------|--------| +| **Side-effect order** | Inventory / XP / depletion commit sequence? | **Inventory add → XP grant → depletion decrement last** — depletion only after successful grant; avoids consuming capacity on `inventory_full` (NEO-54 all-or-nothing). | **User:** inventory → XP → depletion last. | +| **XP failure after inventory** | Roll back items or partial success? | **Deny gather + compensating `TryRemoveStack`** — gather success must include items and XP per AC; NEO-41 silent XP no-op stays on interact until NEO-63. | **User:** rollback + deny. | + +## Goal, scope, and out-of-scope + +**Goal:** Single server operation resolving **`GatherResult`**: validate node + capacity, grant configured yield via **`PlayerInventoryOperations`**, award **`salvage`** XP via **`SkillProgressionGrantOperations`**, commit depletion via **`ResourceNodeInstanceOperations`** — engine-only, ready for interact wiring (NEO-63). + +**In scope (from Linear + [E3M1-06](E3M1-prototype-backlog.md#e3m1-06--gatherresult-engine-yields--inventory--skill-xp)):** + +- **`GatherOperations`** / **`GatherResult`** types in `server/NeonSprawl.Server/Game/Gathering/`. +- Stable deny **`reasonCode`** passthrough: `node_depleted`, `inventory_full`, `unknown_node`, plus gather-specific `progression_store_missing` when XP rollback path runs. +- Unit / integration tests (AAA) via **`InMemoryWebApplicationFactory`** with real catalogs and stores. +- Document **`GatherResult`** shape in module doc (server-internal until promoted to wire). + +**Out of scope (from Linear + backlog):** + +- **`InteractionApi`** wiring and duplicate NEO-41 XP removal ([NEO-63](https://linear.app/neon-sprawl/issue/NEO-63)). +- Client HUD, Bruno, HTTP gather route. +- Regen / respawn timers ([E4.M2](../decomposition/modules/E4_M2_SpawnEcologyController.md)). +- Telemetry hook sites ([NEO-64](https://linear.app/neon-sprawl/issue/NEO-64)). +- Cross-store transactions (prototype relies on ordered commits + compensating remove on XP failure). + +## Acceptance criteria checklist + +- [ ] Successful gather adds configured **`scrap_metal_bulk`** quantity to player bag. +- [ ] Full bag returns stable **`inventory_full`** without partial silent loss or depletion change. +- [ ] Skill XP grant uses same rules as NEO-38 / NEO-41 (`salvage`, `activity`, 10 XP via **`GatherSkillXpConstants`**). +- [ ] Depletion store decrements atomically with successful grant (decrement **last**, only when inventory + XP both succeed). + +## Technical approach + +1. **Entry point:** **`GatherOperations.TryGather(...)`** — static orchestrator accepting `playerId`, `interactableId`, and dependencies: + - **`IResourceNodeDefinitionRegistry`** (def + yield), + - **`IItemDefinitionRegistry`** + **`IPlayerInventoryStore`**, + - **`ISkillDefinitionRegistry`**, **`IPlayerSkillProgressionStore`**, **`ISkillLevelCurve`**, **`PerkUnlockEngine`**, + - **`IResourceNodeInstanceStore`**. + +2. **Normalize key:** **`ResourceNodeInstanceIds.Normalize(interactableId)`** (trim + lowercase); empty → **`unknown_node`**. + +3. **Definition + yield resolve:** **`TryGetDefinition`** and **`TryGetYield`** on normalized id. Either miss → **`Denied`** + **`GatherReasonCodes.UnknownNode`** (no writes). + +4. **Capacity pre-check (read-only, no decrement):** + - **`store.TryEnsureInitialized(key, definition.MaxGathers)`** (lazy init mirror of NEO-61). + - **`store.TryGetRemainingGathers(key, out snapshot)`** — if **`remainingGathers <= 0`** → **`Denied`** + **`GatherReasonCodes.NodeDepleted`** with snapshot (no inventory / XP / decrement). + +5. **Inventory grant (first mutation):** **`PlayerInventoryOperations.TryAddStack(playerId, yield.ItemId, yield.Quantity, itemRegistry, inventoryStore)`**. + - **`Denied`** + **`inventory_full`** (passthrough) → return immediately; depletion unchanged. + - **`StoreMissing`** → **`GatherReasonCodes.InventoryStoreMissing`** (gather surface; unlikely in tests). + - **`Applied`** → continue. + +6. **Skill XP (second mutation):** **`SkillProgressionGrantOperations.TryApplyGrant`** with **`GatherSkillXpConstants.SalvageSkillId`**, **`ActivityXpPerResourceNodeGather`**, **`ActivitySourceKind`**. + - **`Granted`** → continue. + - **`ProgressionStoreMissing`** or **`Denied`** → **compensating rollback:** **`PlayerInventoryOperations.TryRemoveStack`** same **`itemId`** / **`quantity`**; return **`Denied`** + **`GatherReasonCodes.ProgressionStoreMissing`** (or passthrough skill deny **`reasonCode`** from response when **`Denied`**). Depletion unchanged. + +7. **Depletion commit (last mutation):** **`ResourceNodeInstanceOperations.TryCommitSuccessfulGatherDecrement(key, nodeRegistry, store)`**. + - Expect **`Applied`** given step 4 pre-check; on unexpected **`Denied`**, attempt compensating inventory remove + surface **`node_depleted`** (defensive; document as prototype race risk — no XP rollback in this edge case). + +8. **Success envelope — `GatherResult`:** + - **`Success = true`**, **`ReasonCode = null`** + - **`GrantsApplied`**: single entry `{ itemId, quantity }` from yield row (prototype: one yield per node). + - **`RemainingGathers`**: from depletion outcome snapshot. + - **`XpGrantSummary`**: optional compact summary from grant response (e.g. **`skillId`**, **`amount`**, **`sourceKind`**) for tests / future wire. + +9. **Reason codes (`GatherReasonCodes`):** Re-export stable subsystem strings where applicable: + - **`unknown_node`**, **`node_depleted`**, **`inventory_full`** — same literals as NEO-61 / NEO-54. + - **`progression_store_missing`**, **`inventory_store_missing`** — gather-layer codes for store-missing paths. + +10. **Yield quantities (content, fixed):** Alpha **1**, beta **2**, gamma **3**, delta **5** `scrap_metal_bulk` per [`prototype_resource_yields.json`](../../content/resource-nodes/prototype_resource_yields.json). Tests spot-check alpha (1) and optionally delta (5). + +11. **Docs (on land):** Update [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md) **Related implementation slices** + **`GatherResult`** contract note; [documentation_and_implementation_alignment.md](documentation_and_implementation_alignment.md) E3.M1 row; E3M1-06 checkboxes in [E3M1-prototype-backlog.md](E3M1-prototype-backlog.md); brief **`server/README.md`** gather-engine subsection (engine-only; interact in NEO-63). + +## Files to add + +| Path | Purpose | +|------|---------| +| `server/NeonSprawl.Server/Game/Gathering/GatherReasonCodes.cs` | Stable gather deny codes (passthrough + store-missing). | +| `server/NeonSprawl.Server/Game/Gathering/GatherGrantApplied.cs` | One applied yield row in success envelope. | +| `server/NeonSprawl.Server/Game/Gathering/GatherXpGrantSummary.cs` | Compact XP summary on successful gather. | +| `server/NeonSprawl.Server/Game/Gathering/GatherResult.cs` | Success/deny envelope: grants, remainingGathers, reasonCode, xp summary. | +| `server/NeonSprawl.Server/Game/Gathering/GatherOperations.cs` | Orchestrator: pre-check → inventory → XP → depletion; rollback on XP failure. | +| `server/NeonSprawl.Server.Tests/Game/Gathering/GatherOperationsTests.cs` | AAA: success path, inventory_full, node_depleted, unknown_node, XP rollback, quantity per yield. | + +## Files to modify + +| Path | Rationale | +|------|-----------| +| `server/README.md` | Gather engine subsection: `GatherOperations`, commit order, reason codes; note interact wiring in NEO-63. | +| `docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md` | **Related implementation slices** — gather engine bullet (NEO-62); **`GatherResult`** field table alignment. | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E3.M1 row — note NEO-62 gather engine when landed. | +| `docs/plans/E3M1-prototype-backlog.md` | E3M1-06 acceptance checkboxes when landed. | + +## Tests + +| Test file | What it covers | +|-----------|----------------| +| `server/NeonSprawl.Server.Tests/Game/Gathering/GatherOperationsTests.cs` | **Integration-style (DI factory):** **`TryGather`** on **`prototype_resource_node_alpha`** — **`Success`**, bag quantity +1 **`scrap_metal_bulk`**, **`salvage`** XP +10, **`RemainingGathers == 9`**. **Full bag:** pre-fill 24 bag slots (`survey_drone_kit` ×1 each) → **`Denied`**, **`inventory_full`**, depletion row unchanged (still lazy-uninitialized or full capacity). **Depletion loop:** exhaust alpha (10 gathers) → next **`node_depleted`**, no inventory/XP change. **`unknown_node`** for bogus id. **XP failure:** custom test factory or stub progression store that returns **`ProgressionStoreMissing`** after inventory add → gather denied, inventory restored to pre-gather snapshot. **Optional:** delta node grants quantity **5**. **AAA** per [csharp-style](../../.cursor/rules/csharp-style.md). | + +No Bruno or HTTP tests — out of scope; manual gather flow waits for NEO-63. + +## Open questions / risks + +| Question / risk | Agent recommendation | Status | +|-----------------|----------------------|--------| +| **NEO-61 on `main`** | Branch from **`main`** where NEO-59/60/61 are merged (confirmed at kickoff). | **adopted** | +| **Duplicate XP via `InteractionApi`** | Leave NEO-41 interact hook until **NEO-63** removes it; engine-only scope for NEO-62. | **adopted** | +| **Depletion fails after inventory+XP** | Extremely unlikely after pre-check; defensive inventory rollback, no XP undo — document; no cross-store transaction in prototype. | **deferred** | +| **Per-lens yield quantities in tests** | Spot-check alpha (1) + delta (5); full four-node matrix optional. | **adopted** | + +None blocking. + +## Decisions (kickoff) + +- **Commit order:** inventory add → XP grant → depletion decrement **last**. +- **XP failure:** deny gather + compensating **`TryRemoveStack`**; depletion not committed. +- **Reason codes:** passthrough subsystem literals on gather deny surface; gather-specific codes for store-missing only. From f23af01687bd6dbc4b41b095c0ad3afffd198fd6 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 24 May 2026 15:41:26 -0400 Subject: [PATCH 2/8] NEO-62: add GatherOperations engine and tests. Orchestrates inventory grant, salvage XP, and depletion commit with compensating rollback on XP failure. --- .../Game/Gathering/GatherOperationsTests.cs | 311 ++++++++++++++++++ .../Game/Gathering/GatherGrantApplied.cs | 4 + .../Game/Gathering/GatherOperations.cs | 132 ++++++++ .../Game/Gathering/GatherReasonCodes.cs | 22 ++ .../Game/Gathering/GatherResult.cs | 15 + .../Game/Gathering/GatherXpGrantSummary.cs | 4 + 6 files changed, 488 insertions(+) create mode 100644 server/NeonSprawl.Server.Tests/Game/Gathering/GatherOperationsTests.cs create mode 100644 server/NeonSprawl.Server/Game/Gathering/GatherGrantApplied.cs create mode 100644 server/NeonSprawl.Server/Game/Gathering/GatherOperations.cs create mode 100644 server/NeonSprawl.Server/Game/Gathering/GatherReasonCodes.cs create mode 100644 server/NeonSprawl.Server/Game/Gathering/GatherResult.cs create mode 100644 server/NeonSprawl.Server/Game/Gathering/GatherXpGrantSummary.cs diff --git a/server/NeonSprawl.Server.Tests/Game/Gathering/GatherOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Gathering/GatherOperationsTests.cs new file mode 100644 index 0000000..6094dbb --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Gathering/GatherOperationsTests.cs @@ -0,0 +1,311 @@ +using Microsoft.Extensions.DependencyInjection; +using NeonSprawl.Server.Game.Gathering; +using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Game.Mastery; +using NeonSprawl.Server.Game.Skills; +using NeonSprawl.Server.Tests; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Gathering; + +public sealed class GatherOperationsTests +{ + private const string PlayerId = "dev-local-1"; + private const string AlphaId = "prototype_resource_node_alpha"; + private const string DeltaId = "prototype_urban_bulk_delta"; + + [Fact] + public void TryGather_OnFreshAlphaNode_ShouldGrantItemXpAndDecrementCapacity() + { + // Arrange + using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveGatherDependencies(factory); + + // Act + var result = GatherOperations.TryGather( + PlayerId, + AlphaId, + deps.NodeRegistry, + deps.ItemRegistry, + deps.InventoryStore, + deps.SkillRegistry, + deps.XpStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.InstanceStore); + + deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory); + var salvageXp = deps.XpStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage"); + + // Assert + Assert.True(result.Success); + Assert.Null(result.ReasonCode); + Assert.Single(result.GrantsApplied); + Assert.Equal("scrap_metal_bulk", result.GrantsApplied[0].ItemId); + Assert.Equal(1, result.GrantsApplied[0].Quantity); + Assert.Equal(9, result.RemainingGathers); + Assert.NotNull(result.XpGrantSummary); + Assert.Equal("salvage", result.XpGrantSummary!.Value.SkillId); + Assert.Equal(GatherSkillXpConstants.ActivityXpPerResourceNodeGather, result.XpGrantSummary.Value.Amount); + Assert.Equal(GatherSkillXpConstants.ActivitySourceKind, result.XpGrantSummary.Value.SourceKind); + Assert.Equal(1, CountItem(inventory!, "scrap_metal_bulk")); + Assert.Equal(10, salvageXp); + } + + [Fact] + public void TryGather_WhenBagFull_ShouldDenyWithInventoryFullWithoutDepletingNode() + { + // Arrange + using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveGatherDependencies(factory); + for (var i = 0; i < PlayerInventorySnapshot.BagSlotCount; i++) + { + var add = PlayerInventoryOperations.TryAddStack( + PlayerId, + "survey_drone_kit", + quantity: 1, + deps.ItemRegistry, + deps.InventoryStore); + Assert.Equal(PlayerInventoryMutationKind.Applied, add.Kind); + } + + deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory); + var salvageXpBefore = deps.XpStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage"); + + // Act + var result = GatherOperations.TryGather( + PlayerId, + AlphaId, + deps.NodeRegistry, + deps.ItemRegistry, + deps.InventoryStore, + deps.SkillRegistry, + deps.XpStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.InstanceStore); + + deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory); + var salvageXpAfter = deps.XpStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage"); + deps.InstanceStore.TryGetRemainingGathers(AlphaId, out var nodeSnapshot); + + // Assert + Assert.False(result.Success); + Assert.Equal(GatherReasonCodes.InventoryFull, result.ReasonCode); + Assert.Equal(TotalBagQuantity(beforeInventory!), TotalBagQuantity(afterInventory!)); + Assert.Equal(salvageXpBefore, salvageXpAfter); + Assert.True(deps.InstanceStore.TryGetRemainingGathers(AlphaId, out nodeSnapshot)); + Assert.Equal(10, nodeSnapshot.RemainingGathers); + } + + [Fact] + public void TryGather_AfterCapacityExhausted_ShouldReturnNodeDepletedWithoutSideEffects() + { + // Arrange + using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveGatherDependencies(factory); + for (var i = 0; i < 10; i++) + { + var gather = GatherOperations.TryGather( + PlayerId, + AlphaId, + deps.NodeRegistry, + deps.ItemRegistry, + deps.InventoryStore, + deps.SkillRegistry, + deps.XpStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.InstanceStore); + Assert.True(gather.Success); + } + + deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory); + var salvageXpBefore = deps.XpStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage"); + + // Act + var result = GatherOperations.TryGather( + PlayerId, + AlphaId, + deps.NodeRegistry, + deps.ItemRegistry, + deps.InventoryStore, + deps.SkillRegistry, + deps.XpStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.InstanceStore); + + deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory); + var salvageXpAfter = deps.XpStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage"); + + // Assert + Assert.False(result.Success); + Assert.Equal(GatherReasonCodes.NodeDepleted, result.ReasonCode); + Assert.Equal(0, result.RemainingGathers); + Assert.Equal(TotalBagQuantity(beforeInventory!), TotalBagQuantity(afterInventory!)); + Assert.Equal(salvageXpBefore, salvageXpAfter); + } + + [Fact] + public void TryGather_ForUnknownNode_ShouldDenyWithoutCreatingInstanceRow() + { + // Arrange + using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveGatherDependencies(factory); + const string unknownId = "not_a_real_node_def"; + + // Act + var result = GatherOperations.TryGather( + PlayerId, + unknownId, + deps.NodeRegistry, + deps.ItemRegistry, + deps.InventoryStore, + deps.SkillRegistry, + deps.XpStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.InstanceStore); + + var rowExists = deps.InstanceStore.TryGetRemainingGathers(unknownId, out _); + + // Assert + Assert.False(result.Success); + Assert.Equal(GatherReasonCodes.UnknownNode, result.ReasonCode); + Assert.False(rowExists); + } + + [Fact] + public void TryGather_WhenProgressionStoreMissing_ShouldRollbackInventoryAndDeny() + { + // Arrange + using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveGatherDependencies(factory); + var failingXpStore = new ProgressionStoreAlwaysMissing(); + deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory); + + // Act + var result = GatherOperations.TryGather( + PlayerId, + AlphaId, + deps.NodeRegistry, + deps.ItemRegistry, + deps.InventoryStore, + deps.SkillRegistry, + failingXpStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.InstanceStore); + + deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory); + deps.InstanceStore.TryGetRemainingGathers(AlphaId, out var nodeSnapshot); + + // Assert + Assert.False(result.Success); + Assert.Equal(GatherReasonCodes.ProgressionStoreMissing, result.ReasonCode); + Assert.Equal(TotalBagQuantity(beforeInventory!), TotalBagQuantity(afterInventory!)); + Assert.True(deps.InstanceStore.TryGetRemainingGathers(AlphaId, out nodeSnapshot)); + Assert.Equal(10, nodeSnapshot.RemainingGathers); + } + + [Fact] + public void TryGather_OnDeltaNode_ShouldGrantConfiguredYieldQuantity() + { + // Arrange + using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveGatherDependencies(factory); + + // Act + var result = GatherOperations.TryGather( + PlayerId, + DeltaId, + deps.NodeRegistry, + deps.ItemRegistry, + deps.InventoryStore, + deps.SkillRegistry, + deps.XpStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.InstanceStore); + + deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory); + + // Assert + Assert.True(result.Success); + Assert.Single(result.GrantsApplied); + Assert.Equal(5, result.GrantsApplied[0].Quantity); + Assert.Equal(5, CountItem(inventory!, "scrap_metal_bulk")); + } + + private static GatherTestDependencies ResolveGatherDependencies(InMemoryWebApplicationFactory factory) + { + var services = factory.Services; + return new GatherTestDependencies( + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService()); + } + + private static int CountItem(PlayerInventorySnapshot snapshot, string itemId) + { + var total = 0; + foreach (var slot in snapshot.BagSlots) + { + if (string.Equals(slot.ItemId, itemId, StringComparison.Ordinal)) + { + total += slot.Quantity; + } + } + + return total; + } + + private static int TotalBagQuantity(PlayerInventorySnapshot snapshot) + { + var total = 0; + foreach (var slot in snapshot.BagSlots) + { + total += slot.Quantity; + } + + return total; + } + + private readonly record struct GatherTestDependencies( + IResourceNodeDefinitionRegistry NodeRegistry, + IItemDefinitionRegistry ItemRegistry, + IPlayerInventoryStore InventoryStore, + ISkillDefinitionRegistry SkillRegistry, + IPlayerSkillProgressionStore XpStore, + ISkillLevelCurve LevelCurve, + PerkUnlockEngine PerkUnlockEngine, + IResourceNodeInstanceStore InstanceStore); + + private sealed class ProgressionStoreAlwaysMissing : IPlayerSkillProgressionStore + { + public IReadOnlyDictionary GetXpTotals(string playerId) => + new Dictionary(StringComparer.Ordinal); + + public bool TryApplyXpDelta( + string playerId, + string skillId, + int amount, + out int previousXp, + out int newXp) + { + previousXp = 0; + newXp = 0; + return false; + } + + public bool TrySetSkillXpTotal(string playerId, string skillId, int xp) => false; + + public bool TrySetSkillXpTotals(string playerId, IReadOnlyDictionary skillXpTotals) => false; + } +} diff --git a/server/NeonSprawl.Server/Game/Gathering/GatherGrantApplied.cs b/server/NeonSprawl.Server/Game/Gathering/GatherGrantApplied.cs new file mode 100644 index 0000000..0c77c8e --- /dev/null +++ b/server/NeonSprawl.Server/Game/Gathering/GatherGrantApplied.cs @@ -0,0 +1,4 @@ +namespace NeonSprawl.Server.Game.Gathering; + +/// One item grant applied on a successful gather (NEO-62). +public readonly record struct GatherGrantApplied(string ItemId, int Quantity); diff --git a/server/NeonSprawl.Server/Game/Gathering/GatherOperations.cs b/server/NeonSprawl.Server/Game/Gathering/GatherOperations.cs new file mode 100644 index 0000000..3975e95 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Gathering/GatherOperations.cs @@ -0,0 +1,132 @@ +using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Game.Mastery; +using NeonSprawl.Server.Game.Skills; + +namespace NeonSprawl.Server.Game.Gathering; + +/// +/// Orchestrates gather side effects: capacity pre-check, inventory grant, skill XP, depletion commit (NEO-62). +/// Interact wiring is NEO-63. +/// +public static class GatherOperations +{ + private static readonly GatherGrantApplied[] EmptyGrants = []; + + /// + /// Resolves one gather for at . + /// Mutations commit in order: inventory add → XP grant → depletion decrement (last). + /// + public static GatherResult TryGather( + string playerId, + string interactableId, + IResourceNodeDefinitionRegistry nodeRegistry, + IItemDefinitionRegistry itemRegistry, + IPlayerInventoryStore inventoryStore, + ISkillDefinitionRegistry skillRegistry, + IPlayerSkillProgressionStore xpStore, + ISkillLevelCurve levelCurve, + PerkUnlockEngine perkUnlockEngine, + IResourceNodeInstanceStore instanceStore) + { + var key = ResourceNodeInstanceIds.Normalize(interactableId); + if (key.Length == 0 || + !nodeRegistry.TryGetDefinition(key, out var definition) || + !nodeRegistry.TryGetYield(key, out var yield)) + { + return Deny(GatherReasonCodes.UnknownNode, null); + } + + if (!instanceStore.TryEnsureInitialized(key, definition.MaxGathers)) + { + throw new InvalidOperationException( + $"Failed to initialize resource node instance '{key}' with maxGathers {definition.MaxGathers}."); + } + + if (!instanceStore.TryGetRemainingGathers(key, out var capacity) || capacity.RemainingGathers <= 0) + { + var remaining = capacity.RemainingGathers; + return Deny(GatherReasonCodes.NodeDepleted, remaining); + } + + var inventoryOutcome = PlayerInventoryOperations.TryAddStack( + playerId, + yield.ItemId, + yield.Quantity, + itemRegistry, + inventoryStore); + + if (inventoryOutcome.Kind == PlayerInventoryMutationKind.StoreMissing) + { + return Deny(GatherReasonCodes.InventoryStoreMissing, capacity.RemainingGathers); + } + + if (inventoryOutcome.Kind == PlayerInventoryMutationKind.Denied) + { + return Deny(inventoryOutcome.ReasonCode ?? GatherReasonCodes.InventoryFull, capacity.RemainingGathers); + } + + var xpOutcome = SkillProgressionGrantOperations.TryApplyGrant( + playerId, + GatherSkillXpConstants.SalvageSkillId, + GatherSkillXpConstants.ActivityXpPerResourceNodeGather, + GatherSkillXpConstants.ActivitySourceKind, + skillRegistry, + xpStore, + levelCurve, + perkUnlockEngine); + + if (xpOutcome.Kind != SkillProgressionGrantApplyKind.Granted) + { + var xpReason = xpOutcome.Kind == SkillProgressionGrantApplyKind.ProgressionStoreMissing + ? GatherReasonCodes.ProgressionStoreMissing + : xpOutcome.Response?.ReasonCode ?? GatherReasonCodes.ProgressionStoreMissing; + + _ = PlayerInventoryOperations.TryRemoveStack( + playerId, + yield.ItemId, + yield.Quantity, + itemRegistry, + inventoryStore); + + return Deny(xpReason, capacity.RemainingGathers); + } + + var depletionOutcome = ResourceNodeInstanceOperations.TryCommitSuccessfulGatherDecrement( + key, + nodeRegistry, + instanceStore); + + if (depletionOutcome.Kind != ResourceNodeInstanceMutationKind.Applied) + { + _ = PlayerInventoryOperations.TryRemoveStack( + playerId, + yield.ItemId, + yield.Quantity, + itemRegistry, + inventoryStore); + + var depletedRemaining = depletionOutcome.Snapshot?.RemainingGathers ?? 0; + return Deny( + depletionOutcome.ReasonCode ?? GatherReasonCodes.NodeDepleted, + depletedRemaining); + } + + return new GatherResult( + Success: true, + ReasonCode: null, + GrantsApplied: [new GatherGrantApplied(yield.ItemId, yield.Quantity)], + RemainingGathers: depletionOutcome.Snapshot!.Value.RemainingGathers, + XpGrantSummary: new GatherXpGrantSummary( + GatherSkillXpConstants.SalvageSkillId, + GatherSkillXpConstants.ActivityXpPerResourceNodeGather, + GatherSkillXpConstants.ActivitySourceKind)); + } + + private static GatherResult Deny(string reasonCode, int? remainingGathers) => + new( + Success: false, + ReasonCode: reasonCode, + GrantsApplied: EmptyGrants, + RemainingGathers: remainingGathers, + XpGrantSummary: null); +} diff --git a/server/NeonSprawl.Server/Game/Gathering/GatherReasonCodes.cs b/server/NeonSprawl.Server/Game/Gathering/GatherReasonCodes.cs new file mode 100644 index 0000000..be62659 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Gathering/GatherReasonCodes.cs @@ -0,0 +1,22 @@ +using NeonSprawl.Server.Game.Items; + +namespace NeonSprawl.Server.Game.Gathering; + +/// Stable deny reason codes for gather operations (NEO-62). +public static class GatherReasonCodes +{ + /// Passthrough from . + public const string UnknownNode = ResourceNodeInstanceReasonCodes.UnknownNode; + + /// Passthrough from . + public const string NodeDepleted = ResourceNodeInstanceReasonCodes.NodeDepleted; + + /// Passthrough from . + public const string InventoryFull = PlayerInventoryReasonCodes.InventoryFull; + + /// returned false. + public const string ProgressionStoreMissing = "progression_store_missing"; + + /// Player inventory store could not write (e.g. unknown player bucket). + public const string InventoryStoreMissing = "inventory_store_missing"; +} diff --git a/server/NeonSprawl.Server/Game/Gathering/GatherResult.cs b/server/NeonSprawl.Server/Game/Gathering/GatherResult.cs new file mode 100644 index 0000000..afbe888 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Gathering/GatherResult.cs @@ -0,0 +1,15 @@ +namespace NeonSprawl.Server.Game.Gathering; + +/// +/// Server-internal gather resolution envelope (NEO-62). May promote to wire JSON when the client needs explicit +/// gather payloads; until then prototype verification uses inventory GET + interact response (NEO-63). +/// +/// Item grants on success; empty when denied. +/// Authoritative capacity after success, or current capacity on depletion deny. +/// Skill XP applied on success; null when denied. +public readonly record struct GatherResult( + bool Success, + string? ReasonCode, + IReadOnlyList GrantsApplied, + int? RemainingGathers, + GatherXpGrantSummary? XpGrantSummary); diff --git a/server/NeonSprawl.Server/Game/Gathering/GatherXpGrantSummary.cs b/server/NeonSprawl.Server/Game/Gathering/GatherXpGrantSummary.cs new file mode 100644 index 0000000..43556be --- /dev/null +++ b/server/NeonSprawl.Server/Game/Gathering/GatherXpGrantSummary.cs @@ -0,0 +1,4 @@ +namespace NeonSprawl.Server.Game.Gathering; + +/// Compact skill XP summary on a successful gather (NEO-62). +public readonly record struct GatherXpGrantSummary(string SkillId, int Amount, string SourceKind); From f58ab4a2ebe019e446edfea8a2da634e4039379a Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 24 May 2026 15:41:28 -0400 Subject: [PATCH 3/8] NEO-62: update docs for gather engine landing. README, E3.M1 module doc, alignment table, backlog checkboxes. --- .../modules/E3_M1_ResourceNodeAndGatherLoop.md | 4 +++- .../documentation_and_implementation_alignment.md | 2 +- .../modules/module_dependency_register.md | 2 +- docs/plans/E3M1-prototype-backlog.md | 10 +++++----- docs/plans/NEO-62-implementation-plan.md | 8 ++++---- server/README.md | 14 ++++++++++++++ 6 files changed, 28 insertions(+), 12 deletions(-) diff --git a/docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md b/docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md index 9274971..3d61f1e 100644 --- a/docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md +++ b/docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md @@ -92,12 +92,14 @@ The **first shipped four-node spine** is **frozen** for downstream references (r Epic 3 **Slice 2** — [gather nodes and outputs](../epics/epic_03_crafting_economy.md#epic-3-slice-2): 4–6 node types, yield tables, `resource_gathered`, `gather_node_depleted`. -**Prototype implementation anchor (NEO-41):** until **`GatherResult`** lands, a **successful** **`POST …/interact`** on **`kind: resource_node`** grants **`salvage`** skill XP only (`activity`, 10 XP) via `SkillProgressionGrantOperations` — **no** inventory or depletion. **E3M1-07** migrates XP into the gather engine and adds item + capacity behavior ([E3M1-prototype-backlog](../../plans/E3M1-prototype-backlog.md)). +**Prototype implementation anchor (NEO-41, until NEO-63):** a **successful** **`POST …/interact`** on **`kind: resource_node`** still grants **`salvage`** skill XP only (`activity`, 10 XP) via `SkillProgressionGrantOperations` — **no** inventory or depletion on the interact path yet. **`GatherOperations`** (NEO-62) owns the full gather side-effect chain; **E3M1-07** ([NEO-63](https://linear.app/neon-sprawl/issue/NEO-63)) wires interact to the gather engine and removes duplicate XP ([E3M1-prototype-backlog](../../plans/E3M1-prototype-backlog.md)). **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). +**Gather engine (NEO-62):** **`GatherOperations.TryGather`** → **`GatherResult`** (`success`, `reasonCode`, `grantsApplied[]`, `remainingGathers`, optional `xpGrantSummary`) — inventory + **`salvage`** XP + depletion commit in one orchestrated path; server-internal until promoted to wire. Plan: [NEO-62 implementation plan](../../plans/NEO-62-implementation-plan.md); [server README — Gather engine (NEO-62)](../../../server/README.md#gather-engine-neo-62). + **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 309724d..f57793a 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/`. **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.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). **NEO-62 landed:** **`GatherOperations`** + **`GatherResult`** — inventory grant, **`salvage`** XP, depletion commit ([NEO-62](../../plans/NEO-62-implementation-plan.md)); [server README — Gather engine (NEO-62)](../../../server/README.md#gather-engine-neo-62). **Still planned:** interact wiring ([NEO-63](https://linear.app/neon-sprawl/issue/NEO-63)), telemetry hooks ([NEO-64](https://linear.app/neon-sprawl/issue/NEO-64)). | [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), [NEO-62](../../plans/NEO-62-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/decomposition/modules/module_dependency_register.md b/docs/decomposition/modules/module_dependency_register.md index a72eeb2..33797e6 100644 --- a/docs/decomposition/modules/module_dependency_register.md +++ b/docs/decomposition/modules/module_dependency_register.md @@ -48,7 +48,7 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen | E3.M4 | SinkAndDurabilityLifecycle | E3.M3, E8.M3 | DurabilityState, ItemSinkEvent, RepairCostRule | Pre-production | Planned | | E3.M5 | EconomyBalancePolicy | E3.M4, E9.M2 | EconomyPolicy, PriceBandRule, FaucetSinkRatio | Pre-production | Planned | -**E3.M1 note:** Epic 3 **Slice 2** backlog in Linear ([Epic 3 — Crafting, Gathering, and Itemization Economy](https://linear.app/neon-sprawl/project/epic-3-crafting-gathering-and-itemization-economy-65785ed05bc2)): [NEO-57](https://linear.app/neon-sprawl/issue/NEO-57) → [NEO-64](https://linear.app/neon-sprawl/issue/NEO-64); label **`E3.M1`**. See [E3M1-prototype-backlog.md](../../plans/E3M1-prototype-backlog.md), [E3_M1_ResourceNodeAndGatherLoop.md](E3_M1_ResourceNodeAndGatherLoop.md). **NEO-57** (content + CI), **NEO-58** (server fail-fast load), **NEO-59** (`IResourceNodeDefinitionRegistry` + DI), **NEO-60** (`GET /game/world/resource-node-definitions`), **NEO-61** (`IResourceNodeInstanceStore` + depletion operations, `V006`) — E3M1-01–**E3M1-05** complete; register row **In Progress** until gather engine / interact wiring slices land. **NEO-41** (prototype gather XP on `resource_node` interact) remains until **E3M1-07** migrates into the gather engine. Remaining Slice 2 work adds **`GatherResult`**, inventory grants, and telemetry hooks **`resource_gathered`** / **`gather_node_depleted`**. +**E3.M1 note:** Epic 3 **Slice 2** backlog in Linear ([Epic 3 — Crafting, Gathering, and Itemization Economy](https://linear.app/neon-sprawl/project/epic-3-crafting-gathering-and-itemization-economy-65785ed05bc2)): [NEO-57](https://linear.app/neon-sprawl/issue/NEO-57) → [NEO-64](https://linear.app/neon-sprawl/issue/NEO-64); label **`E3.M1`**. See [E3M1-prototype-backlog.md](../../plans/E3M1-prototype-backlog.md), [E3_M1_ResourceNodeAndGatherLoop.md](E3_M1_ResourceNodeAndGatherLoop.md). **NEO-57** (content + CI), **NEO-58** (server fail-fast load), **NEO-59** (`IResourceNodeDefinitionRegistry` + DI), **NEO-60** (`GET /game/world/resource-node-definitions`), **NEO-61** (`IResourceNodeInstanceStore` + depletion operations, `V006`), **NEO-62** (`GatherOperations` + `GatherResult` — inventory + XP + depletion) — E3M1-01–**E3M1-06** complete; register row **In Progress** until interact wiring / telemetry slices land. **NEO-41** (prototype gather XP on `resource_node` interact) remains until **E3M1-07** migrates into the gather engine. Remaining Slice 2 work: interact integration ([NEO-63](https://linear.app/neon-sprawl/issue/NEO-63)) and telemetry hooks **`resource_gathered`** / **`gather_node_depleted`** ([NEO-64](https://linear.app/neon-sprawl/issue/NEO-64)). **E3.M3 note:** Epic 3 **Slice 1** backlog in Linear ([Epic 3 — Crafting, Gathering, and Itemization Economy](https://linear.app/neon-sprawl/project/epic-3-crafting-gathering-and-itemization-economy-65785ed05bc2)): [NEO-50](https://linear.app/neon-sprawl/issue/NEO-50) → [NEO-56](https://linear.app/neon-sprawl/issue/NEO-56); label **`E3.M3`**. See [E3M3-prototype-backlog.md](../../plans/E3M3-prototype-backlog.md), [E3_M3_ItemizationAndInventorySchema.md](E3_M3_ItemizationAndInventorySchema.md). **NEO-50** (content + CI), **NEO-51** (server fail-fast load), **NEO-52** (`IItemDefinitionRegistry` + DI), **NEO-53** (`GET /game/world/item-definitions`), **NEO-54** (inventory store + stack/slot rules engine), **NEO-55** (`GET`/`POST /game/players/{id}/inventory`), and **NEO-56** (comment-only `item_created` / `inventory_transfer_denied` telemetry hook sites in `PlayerInventoryOperations`) — Epic 3 Slice 1 backlog **E3M3-01**–**E3M3-07** complete; register row **In Progress** until Slice 2+; later slices update the alignment table as they land. diff --git a/docs/plans/E3M1-prototype-backlog.md b/docs/plans/E3M1-prototype-backlog.md index 710a73f..32d8f0e 100644 --- a/docs/plans/E3M1-prototype-backlog.md +++ b/docs/plans/E3M1-prototype-backlog.md @@ -193,12 +193,12 @@ Working backlog for **Epic 3 — Slice 2** ([gather nodes and outputs](../decomp **Acceptance criteria** -- [ ] Successful gather adds configured **`scrap_metal_bulk`** quantity to player bag. -- [ ] Full bag returns stable `reasonCode` without partial silent loss. -- [ ] Skill XP grant uses same rules as NEO-38 / NEO-41 (`salvage`, `activity`, 10 XP). -- [ ] Depletion store decrements atomically with successful grant. +- [x] Successful gather adds configured **`scrap_metal_bulk`** quantity to player bag. +- [x] Full bag returns stable `reasonCode` without partial silent loss. +- [x] Skill XP grant uses same rules as NEO-38 / NEO-41 (`salvage`, `activity`, 10 XP). +- [x] Depletion store decrements atomically with successful grant. ---- +**Landed:** [NEO-62](https://linear.app/neon-sprawl/issue/NEO-62) — gather engine; interact wiring is E3M1-07. ### E3M1-07 — Interact path integration + registry expansion + Bruno diff --git a/docs/plans/NEO-62-implementation-plan.md b/docs/plans/NEO-62-implementation-plan.md index 88cda64..1d64407 100644 --- a/docs/plans/NEO-62-implementation-plan.md +++ b/docs/plans/NEO-62-implementation-plan.md @@ -40,10 +40,10 @@ ## Acceptance criteria checklist -- [ ] Successful gather adds configured **`scrap_metal_bulk`** quantity to player bag. -- [ ] Full bag returns stable **`inventory_full`** without partial silent loss or depletion change. -- [ ] Skill XP grant uses same rules as NEO-38 / NEO-41 (`salvage`, `activity`, 10 XP via **`GatherSkillXpConstants`**). -- [ ] Depletion store decrements atomically with successful grant (decrement **last**, only when inventory + XP both succeed). +- [x] Successful gather adds configured **`scrap_metal_bulk`** quantity to player bag. +- [x] Full bag returns stable **`inventory_full`** without partial silent loss or depletion change. +- [x] Skill XP grant uses same rules as NEO-38 / NEO-41 (`salvage`, `activity`, 10 XP via **`GatherSkillXpConstants`**). +- [x] Depletion store decrements atomically with successful grant (decrement **last**, only when inventory + XP both succeed). ## Technical approach diff --git a/server/README.md b/server/README.md index 6e2b16f..7ed9bb1 100644 --- a/server/README.md +++ b/server/README.md @@ -85,6 +85,20 @@ World-wide **remaining gathers** per **`interactableId`** live in **`Game/Gather **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). +## Gather engine (NEO-62) + +**`GatherOperations.TryGather`** in **`Game/Gathering/`** resolves server-internal **`GatherResult`**: capacity pre-check (may **initialize** a depletion row without decrementing), inventory grant via **`PlayerInventoryOperations`**, **`salvage`** skill XP via **`SkillProgressionGrantOperations`** + **`GatherSkillXpConstants`**, then depletion commit via **`ResourceNodeInstanceOperations`** (decrement **last**, only when inventory + XP succeed). On XP failure after inventory add, the engine **rolls back** the item grant before denying. + +| Reason code | When | +|-------------|------| +| **`unknown_node`** | **`interactableId`** not in **`IResourceNodeDefinitionRegistry`** or missing yield row. | +| **`node_depleted`** | **`remainingGathers`** is **0** (or decrement rejected). | +| **`inventory_full`** | Full yield quantity could not be placed (NEO-54 all-or-nothing). | +| **`progression_store_missing`** | Skill progression store could not apply XP (compensating inventory remove). | +| **`inventory_store_missing`** | Player inventory store could not write. | + +**Interact wiring** (replace NEO-41 inline XP, map denies to **`InteractionResponse`**) is **[NEO-63](https://linear.app/neon-sprawl/issue/NEO-63)**; plan: [NEO-62 implementation plan](../../docs/plans/NEO-62-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/`. From e6818577788ca0e06ead2f12e6263072441acc78 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 24 May 2026 15:44:12 -0400 Subject: [PATCH 4/8] NEO-63: wire interact to gather engine and expand registry. Four resource_node anchors; gather denies map to InteractionResponse; integration tests, Bruno gather/inventory and depletion flows. --- .../Post interact depleted node deny.bru | 62 ++++++ ...ost interact gather then get inventory.bru | 48 ++++ ...nteract prototype resource node gather.bru | 5 +- .../Interaction/InteractablesWorldApiTests.cs | 17 +- ...ctionResourceNodeGatherIntegrationTests.cs | 209 ++++++++++++++++++ .../InteractionResourceNodeGatherXpTests.cs | 38 +--- ...vityDeniedRegistryWebApplicationFactory.cs | 17 +- .../Game/Interaction/InteractionApi.cs | 44 +++- .../PrototypeInteractableRegistry.cs | 21 ++ 9 files changed, 409 insertions(+), 52 deletions(-) create mode 100644 bruno/neon-sprawl-server/interaction/Post interact depleted node deny.bru create mode 100644 bruno/neon-sprawl-server/interaction/Post interact gather then get inventory.bru create mode 100644 server/NeonSprawl.Server.Tests/Game/Interaction/InteractionResourceNodeGatherIntegrationTests.cs diff --git a/bruno/neon-sprawl-server/interaction/Post interact depleted node deny.bru b/bruno/neon-sprawl-server/interaction/Post interact depleted node deny.bru new file mode 100644 index 0000000..9964c31 --- /dev/null +++ b/bruno/neon-sprawl-server/interaction/Post interact depleted node deny.bru @@ -0,0 +1,62 @@ +meta { + name: POST interact depleted node deny + type: http + seq: 13 +} + +docs { + NEO-63: pre-request exhausts prototype_resource_node_alpha capacity (10 successful gathers), then this interact should deny with node_depleted. +} + +script:pre-request { + const axios = require("axios"); + const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl"); + const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId"); + await axios.post( + `${baseUrl}/game/players/${playerId}/move`, + { + schemaVersion: 1, + target: { x: 10, y: 0.9, z: -6 }, + }, + { headers: { "Content-Type": "application/json" } }, + ); + for (let i = 0; i < 10; i++) { + const res = await axios.post( + `${baseUrl}/game/players/${playerId}/interact`, + { + schemaVersion: 1, + interactableId: "prototype_resource_node_alpha", + }, + { headers: { "Content-Type": "application/json" } }, + ); + if (!res.data.allowed) { + throw new Error(`expected gather ${i + 1}/10 to succeed, got ${res.data.reasonCode}`); + } + } +} + +post { + url: {{baseUrl}}/game/players/{{playerId}}/interact + body: json + auth: none +} + +headers { + content-type: application/json +} + +body:json { + { + "schemaVersion": 1, + "interactableId": "prototype_resource_node_alpha" + } +} + +tests { + test("depleted node interact denied with node_depleted", function () { + expect(res.getStatus()).to.equal(200); + const body = res.getBody(); + expect(body.allowed).to.equal(false); + expect(body.reasonCode).to.equal("node_depleted"); + }); +} diff --git a/bruno/neon-sprawl-server/interaction/Post interact gather then get inventory.bru b/bruno/neon-sprawl-server/interaction/Post interact gather then get inventory.bru new file mode 100644 index 0000000..611d5bb --- /dev/null +++ b/bruno/neon-sprawl-server/interaction/Post interact gather then get inventory.bru @@ -0,0 +1,48 @@ +meta { + name: POST interact gather then GET inventory + type: http + seq: 12 +} + +docs { + NEO-63 happy path: move near prototype_resource_node_alpha, interact once, then GET inventory and assert scrap_metal_bulk quantity >= 1. +} + +script:pre-request { + const axios = require("axios"); + const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl"); + const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId"); + await axios.post( + `${baseUrl}/game/players/${playerId}/move`, + { + schemaVersion: 1, + target: { x: 10, y: 0.9, z: -6 }, + }, + { headers: { "Content-Type": "application/json" } }, + ); + await axios.post( + `${baseUrl}/game/players/${playerId}/interact`, + { + schemaVersion: 1, + interactableId: "prototype_resource_node_alpha", + }, + { headers: { "Content-Type": "application/json" } }, + ); +} + +get { + url: {{baseUrl}}/game/players/{{playerId}}/inventory + body: none + auth: none +} + +tests { + test("inventory includes scrap_metal_bulk after gather interact", function () { + expect(res.getStatus()).to.equal(200); + const body = res.getBody(); + const total = body.bagSlots + .filter((s) => s.itemId === "scrap_metal_bulk") + .reduce((sum, s) => sum + s.quantity, 0); + expect(total).to.be.at.least(1); + }); +} diff --git a/bruno/neon-sprawl-server/interaction/Post interact prototype resource node gather.bru b/bruno/neon-sprawl-server/interaction/Post interact prototype resource node gather.bru index 4369d5d..87f9581 100644 --- a/bruno/neon-sprawl-server/interaction/Post interact prototype resource node gather.bru +++ b/bruno/neon-sprawl-server/interaction/Post interact prototype resource node gather.bru @@ -1,12 +1,11 @@ meta { - name: POST interact - prototype_resource_node_alpha (NEO-41 gather XP) + name: POST interact - prototype_resource_node_alpha (NEO-63 gather) type: http seq: 11 } docs { - NEO-41: successful interact on `resource_node` kind grants `salvage` XP (`sourceKind: activity`, 10 XP) via same server path as POST skill-progression. Pre-request moves the dev player in horizontal range of the node so this request passes when run alone or when global `seq` order would otherwise leave the player far from the anchor. Optional: GET skill-progression to read `salvage.xp`. - Server tests include a stub-registry case where `activity` is disallowed for `salvage` (interact still allowed; XP unchanged). + NEO-63: successful interact on `resource_node` kind runs GatherOperations — inventory grant (scrap_metal_bulk), salvage XP (activity, 10), and depletion. Pre-request moves in range of alpha anchor (12, -6). Optional: GET inventory / skill-progression after this request. } script:pre-request { diff --git a/server/NeonSprawl.Server.Tests/Game/Interaction/InteractablesWorldApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Interaction/InteractablesWorldApiTests.cs index 830468c..6d5e320 100644 --- a/server/NeonSprawl.Server.Tests/Game/Interaction/InteractablesWorldApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Interaction/InteractablesWorldApiTests.cs @@ -25,7 +25,7 @@ public class InteractablesWorldApiTests Assert.NotNull(body); Assert.Equal(InteractablesListResponse.CurrentSchemaVersion, body!.SchemaVersion); Assert.NotNull(body.Interactables); - Assert.True(body.Interactables.Count >= 2); + Assert.Equal(5, body.Interactables.Count); var ids = body.Interactables.Select(static x => x.InteractableId).ToList(); Assert.Equal(ids.OrderBy(static x => x, StringComparer.Ordinal).ToList(), ids); @@ -37,6 +37,21 @@ public class InteractablesWorldApiTests Assert.Equal(-6, alpha.Anchor.Z); Assert.Equal(3.0, alpha.InteractionRadius); + var beta = body.Interactables.Single(x => x.InteractableId == PrototypeInteractableRegistry.PrototypeSubsurfaceVeinBetaId); + Assert.Equal("resource_node", beta.Kind); + Assert.Equal(18, beta.Anchor.X); + Assert.Equal(-6, beta.Anchor.Z); + + var gamma = body.Interactables.Single(x => x.InteractableId == PrototypeInteractableRegistry.PrototypeBioMatGammaId); + Assert.Equal("resource_node", gamma.Kind); + Assert.Equal(12, gamma.Anchor.X); + Assert.Equal(0, gamma.Anchor.Z); + + var delta = body.Interactables.Single(x => x.InteractableId == PrototypeInteractableRegistry.PrototypeUrbanBulkDeltaId); + Assert.Equal("resource_node", delta.Kind); + Assert.Equal(18, delta.Anchor.X); + Assert.Equal(0, delta.Anchor.Z); + var term = body.Interactables.Single(x => x.InteractableId == PrototypeInteractableRegistry.PrototypeTerminalId); Assert.Equal("terminal", term.Kind); Assert.Equal(0, term.Anchor.X); diff --git a/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionResourceNodeGatherIntegrationTests.cs b/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionResourceNodeGatherIntegrationTests.cs new file mode 100644 index 0000000..a61c89f --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionResourceNodeGatherIntegrationTests.cs @@ -0,0 +1,209 @@ +using System.Linq; +using System.Net; +using System.Net.Http.Json; +using NeonSprawl.Server.Game.Gathering; +using NeonSprawl.Server.Game.Interaction; +using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Game.Skills; +using NeonSprawl.Server.Game.World; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Interaction; + +/// NEO-63: resource_node interact delegates to . +public sealed class InteractionResourceNodeGatherIntegrationTests +{ + [Fact] + public async Task PostInteract_ResourceNode_WhenInRange_ShouldGrantInventoryAndSalvageXp() + { + // Arrange — node at (12, -6), radius 3; stand at (10, -6) → horizontal distance 2 + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + await MoveNearAlphaAsync(client); + + // Act + var interact = await client.PostAsJsonAsync( + "/game/players/dev-local-1/interact", + new InteractionRequest + { + SchemaVersion = InteractionRequest.CurrentSchemaVersion, + InteractableId = PrototypeInteractableRegistry.PrototypeResourceNodeAlphaId, + }); + var inventory = await client.GetAsync("/game/players/dev-local-1/inventory"); + var progression = await client.GetAsync("/game/players/dev-local-1/skill-progression"); + + // Assert + Assert.Equal(HttpStatusCode.OK, interact.StatusCode); + var envelope = await interact.Content.ReadFromJsonAsync(); + Assert.NotNull(envelope); + Assert.True(envelope!.Allowed); + Assert.Equal(PrototypeInteractableRegistry.PrototypeResourceNodeAlphaId, envelope.InteractableId); + + Assert.Equal(HttpStatusCode.OK, inventory.StatusCode); + var bag = await inventory.Content.ReadFromJsonAsync(); + Assert.NotNull(bag); + Assert.Equal(1, CountItem(bag!, "scrap_metal_bulk")); + + Assert.Equal(HttpStatusCode.OK, progression.StatusCode); + var skills = await progression.Content.ReadFromJsonAsync(); + Assert.NotNull(skills); + var salvage = skills!.Skills!.Single(static s => s.Id == "salvage"); + Assert.Equal(GatherSkillXpConstants.ActivityXpPerResourceNodeGather, salvage.Xp); + } + + [Fact] + public async Task PostInteract_ResourceNode_WhenDepleted_ShouldDenyWithNodeDepletedWithoutGrants() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + await MoveNearAlphaAsync(client); + for (var i = 0; i < 10; i++) + { + var gather = await client.PostAsJsonAsync( + "/game/players/dev-local-1/interact", + new InteractionRequest + { + SchemaVersion = InteractionRequest.CurrentSchemaVersion, + InteractableId = PrototypeInteractableRegistry.PrototypeResourceNodeAlphaId, + }); + Assert.Equal(HttpStatusCode.OK, gather.StatusCode); + var body = await gather.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.True(body!.Allowed); + } + + var inventoryBefore = await client.GetAsync("/game/players/dev-local-1/inventory"); + var progressionBefore = await client.GetAsync("/game/players/dev-local-1/skill-progression"); + var bagBefore = await inventoryBefore.Content.ReadFromJsonAsync(); + var skillsBefore = await progressionBefore.Content.ReadFromJsonAsync(); + var scrapBefore = CountItem(bagBefore!, "scrap_metal_bulk"); + var salvageBefore = skillsBefore!.Skills!.Single(static s => s.Id == "salvage").Xp; + + // Act + var interact = await client.PostAsJsonAsync( + "/game/players/dev-local-1/interact", + new InteractionRequest + { + SchemaVersion = InteractionRequest.CurrentSchemaVersion, + InteractableId = PrototypeInteractableRegistry.PrototypeResourceNodeAlphaId, + }); + var inventoryAfter = await client.GetAsync("/game/players/dev-local-1/inventory"); + var progressionAfter = await client.GetAsync("/game/players/dev-local-1/skill-progression"); + + // Assert + Assert.Equal(HttpStatusCode.OK, interact.StatusCode); + var envelope = await interact.Content.ReadFromJsonAsync(); + Assert.NotNull(envelope); + Assert.False(envelope!.Allowed); + Assert.Equal(GatherReasonCodes.NodeDepleted, envelope.ReasonCode); + + var bagAfter = await inventoryAfter.Content.ReadFromJsonAsync(); + var skillsAfter = await progressionAfter.Content.ReadFromJsonAsync(); + Assert.Equal(scrapBefore, CountItem(bagAfter!, "scrap_metal_bulk")); + Assert.Equal( + salvageBefore, + skillsAfter!.Skills!.Single(static s => s.Id == "salvage").Xp); + } + + [Fact] + public async Task PostInteract_PrototypeTerminal_ShouldNotGrantInventoryOrSalvageXp() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + var move = new MoveCommandRequest + { + SchemaVersion = MoveCommandRequest.CurrentSchemaVersion, + Target = new PositionVector { X = 0.5, Y = 0.9, Z = 0.5 }, + }; + await client.PostAsJsonAsync("/game/players/dev-local-1/move", move); + + // Act + var interact = await client.PostAsJsonAsync( + "/game/players/dev-local-1/interact", + new InteractionRequest + { + SchemaVersion = InteractionRequest.CurrentSchemaVersion, + InteractableId = PrototypeInteractableRegistry.PrototypeTerminalId, + }); + var inventory = await client.GetAsync("/game/players/dev-local-1/inventory"); + var progression = await client.GetAsync("/game/players/dev-local-1/skill-progression"); + + // Assert + Assert.Equal(HttpStatusCode.OK, interact.StatusCode); + var envelope = await interact.Content.ReadFromJsonAsync(); + Assert.NotNull(envelope); + Assert.True(envelope!.Allowed); + + var bag = await inventory.Content.ReadFromJsonAsync(); + Assert.NotNull(bag); + Assert.Equal(0, CountItem(bag!, "scrap_metal_bulk")); + + var skills = await progression.Content.ReadFromJsonAsync(); + Assert.NotNull(skills); + var salvage = skills!.Skills!.Single(static s => s.Id == "salvage"); + Assert.Equal(0, salvage.Xp); + } + + [Fact] + public async Task PostInteract_ResourceNode_WhenSalvageDisallowsActivity_ShouldDenyWithoutGrants() + { + // Arrange + await using var factory = new SalvageActivityDeniedRegistryWebApplicationFactory(); + var client = factory.CreateClient(); + await MoveNearAlphaAsync(client); + + // Act + var interact = await client.PostAsJsonAsync( + "/game/players/dev-local-1/interact", + new InteractionRequest + { + SchemaVersion = InteractionRequest.CurrentSchemaVersion, + InteractableId = PrototypeInteractableRegistry.PrototypeResourceNodeAlphaId, + }); + var inventory = await client.GetAsync("/game/players/dev-local-1/inventory"); + var progression = await client.GetAsync("/game/players/dev-local-1/skill-progression"); + + // Assert + Assert.Equal(HttpStatusCode.OK, interact.StatusCode); + var envelope = await interact.Content.ReadFromJsonAsync(); + Assert.NotNull(envelope); + Assert.False(envelope!.Allowed); + Assert.False(string.IsNullOrEmpty(envelope.ReasonCode)); + + var bag = await inventory.Content.ReadFromJsonAsync(); + Assert.NotNull(bag); + Assert.Equal(0, CountItem(bag!, "scrap_metal_bulk")); + + var skills = await progression.Content.ReadFromJsonAsync(); + Assert.NotNull(skills); + var salvage = skills!.Skills!.Single(static s => s.Id == "salvage"); + Assert.Equal(0, salvage.Xp); + } + + private static async Task MoveNearAlphaAsync(HttpClient client) + { + var move = new MoveCommandRequest + { + SchemaVersion = MoveCommandRequest.CurrentSchemaVersion, + Target = new PositionVector { X = 10, Y = 0.9, Z = -6 }, + }; + await client.PostAsJsonAsync("/game/players/dev-local-1/move", move); + } + + private static int CountItem(PlayerInventorySnapshotResponse snapshot, string itemId) + { + var total = 0; + foreach (var slot in snapshot.BagSlots!) + { + if (string.Equals(slot.ItemId, itemId, StringComparison.Ordinal)) + { + total += slot.Quantity; + } + } + + return total; + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionResourceNodeGatherXpTests.cs b/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionResourceNodeGatherXpTests.cs index 19a682d..5e7507b 100644 --- a/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionResourceNodeGatherXpTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionResourceNodeGatherXpTests.cs @@ -9,7 +9,7 @@ using Xunit; namespace NeonSprawl.Server.Tests.Game.Interaction; -/// NEO-41: successful resource_node interact grants salvage XP via shared NEO-38 grant path. +/// NEO-41 legacy coverage retained for terminal control; gather side effects covered by NEO-63 integration tests. public sealed class InteractionResourceNodeGatherXpTests { [Fact] @@ -79,40 +79,4 @@ public sealed class InteractionResourceNodeGatherXpTests var salvage = body!.Skills!.Single(static s => s.Id == "salvage"); Assert.Equal(GatherSkillXpConstants.ActivityXpPerResourceNodeGather, salvage.Xp); } - - [Fact] - public async Task PostInteract_ResourceNode_WhenSalvageDisallowsActivity_ShouldStillAllowInteract_AndNotApplyXp() - { - // Arrange — stub registry: salvage exists but activity is not in allowedXpSourceKinds (defensive / corrupt-catalog stand-in). - await using var factory = new SalvageActivityDeniedRegistryWebApplicationFactory(); - var client = factory.CreateClient(); - var move = new MoveCommandRequest - { - SchemaVersion = MoveCommandRequest.CurrentSchemaVersion, - Target = new PositionVector { X = 10, Y = 0.9, Z = -6 }, - }; - await client.PostAsJsonAsync("/game/players/dev-local-1/move", move); - - // Act - var interact = await client.PostAsJsonAsync( - "/game/players/dev-local-1/interact", - new InteractionRequest - { - SchemaVersion = InteractionRequest.CurrentSchemaVersion, - InteractableId = PrototypeInteractableRegistry.PrototypeResourceNodeAlphaId, - }); - var snapshot = await client.GetAsync("/game/players/dev-local-1/skill-progression"); - - // Assert - Assert.Equal(HttpStatusCode.OK, interact.StatusCode); - var envelope = await interact.Content.ReadFromJsonAsync(); - Assert.NotNull(envelope); - Assert.True(envelope!.Allowed); - - Assert.Equal(HttpStatusCode.OK, snapshot.StatusCode); - var body = await snapshot.Content.ReadFromJsonAsync(); - Assert.NotNull(body); - var salvage = body!.Skills!.Single(static s => s.Id == "salvage"); - Assert.Equal(0, salvage.Xp); - } } diff --git a/server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs index 6e2b43c..fabbdb8 100644 --- a/server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs @@ -6,6 +6,8 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Time.Testing; using NeonSprawl.Server.Game.AbilityInput; +using NeonSprawl.Server.Game.Gathering; +using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.Skills; @@ -15,7 +17,7 @@ namespace NeonSprawl.Server.Tests.Game.Interaction; /// /// Same in-memory overrides as , plus a stub -/// where salvage does not allow activity — for NEO-41 defensive tests without mutating disk catalog. +/// where salvage does not allow activity — for gather deny tests without mutating disk catalog. /// public sealed class SalvageActivityDeniedRegistryWebApplicationFactory : WebApplicationFactory { @@ -29,8 +31,17 @@ public sealed class SalvageActivityDeniedRegistryWebApplicationFactory : WebAppl var masteryDir = MasteryCatalogPathResolution.TryDiscoverMasteryDirectory(AppContext.BaseDirectory) ?? throw new InvalidOperationException( "Could not discover repo content/mastery from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); + var itemsDir = ItemCatalogPathResolution.TryDiscoverItemsDirectory(AppContext.BaseDirectory) + ?? throw new InvalidOperationException( + "Could not discover repo content/items from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); + var resourceNodesDir = ResourceNodeCatalogPathResolution.TryDiscoverResourceNodesDirectory(AppContext.BaseDirectory) + ?? throw new InvalidOperationException( + "Could not discover repo content/resource-nodes from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); builder.UseSetting("Content:SkillsDirectory", skillsDir); builder.UseSetting("Content:MasteryDirectory", masteryDir); + builder.UseSetting("Content:ItemsDirectory", itemsDir); + builder.UseSetting("Content:ResourceNodesDirectory", resourceNodesDir); + builder.UseSetting("Game:EnableMasteryFixtureApi", "true"); builder.ConfigureTestServices(services => { @@ -40,6 +51,8 @@ public sealed class SalvageActivityDeniedRegistryWebApplicationFactory : WebAppl if (d.ServiceType == typeof(IPositionStateStore) || d.ServiceType == typeof(IPlayerHotbarLoadoutStore) || d.ServiceType == typeof(IPlayerSkillProgressionStore) || + d.ServiceType == typeof(IPlayerInventoryStore) || + d.ServiceType == typeof(IResourceNodeInstanceStore) || d.ServiceType == typeof(IPlayerPerkStateStore) || d.ServiceType == typeof(TimeProvider) || d.ServiceType == typeof(IPlayerAbilityCooldownStore) || @@ -58,6 +71,8 @@ public sealed class SalvageActivityDeniedRegistryWebApplicationFactory : WebAppl services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/server/NeonSprawl.Server/Game/Interaction/InteractionApi.cs b/server/NeonSprawl.Server/Game/Interaction/InteractionApi.cs index 53d03bc..35f502e 100644 --- a/server/NeonSprawl.Server/Game/Interaction/InteractionApi.cs +++ b/server/NeonSprawl.Server/Game/Interaction/InteractionApi.cs @@ -1,3 +1,5 @@ +using NeonSprawl.Server.Game.Gathering; +using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.Skills; @@ -16,8 +18,10 @@ public static class InteractionApi app.MapPost( "/game/players/{id}/interact", (string id, InteractionRequest? body, IPositionStateStore store, - ISkillDefinitionRegistry registry, IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve, - PerkUnlockEngine perkUnlockEngine) => + IResourceNodeDefinitionRegistry nodeRegistry, IItemDefinitionRegistry itemRegistry, + IPlayerInventoryStore inventoryStore, ISkillDefinitionRegistry skillRegistry, + IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve, PerkUnlockEngine perkUnlockEngine, + IResourceNodeInstanceStore nodeInstanceStore) => { if (body is null || body.SchemaVersion != InteractionRequest.CurrentSchemaVersion) { @@ -72,17 +76,37 @@ public static class InteractionApi if (string.Equals(entry.Kind, PrototypeInteractableRegistry.ResourceNodeKind, StringComparison.Ordinal)) { - // NEO-41: prototype gather completion — same grant rules/persistence as POST …/skill-progression. - // Interact response stays Allowed=true; grant outcome is intentionally ignored (silent no-op on deny / store miss per plan). - _ = SkillProgressionGrantOperations.TryApplyGrant( + var gather = GatherOperations.TryGather( playerKey, - GatherSkillXpConstants.SalvageSkillId, - GatherSkillXpConstants.ActivityXpPerResourceNodeGather, - GatherSkillXpConstants.ActivitySourceKind, - registry, + lookupKey, + nodeRegistry, + itemRegistry, + inventoryStore, + skillRegistry, xpStore, levelCurve, - perkUnlockEngine); + perkUnlockEngine, + nodeInstanceStore); + + if (!gather.Success) + { + return Results.Json( + new InteractionResponse + { + SchemaVersion = InteractionResponse.CurrentSchemaVersion, + Allowed = false, + ReasonCode = gather.ReasonCode, + }); + } + + return Results.Json( + new InteractionResponse + { + SchemaVersion = InteractionResponse.CurrentSchemaVersion, + Allowed = true, + InteractableId = lookupKey, + Payload = new InteractionPlaceholderPayload(), + }); } return Results.Json( diff --git a/server/NeonSprawl.Server/Game/Interaction/PrototypeInteractableRegistry.cs b/server/NeonSprawl.Server/Game/Interaction/PrototypeInteractableRegistry.cs index 1bcf04c..2fd5f55 100644 --- a/server/NeonSprawl.Server/Game/Interaction/PrototypeInteractableRegistry.cs +++ b/server/NeonSprawl.Server/Game/Interaction/PrototypeInteractableRegistry.cs @@ -12,6 +12,15 @@ public static class PrototypeInteractableRegistry /// Second stub row for multi-entity projection (NEO-25). public const string PrototypeResourceNodeAlphaId = "prototype_resource_node_alpha"; + /// Subsurface lens gather anchor (NEO-63). + public const string PrototypeSubsurfaceVeinBetaId = "prototype_subsurface_vein_beta"; + + /// Bio lens gather anchor (NEO-63). + public const string PrototypeBioMatGammaId = "prototype_bio_mat_gamma"; + + /// Urban bulk lens gather anchor (NEO-63). + public const string PrototypeUrbanBulkDeltaId = "prototype_urban_bulk_delta"; + /// for gather-anchor nodes (NEO-41). public const string ResourceNodeKind = "resource_node"; @@ -21,10 +30,22 @@ public static class PrototypeInteractableRegistry /// +X / −Z open floor (avoids overlap with scene Obstacle at ~(6,1,5)); same radius as terminal. public static readonly PrototypeInteractableEntry PrototypeResourceNodeAlpha = new(12, 0.5, -6, 3.0, ResourceNodeKind); + /// 6 m east of alpha on −Z band (NEO-63). + public static readonly PrototypeInteractableEntry PrototypeSubsurfaceVeinBeta = new(18, 0.5, -6, 3.0, ResourceNodeKind); + + /// 6 m north (+Z) of alpha (NEO-63). + public static readonly PrototypeInteractableEntry PrototypeBioMatGamma = new(12, 0.5, 0, 3.0, ResourceNodeKind); + + /// Northeast open quadrant (NEO-63). + public static readonly PrototypeInteractableEntry PrototypeUrbanBulkDelta = new(18, 0.5, 0, 3.0, ResourceNodeKind); + private static readonly Dictionary ById = new(StringComparer.Ordinal) { [PrototypeTerminalId] = PrototypeTerminal, [PrototypeResourceNodeAlphaId] = PrototypeResourceNodeAlpha, + [PrototypeSubsurfaceVeinBetaId] = PrototypeSubsurfaceVeinBeta, + [PrototypeBioMatGammaId] = PrototypeBioMatGamma, + [PrototypeUrbanBulkDeltaId] = PrototypeUrbanBulkDelta, }; public static bool TryGet(string interactableIdLowercase, out PrototypeInteractableEntry entry) => From 01dc48a50af956571ec397046ec7329a194a70e6 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 24 May 2026 15:44:15 -0400 Subject: [PATCH 5/8] NEO-63: add plan, manual QA, and doc updates. README gather-via-interact section; E3.M1 anchor coords; backlog landed. --- .../E3_M1_ResourceNodeAndGatherLoop.md | 16 ++-- ...umentation_and_implementation_alignment.md | 2 +- .../modules/module_dependency_register.md | 2 +- docs/manual-qa/NEO-63.md | 65 ++++++++++++++ docs/plans/E3M1-prototype-backlog.md | 10 ++- docs/plans/NEO-63-implementation-plan.md | 90 +++++++++++++++++++ server/README.md | 14 ++- 7 files changed, 181 insertions(+), 18 deletions(-) create mode 100644 docs/manual-qa/NEO-63.md create mode 100644 docs/plans/NEO-63-implementation-plan.md diff --git a/docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md b/docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md index 3d61f1e..24a592f 100644 --- a/docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md +++ b/docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md @@ -81,10 +81,10 @@ The **first shipped four-node spine** is **frozen** for downstream references (r | `nodeDefId` / `interactableId` | `gatherLens` | Notes | |-------------------------------|--------------|--------| -| **`prototype_resource_node_alpha`** | `consumer_salvage` | Existing NEO-9/NEO-41 anchor (12, −6) | -| **`prototype_subsurface_vein_beta`** | `subsurface` | New anchor — plan PR sets X/Z | -| **`prototype_bio_mat_gamma`** | `bio` | New anchor — plan PR sets X/Z | -| **`prototype_urban_bulk_delta`** | `urban_bulk` | New anchor — plan PR sets X/Z | +| **`prototype_resource_node_alpha`** | `consumer_salvage` | NEO-9/NEO-41 anchor **(12, −6)** | +| **`prototype_subsurface_vein_beta`** | `subsurface` | NEO-63 anchor **(18, −6)** | +| **`prototype_bio_mat_gamma`** | `bio` | NEO-63 anchor **(12, 0)** | +| **`prototype_urban_bulk_delta`** | `urban_bulk` | NEO-63 anchor **(18, 0)** | **Rules:** Do **not** rename these ids after ship. CI enforces **exactly four** node defs and valid yield item references. Relaxing the gate belongs in a new Linear issue when the roster grows. @@ -92,13 +92,13 @@ The **first shipped four-node spine** is **frozen** for downstream references (r Epic 3 **Slice 2** — [gather nodes and outputs](../epics/epic_03_crafting_economy.md#epic-3-slice-2): 4–6 node types, yield tables, `resource_gathered`, `gather_node_depleted`. -**Prototype implementation anchor (NEO-41, until NEO-63):** a **successful** **`POST …/interact`** on **`kind: resource_node`** still grants **`salvage`** skill XP only (`activity`, 10 XP) via `SkillProgressionGrantOperations` — **no** inventory or depletion on the interact path yet. **`GatherOperations`** (NEO-62) owns the full gather side-effect chain; **E3M1-07** ([NEO-63](https://linear.app/neon-sprawl/issue/NEO-63)) wires interact to the gather engine and removes duplicate XP ([E3M1-prototype-backlog](../../plans/E3M1-prototype-backlog.md)). +**Interact path (NEO-63):** **`POST …/interact`** on **`kind: resource_node`** delegates to **`GatherOperations`** — success grants inventory + **`salvage`** XP + depletion; denies return **`allowed: false`** + **`reasonCode`** (`node_depleted`, `inventory_full`, …). **Four** anchors in **`PrototypeInteractableRegistry`**. Plan: [NEO-63 implementation plan](../../plans/NEO-63-implementation-plan.md); [server README — Gather via interact (NEO-63)](../../../server/README.md#gather-via-interact-neo-63). + +**Gather engine (NEO-62):** **`GatherOperations.TryGather`** → **`GatherResult`** (`success`, `reasonCode`, `grantsApplied[]`, `remainingGathers`, optional `xpGrantSummary`) — inventory + **`salvage`** XP + depletion commit in one orchestrated path; server-internal until promoted to wire. Plan: [NEO-62 implementation plan](../../plans/NEO-62-implementation-plan.md); [server README — Gather engine (NEO-62)](../../../server/README.md#gather-engine-neo-62). **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). - -**Gather engine (NEO-62):** **`GatherOperations.TryGather`** → **`GatherResult`** (`success`, `reasonCode`, `grantsApplied[]`, `remainingGathers`, optional `xpGrantSummary`) — inventory + **`salvage`** XP + depletion commit in one orchestrated path; server-internal until promoted to wire. Plan: [NEO-62 implementation plan](../../plans/NEO-62-implementation-plan.md); [server README — Gather engine (NEO-62)](../../../server/README.md#gather-engine-neo-62). +**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. 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). diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index f57793a..0d21bb6 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/`. **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). **NEO-62 landed:** **`GatherOperations`** + **`GatherResult`** — inventory grant, **`salvage`** XP, depletion commit ([NEO-62](../../plans/NEO-62-implementation-plan.md)); [server README — Gather engine (NEO-62)](../../../server/README.md#gather-engine-neo-62). **Still planned:** interact wiring ([NEO-63](https://linear.app/neon-sprawl/issue/NEO-63)), telemetry hooks ([NEO-64](https://linear.app/neon-sprawl/issue/NEO-64)). | [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), [NEO-62](../../plans/NEO-62-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, superseded on interact by NEO-63):** inline XP on **`resource_node`** interact. **NEO-57–NEO-61** — content, registry, HTTP defs, depletion store (see prior alignment). **NEO-62 landed:** **`GatherOperations`** + **`GatherResult`**. **NEO-63 landed:** **`POST …/interact`** **`resource_node`** → gather engine; **four** **`PrototypeInteractableRegistry`** anchors; Bruno gather → inventory + depletion deny ([NEO-63](../../plans/NEO-63-implementation-plan.md), [`NEO-63` manual QA](../../manual-qa/NEO-63.md)); [server README — Gather via interact (NEO-63)](../../../server/README.md#gather-via-interact-neo-63). **Still planned:** telemetry hooks ([NEO-64](https://linear.app/neon-sprawl/issue/NEO-64)). | [NEO-41](../../plans/NEO-41-implementation-plan.md) … [NEO-63](../../plans/NEO-63-implementation-plan.md), [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md); `server/NeonSprawl.Server/Game/Gathering/`, `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/decomposition/modules/module_dependency_register.md b/docs/decomposition/modules/module_dependency_register.md index 33797e6..7e14d28 100644 --- a/docs/decomposition/modules/module_dependency_register.md +++ b/docs/decomposition/modules/module_dependency_register.md @@ -48,7 +48,7 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen | E3.M4 | SinkAndDurabilityLifecycle | E3.M3, E8.M3 | DurabilityState, ItemSinkEvent, RepairCostRule | Pre-production | Planned | | E3.M5 | EconomyBalancePolicy | E3.M4, E9.M2 | EconomyPolicy, PriceBandRule, FaucetSinkRatio | Pre-production | Planned | -**E3.M1 note:** Epic 3 **Slice 2** backlog in Linear ([Epic 3 — Crafting, Gathering, and Itemization Economy](https://linear.app/neon-sprawl/project/epic-3-crafting-gathering-and-itemization-economy-65785ed05bc2)): [NEO-57](https://linear.app/neon-sprawl/issue/NEO-57) → [NEO-64](https://linear.app/neon-sprawl/issue/NEO-64); label **`E3.M1`**. See [E3M1-prototype-backlog.md](../../plans/E3M1-prototype-backlog.md), [E3_M1_ResourceNodeAndGatherLoop.md](E3_M1_ResourceNodeAndGatherLoop.md). **NEO-57** (content + CI), **NEO-58** (server fail-fast load), **NEO-59** (`IResourceNodeDefinitionRegistry` + DI), **NEO-60** (`GET /game/world/resource-node-definitions`), **NEO-61** (`IResourceNodeInstanceStore` + depletion operations, `V006`), **NEO-62** (`GatherOperations` + `GatherResult` — inventory + XP + depletion) — E3M1-01–**E3M1-06** complete; register row **In Progress** until interact wiring / telemetry slices land. **NEO-41** (prototype gather XP on `resource_node` interact) remains until **E3M1-07** migrates into the gather engine. Remaining Slice 2 work: interact integration ([NEO-63](https://linear.app/neon-sprawl/issue/NEO-63)) and telemetry hooks **`resource_gathered`** / **`gather_node_depleted`** ([NEO-64](https://linear.app/neon-sprawl/issue/NEO-64)). +**E3.M1 note:** Epic 3 **Slice 2** backlog in Linear ([Epic 3 — Crafting, Gathering, and Itemization Economy](https://linear.app/neon-sprawl/project/epic-3-crafting-gathering-and-itemization-economy-65785ed05bc2)): [NEO-57](https://linear.app/neon-sprawl/issue/NEO-57) → [NEO-64](https://linear.app/neon-sprawl/issue/NEO-64); label **`E3.M1`**. See [E3M1-prototype-backlog.md](../../plans/E3M1-prototype-backlog.md), [E3_M1_ResourceNodeAndGatherLoop.md](E3_M1_ResourceNodeAndGatherLoop.md). **NEO-57** (content + CI), **NEO-58** (server fail-fast load), **NEO-59** (`IResourceNodeDefinitionRegistry` + DI), **NEO-60** (`GET /game/world/resource-node-definitions`), **NEO-61** (`IResourceNodeInstanceStore` + depletion operations, `V006`), **NEO-62** (`GatherOperations` + `GatherResult`), **NEO-63** (interact → gather engine + four registry anchors + Bruno) — E3M1-01–**E3M1-07** complete; register row **In Progress** until telemetry slice lands. Remaining Slice 2 work: **`resource_gathered`** / **`gather_node_depleted`** hook sites ([NEO-64](https://linear.app/neon-sprawl/issue/NEO-64)). **E3.M3 note:** Epic 3 **Slice 1** backlog in Linear ([Epic 3 — Crafting, Gathering, and Itemization Economy](https://linear.app/neon-sprawl/project/epic-3-crafting-gathering-and-itemization-economy-65785ed05bc2)): [NEO-50](https://linear.app/neon-sprawl/issue/NEO-50) → [NEO-56](https://linear.app/neon-sprawl/issue/NEO-56); label **`E3.M3`**. See [E3M3-prototype-backlog.md](../../plans/E3M3-prototype-backlog.md), [E3_M3_ItemizationAndInventorySchema.md](E3_M3_ItemizationAndInventorySchema.md). **NEO-50** (content + CI), **NEO-51** (server fail-fast load), **NEO-52** (`IItemDefinitionRegistry` + DI), **NEO-53** (`GET /game/world/item-definitions`), **NEO-54** (inventory store + stack/slot rules engine), **NEO-55** (`GET`/`POST /game/players/{id}/inventory`), and **NEO-56** (comment-only `item_created` / `inventory_transfer_denied` telemetry hook sites in `PlayerInventoryOperations`) — Epic 3 Slice 1 backlog **E3M3-01**–**E3M3-07** complete; register row **In Progress** until Slice 2+; later slices update the alignment table as they land. diff --git a/docs/manual-qa/NEO-63.md b/docs/manual-qa/NEO-63.md new file mode 100644 index 0000000..ec1be0e --- /dev/null +++ b/docs/manual-qa/NEO-63.md @@ -0,0 +1,65 @@ +# Manual QA — NEO-63 (gather via interact → inventory) + +Reference: [implementation plan](../plans/NEO-63-implementation-plan.md), [NEO-62 implementation plan](../plans/NEO-62-implementation-plan.md) gather engine, [NEO-55](./NEO-55.md) inventory GET. + +## Preconditions + +- Run `NeonSprawl.Server` from `server/NeonSprawl.Server` (`dotnet run`; default `http://localhost:5253`). +- Player **`dev-local-1`** (default seed). + +```bash +BASE=http://localhost:5253 +ID=dev-local-1 +``` + +## Happy path — gather → inventory + XP + +Move in range of **`prototype_resource_node_alpha`** (anchor **12, −6**; stand at **10, −6**): + +```bash +curl -sS -i -X POST "${BASE}/game/players/${ID}/move" \ + -H "Content-Type: application/json" \ + -d '{"schemaVersion":1,"target":{"x":10,"y":0.9,"z":-6}}' +``` + +Interact: + +```bash +curl -sS -i -X POST "${BASE}/game/players/${ID}/interact" \ + -H "Content-Type: application/json" \ + -d '{"schemaVersion":1,"interactableId":"prototype_resource_node_alpha"}' +``` + +Expect **HTTP 200**, **`allowed`:** **true**. + +Inventory should show **`scrap_metal_bulk`** quantity **1** (or +1 per repeat): + +```bash +curl -sS "${BASE}/game/players/${ID}/inventory" +``` + +Skill progression should show **`salvage`** **`xp`** **+10** per successful gather: + +```bash +curl -sS "${BASE}/game/players/${ID}/skill-progression" +``` + +## Depletion deny + +Repeat interact on the same node until capacity is exhausted (**10** successful gathers on a fresh node), then interact again: + +```bash +curl -sS -i -X POST "${BASE}/game/players/${ID}/interact" \ + -H "Content-Type: application/json" \ + -d '{"schemaVersion":1,"interactableId":"prototype_resource_node_alpha"}' +``` + +Expect **HTTP 200**, **`allowed`:** **false**, **`reasonCode`:** **`node_depleted`**. Inventory and XP totals unchanged on that denied request. + +## Control — terminal + +Move near **`prototype_terminal`**, interact with **`prototype_terminal`**: **`allowed`:** **true**; no **`scrap_metal_bulk`** added; no gather XP from terminal alone. + +## Bruno + +Run folder `bruno/neon-sprawl-server/interaction/` — **`Post interact gather then get inventory.bru`** and **`Post interact depleted node deny.bru`**. diff --git a/docs/plans/E3M1-prototype-backlog.md b/docs/plans/E3M1-prototype-backlog.md index 32d8f0e..eec9851 100644 --- a/docs/plans/E3M1-prototype-backlog.md +++ b/docs/plans/E3M1-prototype-backlog.md @@ -219,10 +219,12 @@ Working backlog for **Epic 3 — Slice 2** ([gather nodes and outputs](../decomp **Acceptance criteria** -- [ ] In-range interact on fresh node increases inventory quantity and **`salvage`** XP. -- [ ] Depleted node returns **`node_depleted`** without inventory or XP change. -- [ ] Terminal interact unchanged (no gather side effects). -- [ ] Bruno documents happy path + depletion deny for at least one node. +- [x] In-range interact on fresh node increases inventory quantity and **`salvage`** XP. +- [x] Depleted node returns **`node_depleted`** without inventory or XP change. +- [x] Terminal interact unchanged (no gather side effects). +- [x] Bruno documents happy path + depletion deny for at least one node. + +**Landed:** [NEO-63](https://linear.app/neon-sprawl/issue/NEO-63) — interact gather wiring; telemetry is E3M1-08. --- diff --git a/docs/plans/NEO-63-implementation-plan.md b/docs/plans/NEO-63-implementation-plan.md new file mode 100644 index 0000000..5b4b804 --- /dev/null +++ b/docs/plans/NEO-63-implementation-plan.md @@ -0,0 +1,90 @@ +# NEO-63 — Implementation plan + +## Story reference + +| Field | Value | +|--------|--------| +| **Key** | NEO-63 | +| **Title** | E3.M1: Interact path integration + registry expansion + Bruno | +| **Linear** | https://linear.app/neon-sprawl/issue/NEO-63/e3m1-interact-path-integration-registry-expansion-bruno | +| **Module** | [E3.M1 — ResourceNodeAndGatherLoop](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md) · Epic 3 Slice 2 · backlog **E3M1-07** | +| **Branch** | `NEO-63-e3m1-interact-path-integration-registry-expansion-bruno` | +| **Precursors** | [NEO-62](https://linear.app/neon-sprawl/issue/NEO-62) — `GatherOperations` + `GatherResult` (**Done** on story branch); [NEO-41](https://linear.app/neon-sprawl/issue/NEO-41) — inline XP on interact (superseded) | + +## Kickoff clarifications + +| Topic | Question | Agent recommendation | Answer | +|--------|----------|----------------------|--------| +| **New node anchors** | X/Z for beta, gamma, delta? | **Beta (18, −6)**, **gamma (12, 0)**, **delta (18, 0)** — spread from alpha **(12, −6)** and terminal **(0, 0)**, same radius **3**. | **Adopted** (no blocking questions at kickoff; user said **begin**). | +| **Gather deny on interact** | `Allowed: false` + `reasonCode` vs NEO-41 silent success? | **`Allowed: false`** + passthrough gather **`reasonCode`** per E3M1-07 backlog. | **Adopted**. | +| **InteractionResponse payload** | Extend with gather fields? | **Keep placeholder** — verify via GET inventory / skill-progression (module doc). | **Adopted**. | + +## Goal, scope, and out-of-scope + +**Goal:** Wire **`POST …/interact`** **`resource_node`** path to **`GatherOperations`**; register **four** prototype nodes; Bruno + manual QA for gather → inventory and depletion deny. + +**In scope:** `InteractionApi`, `PrototypeInteractableRegistry` expansion, integration tests, Bruno, README, manual QA. + +**Out of scope:** Godot HUD, gather HTTP route, telemetry hooks (NEO-64). + +## Acceptance criteria checklist + +- [x] In-range interact on fresh node increases inventory quantity and **`salvage`** XP. +- [x] Depleted node returns **`node_depleted`** without inventory or XP change. +- [x] Terminal interact unchanged (no gather side effects). +- [x] Bruno documents happy path + depletion deny for at least one node. + +## Technical approach + +1. **`InteractionApi`:** On **`resource_node`** kind after range check, call **`GatherOperations.TryGather`**. Success → **`Allowed: true`**; deny → **`Allowed: false`** + **`ReasonCode`** from **`GatherResult`**. Remove NEO-41 inline **`SkillProgressionGrantOperations`** block. + +2. **Registry:** Add beta/gamma/delta ids + anchors (see kickoff table). **`GET …/interactables`** returns **5** rows (terminal + four nodes). + +3. **Tests:** New **`InteractionResourceNodeGatherIntegrationTests`** — inventory + XP success, depletion deny, terminal control, stub-registry deny. Update **`InteractablesWorldApiTests`** for four nodes. Trim NEO-41 XP-only defensive test (migrated to integration deny test). Fix **`SalvageActivityDeniedRegistryWebApplicationFactory`** for gather DI (items + resource nodes + inventory + instance stores). + +4. **Bruno:** Update alpha gather request; add gather → GET inventory; add depletion deny (pre-request 10 gathers). + +5. **Docs:** README interaction reason codes + gather subsection; E3.M1 module freeze table anchors; alignment + backlog checkboxes. + +## Files to add + +| Path | Purpose | +|------|---------| +| `server/NeonSprawl.Server.Tests/Game/Interaction/InteractionResourceNodeGatherIntegrationTests.cs` | HTTP integration: inventory, XP, depletion, terminal, deny registry. | +| `bruno/neon-sprawl-server/interaction/Post interact gather then get inventory.bru` | Happy path: interact + assert scrap in bag. | +| `bruno/neon-sprawl-server/interaction/Post interact depleted node deny.bru` | Pre-loop 10 gathers; 11th **`node_depleted`**. | +| `docs/manual-qa/NEO-63.md` | curl gather → inventory + depletion deny. | +| `docs/plans/NEO-63-implementation-plan.md` | This plan. | + +## Files to modify + +| Path | Rationale | +|------|-----------| +| `server/NeonSprawl.Server/Game/Interaction/InteractionApi.cs` | Delegate **`resource_node`** to **`GatherOperations`**; map denies. | +| `server/NeonSprawl.Server/Game/Interaction/PrototypeInteractableRegistry.cs` | Three new node anchors + registry entries. | +| `server/NeonSprawl.Server.Tests/Game/Interaction/InteractablesWorldApiTests.cs` | Assert **5** interactables + new anchor coords. | +| `server/NeonSprawl.Server.Tests/Game/Interaction/InteractionResourceNodeGatherXpTests.cs` | Remove obsolete NEO-41 silent-deny test; keep terminal + XP smoke. | +| `server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs` | Full in-memory gather dependencies for deny test host. | +| `bruno/neon-sprawl-server/interaction/Post interact prototype resource node gather.bru` | NEO-63 docs (inventory + XP via gather engine). | +| `server/README.md` | Gather via interact; reason codes; four anchors. | +| `docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md` | Anchor coords; NEO-63 slice bullet. | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E3.M1 row — NEO-63 landed. | +| `docs/decomposition/modules/module_dependency_register.md` | E3.M1 note — E3M1-07 complete. | +| `docs/plans/E3M1-prototype-backlog.md` | E3M1-07 checkboxes. | + +## Tests + +| Test file | What it covers | +|-----------|----------------| +| `InteractionResourceNodeGatherIntegrationTests.cs` | Success: +1 scrap, +10 salvage XP. Depletion: 10 gathers then **`node_depleted`**. Terminal: no side effects. Stub registry: deny + no grants. | +| `InteractablesWorldApiTests.cs` | **5** descriptors; beta/gamma/delta anchors. | +| `InteractionResourceNodeGatherXpTests.cs` | Terminal + alpha XP smoke (regression). | + +## Open questions / risks + +| Question / risk | Agent recommendation | Status | +|-----------------|----------------------|--------| +| **Branch stacks NEO-62** | PR base includes gather engine commits until NEO-62 merges. | **adopted** | +| **Client world props** | Godot **`interactable_world_builder`** picks up new ids from GET; no scene edit required for server QA. | **deferred** | + +None blocking. diff --git a/server/README.md b/server/README.md index 7ed9bb1..64ed30d 100644 --- a/server/README.md +++ b/server/README.md @@ -83,7 +83,7 @@ World-wide **remaining gathers** per **`interactableId`** live in **`Game/Gather | **`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). +**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: [NEO-63](#gather-via-interact-neo-63); plan: [NEO-61 implementation plan](../../docs/plans/NEO-61-implementation-plan.md). ## Gather engine (NEO-62) @@ -97,7 +97,7 @@ World-wide **remaining gathers** per **`interactableId`** live in **`Game/Gather | **`progression_store_missing`** | Skill progression store could not apply XP (compensating inventory remove). | | **`inventory_store_missing`** | Player inventory store could not write. | -**Interact wiring** (replace NEO-41 inline XP, map denies to **`InteractionResponse`**) is **[NEO-63](https://linear.app/neon-sprawl/issue/NEO-63)**; plan: [NEO-62 implementation plan](../../docs/plans/NEO-62-implementation-plan.md). +**Interact wiring** (replace NEO-41 inline XP, map denies to **`InteractionResponse`**) is **[NEO-63](#gather-via-interact-neo-63)**; plan: [NEO-63 implementation plan](../../docs/plans/NEO-63-implementation-plan.md). ## Item definitions (NEO-53) @@ -312,12 +312,18 @@ curl -s -X POST http://localhost:5253/game/players/dev-local-1/interact \ |------|------| | `out_of_range` | Horizontal distance on X/Z is **greater than** the interactable’s `interactionRadius` (on the radius is **allowed** — inclusive `<=`). | | `unknown_interactable` | Id not in the prototype registry (after trim + case-insensitive lookup). | +| `node_depleted` | **`resource_node`** gather: instance capacity exhausted ([NEO-63](#gather-via-interact-neo-63)). | +| `inventory_full` | **`resource_node`** gather: yield could not fit in bag (all-or-nothing; [NEO-54](#player-inventory-neo-54-store-neo-55-http)). | +| `unknown_node` | **`resource_node`** gather: registry miss (defensive; should not occur for registered interactables). | +| `progression_store_missing` | **`resource_node`** gather: skill progression store could not apply XP. | When **`allowed` is `true`**, **`reasonCode` is omitted** (clients must not require it on success). Unknown player never returns this JSON — use **404**. -### Gather prototype → skill XP (NEO-41) +### Gather via interact (NEO-63) -When **`allowed` is `true`** and the interactable’s **`kind`** is **`resource_node`** (today: **`prototype_resource_node_alpha`** from **`GET /game/world/interactables`**), the server applies **10** **`salvage`** skill XP with **`sourceKind: activity`** using the **same validation and persistence** as **`POST /game/players/{id}/skill-progression`** ([NEO-38](#skill-progression-grant-neo-38)) via `SkillProgressionGrantOperations` — not a separate client-side XP bump. Move into horizontal range first (anchor **(12, −6)** m on X/Z, radius **3**). Plan: [NEO-41 implementation plan](../../docs/plans/NEO-41-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-41.md`](../../docs/manual-qa/NEO-41.md); Bruno: `bruno/neon-sprawl-server/position/Post move near prototype resource node.bru` then `bruno/neon-sprawl-server/interaction/Post interact prototype resource node gather.bru`. +When the interactable’s **`kind`** is **`resource_node`**, **`POST …/interact`** delegates to **`GatherOperations.TryGather`** ([NEO-62](#gather-engine-neo-62)): inventory grant, **`salvage`** skill XP (`activity`, 10 XP), and depletion commit. On success **`allowed: true`**; gather denies map to **`allowed: false`** + stable **`reasonCode`** (including **`node_depleted`**, **`inventory_full`**). **Four** prototype nodes are registered in **`PrototypeInteractableRegistry`** (alpha **(12, −6)**, beta **(18, −6)**, gamma **(12, 0)**, delta **(18, 0)** on X/Z, radius **3**). Verify with **`GET …/inventory`** and **`GET …/skill-progression`**. Plan: [NEO-63 implementation plan](../../docs/plans/NEO-63-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-63.md`](../../docs/manual-qa/NEO-63.md); Bruno: `bruno/neon-sprawl-server/interaction/Post interact prototype resource node gather.bru`, `Post interact gather then get inventory.bru`, `Post interact depleted node deny.bru`. + +**Historical:** [NEO-41](#gather-via-interact-neo-63) previously granted XP only on allowed interact without inventory or depletion; superseded by NEO-63. ### Craft / refine hook → skill XP (NEO-42) From 35381ad7491447ce01232427c88c51a77de6c880 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 24 May 2026 15:45:13 -0400 Subject: [PATCH 6/8] NEO-63: add code review document. --- docs/reviews/2026-05-24-NEO-63.md | 68 +++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 docs/reviews/2026-05-24-NEO-63.md diff --git a/docs/reviews/2026-05-24-NEO-63.md b/docs/reviews/2026-05-24-NEO-63.md new file mode 100644 index 0000000..1e4ae7e --- /dev/null +++ b/docs/reviews/2026-05-24-NEO-63.md @@ -0,0 +1,68 @@ +# Code review — NEO-63 interact path integration + NEO-62 gather engine + +**Date:** 2026-05-24 +**Scope:** Branch `NEO-63-e3m1-interact-path-integration-registry-expansion-bruno` · commits `acf9fe1`–`01dc48a` vs `origin/main` (includes stacked **NEO-62** gather engine commits) +**Base:** `origin/main` + +## Verdict + +**Approve with nits** + +## Summary + +This branch completes Epic 3 Slice 2 gather wiring: **NEO-62** adds **`GatherOperations.TryGather`** and **`GatherResult`** (inventory → XP → depletion commit, compensating rollback on XP failure); **NEO-63** replaces the NEO-41 silent inline XP path in **`InteractionApi`** with gather-engine delegation, expands **`PrototypeInteractableRegistry`** to four resource-node anchors, and adds HTTP integration tests, Bruno requests, and manual QA. Denies now surface as **`allowed: false`** with stable **`reasonCode`** passthrough (`node_depleted`, `inventory_full`, skill deny codes, etc.). Risk is moderate but well-contained: the orchestration touches inventory, progression, and depletion stores with ordered commits and rollback, but coverage is strong (6 engine tests, 4 new HTTP integration tests, registry/world API updates, Bruno depletion loop). Recommend merging **NEO-62** first or accepting a stacked PR with both stories called out in the PR title/body. + +## Documentation checked + +| Document | Result | +|----------|--------| +| [`docs/plans/NEO-62-implementation-plan.md`](../plans/NEO-62-implementation-plan.md) | **Matches** — commit order (inventory → XP → depletion), rollback on XP failure, reason-code passthrough, **`GatherResult`** shape, acceptance checklist complete. | +| [`docs/plans/NEO-63-implementation-plan.md`](../plans/NEO-63-implementation-plan.md) | **Matches** — **`InteractionApi`** delegates **`resource_node`** to gather engine; NEO-41 inline XP removed; four registry anchors at kickoff coords; integration + Bruno + manual QA; acceptance checklist complete. | +| [`docs/plans/E3M1-prototype-backlog.md`](../plans/E3M1-prototype-backlog.md) | **Matches** — E3M1-06 and E3M1-07 landed notes and checkboxes updated. | +| [`docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md`](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md) | **Matches** — four-node freeze table anchors, NEO-62 engine + NEO-63 interact slices, **`GatherResult`** field table, E1.M3 boundary (no separate gather HTTP). | +| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E3.M1 row notes NEO-62/NEO-63 landed; NEO-64 telemetry still planned. | +| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | **Matches** — **E3.M1 note** extended through E3M1-07; row **In Progress** until NEO-64. | +| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **Matches** — server resolves gather outcomes; client sends interact intent only. | +| [`docs/manual-qa/NEO-63.md`](../manual-qa/NEO-63.md) | **Matches** — happy path, depletion deny, terminal control, Bruno pointers. | +| [`server/README.md`](../../server/README.md) | **Matches** — gather engine subsection, gather-via-interact reason codes, four anchors. | + +Register/tracking: alignment table and **E3.M1 note** updated for NEO-62/NEO-63. + +## Blocking issues + +None. + +## Suggestions + +1. **AAA layout in depletion integration test** — `InteractionResourceNodeGatherIntegrationTests.PostInteract_ResourceNode_WhenDepleted_ShouldDenyWithNodeDepletedWithoutGrants` asserts **`Allowed`** inside the Arrange loop (10 pre-gathers). Move loop invariants to **Act** (fire-and-forget or collect last response) and keep all expectations in **Assert**, per [csharp-style](../../.cursor/rules/csharp-style.md#unit-and-integration-tests-arrange-act-assert) — same pattern flagged on NEO-61. + +2. **Tighten skill-deny integration assertion** — `PostInteract_ResourceNode_WhenSalvageDisallowsActivity_ShouldDenyWithoutGrants` only checks `ReasonCode` is non-empty. Assert the expected passthrough **`source_kind_not_allowed`** (from `SkillProgressionSnapshotApi.ReasonSourceKindNotAllowed`) so regressions in XP rollback + reason mapping are caught explicitly. + +3. **Optional HTTP `inventory_full` deny** — Engine test covers full-bag deny without depletion; no interact-level test yet. Low priority since **`GatherOperationsTests`** already locks the rule; add one HTTP test if you want parity with depletion deny at the wire boundary. + +4. **Test duplication (optional cleanup)** — `InteractionResourceNodeGatherXpTests` overlaps with `InteractionResourceNodeGatherIntegrationTests` on terminal control and alpha XP smoke. Plan kept both for regression; consider trimming the legacy file to terminal-only once NEO-63 integration tests are trusted. + +## Nits + +- Nit: **`SkillProgressionGrantOperations`** summary still references “NEO-41 gather interact hook”; interact now routes through **`GatherOperations`** (NEO-63). + +- Nit: **`GatherOperations`** class comment says “Interact wiring is NEO-63” — wiring is landed; update to “wired from **`InteractionApi`** (NEO-63)”. + +- Nit: Stacked branch — five commits include NEO-62 plan + engine before NEO-63 wiring. Call out both issue keys in the PR title/description so reviewers know scope. + +- Nit: Beta/gamma/delta per-node yield quantities (2/3/5) are validated in **`GatherOperationsTests`** (delta) but not via HTTP interact on the new anchors; acceptable for prototype, optional Bruno spot-check on delta. + +## Verification + +```bash +cd server +dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj --filter "FullyQualifiedName~Gather|FullyQualifiedName~Interaction" +# Passed! Failed: 0, Passed: 70 + +dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj +# Passed! Failed: 0, Passed: 292 +``` + +Manual / Bruno: run `docs/manual-qa/NEO-63.md` curl steps; Bruno folder `bruno/neon-sprawl-server/interaction/` — **`Post interact gather then get inventory.bru`**, **`Post interact depleted node deny.bru`**. + +Postgres persistence (when `ConnectionStrings__NeonSprawl` is set): gather depletion rows should persist across restarts via existing NEO-61 store (not re-tested in this diff). From 3834b2316a7aa14937ed0330f3cdb19cbaa89839 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 24 May 2026 15:46:20 -0400 Subject: [PATCH 7/8] NEO-63: address code review follow-ups. AAA depletion test layout, source_kind_not_allowed assertion, HTTP inventory_full deny test, trim duplicate XP test, doc comments. --- docs/reviews/2026-05-24-NEO-63.md | 12 +-- .../Game/Interaction/InteractionApiTests.cs | 2 +- ...ctionResourceNodeGatherIntegrationTests.cs | 92 ++++++++++++++----- .../InteractionResourceNodeGatherXpTests.cs | 38 +------- .../Game/Gathering/GatherOperations.cs | 2 +- .../Skills/SkillProgressionGrantOperations.cs | 2 +- 6 files changed, 77 insertions(+), 71 deletions(-) diff --git a/docs/reviews/2026-05-24-NEO-63.md b/docs/reviews/2026-05-24-NEO-63.md index 1e4ae7e..7680561 100644 --- a/docs/reviews/2026-05-24-NEO-63.md +++ b/docs/reviews/2026-05-24-NEO-63.md @@ -34,19 +34,19 @@ None. ## Suggestions -1. **AAA layout in depletion integration test** — `InteractionResourceNodeGatherIntegrationTests.PostInteract_ResourceNode_WhenDepleted_ShouldDenyWithNodeDepletedWithoutGrants` asserts **`Allowed`** inside the Arrange loop (10 pre-gathers). Move loop invariants to **Act** (fire-and-forget or collect last response) and keep all expectations in **Assert**, per [csharp-style](../../.cursor/rules/csharp-style.md#unit-and-integration-tests-arrange-act-assert) — same pattern flagged on NEO-61. +1. ~~**AAA layout in depletion integration test** — `InteractionResourceNodeGatherIntegrationTests.PostInteract_ResourceNode_WhenDepleted_ShouldDenyWithNodeDepletedWithoutGrants` asserts **`Allowed`** inside the Arrange loop (10 pre-gathers). Move loop invariants to **Act** (fire-and-forget or collect last response) and keep all expectations in **Assert**, per [csharp-style](../../.cursor/rules/csharp-style.md#unit-and-integration-tests-arrange-act-assert) — same pattern flagged on NEO-61.~~ **Done.** — pre-gather loop moved to **Act**; **`Assert.All`** on successful responses; depletion + totals asserted in **Assert**. -2. **Tighten skill-deny integration assertion** — `PostInteract_ResourceNode_WhenSalvageDisallowsActivity_ShouldDenyWithoutGrants` only checks `ReasonCode` is non-empty. Assert the expected passthrough **`source_kind_not_allowed`** (from `SkillProgressionSnapshotApi.ReasonSourceKindNotAllowed`) so regressions in XP rollback + reason mapping are caught explicitly. +2. ~~**Tighten skill-deny integration assertion** — `PostInteract_ResourceNode_WhenSalvageDisallowsActivity_ShouldDenyWithoutGrants` only checks `ReasonCode` is non-empty. Assert the expected passthrough **`source_kind_not_allowed`** (from `SkillProgressionSnapshotApi.ReasonSourceKindNotAllowed`) so regressions in XP rollback + reason mapping are caught explicitly.~~ **Done.** -3. **Optional HTTP `inventory_full` deny** — Engine test covers full-bag deny without depletion; no interact-level test yet. Low priority since **`GatherOperationsTests`** already locks the rule; add one HTTP test if you want parity with depletion deny at the wire boundary. +3. ~~**Optional HTTP `inventory_full` deny** — Engine test covers full-bag deny without depletion; no interact-level test yet. Low priority since **`GatherOperationsTests`** already locks the rule; add one HTTP test if you want parity with depletion deny at the wire boundary.~~ **Done.** — **`PostInteract_ResourceNode_WhenBagFull_ShouldDenyWithInventoryFullWithoutDepletingNode`**. -4. **Test duplication (optional cleanup)** — `InteractionResourceNodeGatherXpTests` overlaps with `InteractionResourceNodeGatherIntegrationTests` on terminal control and alpha XP smoke. Plan kept both for regression; consider trimming the legacy file to terminal-only once NEO-63 integration tests are trusted. +4. ~~**Test duplication (optional cleanup)** — `InteractionResourceNodeGatherXpTests` overlaps with `InteractionResourceNodeGatherIntegrationTests` on terminal control and alpha XP smoke. Plan kept both for regression; consider trimming the legacy file to terminal-only once NEO-63 integration tests are trusted.~~ **Done.** — legacy file trimmed to terminal-only; **`InteractionApiTests`** summary points at NEO-63 integration tests. ## Nits -- Nit: **`SkillProgressionGrantOperations`** summary still references “NEO-41 gather interact hook”; interact now routes through **`GatherOperations`** (NEO-63). +- ~~Nit: **`SkillProgressionGrantOperations`** summary still references “NEO-41 gather interact hook”; interact now routes through **`GatherOperations`** (NEO-63).~~ **Done.** -- Nit: **`GatherOperations`** class comment says “Interact wiring is NEO-63” — wiring is landed; update to “wired from **`InteractionApi`** (NEO-63)”. +- ~~Nit: **`GatherOperations`** class comment says “Interact wiring is NEO-63” — wiring is landed; update to “wired from **`InteractionApi`** (NEO-63)”.~~ **Done.** - Nit: Stacked branch — five commits include NEO-62 plan + engine before NEO-63 wiring. Call out both issue keys in the PR title/description so reviewers know scope. diff --git a/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionApiTests.cs index c2e380c..4b09426 100644 --- a/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionApiTests.cs @@ -7,7 +7,7 @@ using Xunit; namespace NeonSprawl.Server.Tests.Game.Interaction; -/// Integration tests for (NS-18). NEO-41: covers gather XP on resource_node kind. +/// Integration tests for (NS-18). NEO-63: covers gather via interact. public class InteractionApiTests { [Fact] diff --git a/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionResourceNodeGatherIntegrationTests.cs b/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionResourceNodeGatherIntegrationTests.cs index a61c89f..fff00ec 100644 --- a/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionResourceNodeGatherIntegrationTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionResourceNodeGatherIntegrationTests.cs @@ -59,27 +59,67 @@ public sealed class InteractionResourceNodeGatherIntegrationTests await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); await MoveNearAlphaAsync(client); - for (var i = 0; i < 10; i++) + var request = new InteractionRequest { - var gather = await client.PostAsJsonAsync( - "/game/players/dev-local-1/interact", - new InteractionRequest - { - SchemaVersion = InteractionRequest.CurrentSchemaVersion, - InteractableId = PrototypeInteractableRegistry.PrototypeResourceNodeAlphaId, - }); - Assert.Equal(HttpStatusCode.OK, gather.StatusCode); - var body = await gather.Content.ReadFromJsonAsync(); - Assert.NotNull(body); - Assert.True(body!.Allowed); + SchemaVersion = InteractionRequest.CurrentSchemaVersion, + InteractableId = PrototypeInteractableRegistry.PrototypeResourceNodeAlphaId, + }; + + // Act — exhaust capacity, then one more interact + var successfulGathers = new InteractionResponse[10]; + for (var i = 0; i < successfulGathers.Length; i++) + { + var gather = await client.PostAsJsonAsync("/game/players/dev-local-1/interact", request); + successfulGathers[i] = (await gather.Content.ReadFromJsonAsync())!; } + var depletedInteract = await client.PostAsJsonAsync("/game/players/dev-local-1/interact", request); + var inventoryAfter = await client.GetAsync("/game/players/dev-local-1/inventory"); + var progressionAfter = await client.GetAsync("/game/players/dev-local-1/skill-progression"); + + // Assert + Assert.All(successfulGathers, static r => Assert.True(r.Allowed)); + Assert.Equal(HttpStatusCode.OK, depletedInteract.StatusCode); + var envelope = await depletedInteract.Content.ReadFromJsonAsync(); + Assert.NotNull(envelope); + Assert.False(envelope!.Allowed); + Assert.Equal(GatherReasonCodes.NodeDepleted, envelope.ReasonCode); + Assert.Equal(10, CountItem( + (await inventoryAfter.Content.ReadFromJsonAsync())!, + "scrap_metal_bulk")); + Assert.Equal( + 10 * GatherSkillXpConstants.ActivityXpPerResourceNodeGather, + (await progressionAfter.Content.ReadFromJsonAsync())! + .Skills!.Single(static s => s.Id == "salvage").Xp); + } + + [Fact] + public async Task PostInteract_ResourceNode_WhenBagFull_ShouldDenyWithInventoryFullWithoutDepletingNode() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + for (var i = 0; i < PlayerInventorySnapshot.BagSlotCount; i++) + { + await client.PostAsJsonAsync( + "/game/players/dev-local-1/inventory", + new PlayerInventoryMutationRequest + { + SchemaVersion = PlayerInventoryMutationRequest.CurrentSchemaVersion, + MutationKind = "add", + ItemId = "survey_drone_kit", + Quantity = 1, + }); + } + + await MoveNearAlphaAsync(client); var inventoryBefore = await client.GetAsync("/game/players/dev-local-1/inventory"); var progressionBefore = await client.GetAsync("/game/players/dev-local-1/skill-progression"); - var bagBefore = await inventoryBefore.Content.ReadFromJsonAsync(); - var skillsBefore = await progressionBefore.Content.ReadFromJsonAsync(); - var scrapBefore = CountItem(bagBefore!, "scrap_metal_bulk"); - var salvageBefore = skillsBefore!.Skills!.Single(static s => s.Id == "salvage").Xp; + var scrapBefore = CountItem( + (await inventoryBefore.Content.ReadFromJsonAsync())!, + "scrap_metal_bulk"); + var salvageBefore = (await progressionBefore.Content.ReadFromJsonAsync())! + .Skills!.Single(static s => s.Id == "salvage").Xp; // Act var interact = await client.PostAsJsonAsync( @@ -89,22 +129,24 @@ public sealed class InteractionResourceNodeGatherIntegrationTests SchemaVersion = InteractionRequest.CurrentSchemaVersion, InteractableId = PrototypeInteractableRegistry.PrototypeResourceNodeAlphaId, }); - var inventoryAfter = await client.GetAsync("/game/players/dev-local-1/inventory"); - var progressionAfter = await client.GetAsync("/game/players/dev-local-1/skill-progression"); // Assert Assert.Equal(HttpStatusCode.OK, interact.StatusCode); var envelope = await interact.Content.ReadFromJsonAsync(); Assert.NotNull(envelope); Assert.False(envelope!.Allowed); - Assert.Equal(GatherReasonCodes.NodeDepleted, envelope.ReasonCode); - - var bagAfter = await inventoryAfter.Content.ReadFromJsonAsync(); - var skillsAfter = await progressionAfter.Content.ReadFromJsonAsync(); - Assert.Equal(scrapBefore, CountItem(bagAfter!, "scrap_metal_bulk")); + Assert.Equal(GatherReasonCodes.InventoryFull, envelope.ReasonCode); + Assert.Equal( + scrapBefore, + CountItem( + (await (await client.GetAsync("/game/players/dev-local-1/inventory")).Content + .ReadFromJsonAsync())!, + "scrap_metal_bulk")); Assert.Equal( salvageBefore, - skillsAfter!.Skills!.Single(static s => s.Id == "salvage").Xp); + (await (await client.GetAsync("/game/players/dev-local-1/skill-progression")).Content + .ReadFromJsonAsync())! + .Skills!.Single(static s => s.Id == "salvage").Xp); } [Fact] @@ -171,7 +213,7 @@ public sealed class InteractionResourceNodeGatherIntegrationTests var envelope = await interact.Content.ReadFromJsonAsync(); Assert.NotNull(envelope); Assert.False(envelope!.Allowed); - Assert.False(string.IsNullOrEmpty(envelope.ReasonCode)); + Assert.Equal(SkillProgressionSnapshotApi.ReasonSourceKindNotAllowed, envelope.ReasonCode); var bag = await inventory.Content.ReadFromJsonAsync(); Assert.NotNull(bag); diff --git a/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionResourceNodeGatherXpTests.cs b/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionResourceNodeGatherXpTests.cs index 5e7507b..ee0daad 100644 --- a/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionResourceNodeGatherXpTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionResourceNodeGatherXpTests.cs @@ -9,7 +9,7 @@ using Xunit; namespace NeonSprawl.Server.Tests.Game.Interaction; -/// NEO-41 legacy coverage retained for terminal control; gather side effects covered by NEO-63 integration tests. +/// NEO-41 terminal-only regression; gather side effects covered by NEO-63 integration tests. public sealed class InteractionResourceNodeGatherXpTests { [Fact] @@ -43,40 +43,4 @@ public sealed class InteractionResourceNodeGatherXpTests var salvage = body!.Skills!.Single(static s => s.Id == "salvage"); Assert.Equal(0, salvage.Xp); } - - [Fact] - public async Task PostInteract_ResourceNode_WhenInRange_ShouldGrantSalvageActivityXp_ViaProgressionSnapshot() - { - // Arrange — node at (12, -6), radius 3; stand at (10, -6) → horizontal distance 2 - await using var factory = new InMemoryWebApplicationFactory(); - var client = factory.CreateClient(); - var move = new MoveCommandRequest - { - SchemaVersion = MoveCommandRequest.CurrentSchemaVersion, - Target = new PositionVector { X = 10, Y = 0.9, Z = -6 }, - }; - await client.PostAsJsonAsync("/game/players/dev-local-1/move", move); - - // Act - var interact = await client.PostAsJsonAsync( - "/game/players/dev-local-1/interact", - new InteractionRequest - { - SchemaVersion = InteractionRequest.CurrentSchemaVersion, - InteractableId = PrototypeInteractableRegistry.PrototypeResourceNodeAlphaId, - }); - var snapshot = await client.GetAsync("/game/players/dev-local-1/skill-progression"); - - // Assert - Assert.Equal(HttpStatusCode.OK, interact.StatusCode); - var envelope = await interact.Content.ReadFromJsonAsync(); - Assert.NotNull(envelope); - Assert.True(envelope!.Allowed); - - Assert.Equal(HttpStatusCode.OK, snapshot.StatusCode); - var body = await snapshot.Content.ReadFromJsonAsync(); - Assert.NotNull(body); - var salvage = body!.Skills!.Single(static s => s.Id == "salvage"); - Assert.Equal(GatherSkillXpConstants.ActivityXpPerResourceNodeGather, salvage.Xp); - } } diff --git a/server/NeonSprawl.Server/Game/Gathering/GatherOperations.cs b/server/NeonSprawl.Server/Game/Gathering/GatherOperations.cs index 3975e95..187396b 100644 --- a/server/NeonSprawl.Server/Game/Gathering/GatherOperations.cs +++ b/server/NeonSprawl.Server/Game/Gathering/GatherOperations.cs @@ -6,7 +6,7 @@ namespace NeonSprawl.Server.Game.Gathering; /// /// Orchestrates gather side effects: capacity pre-check, inventory grant, skill XP, depletion commit (NEO-62). -/// Interact wiring is NEO-63. +/// Wired from on resource_node interact (NEO-63). /// public static class GatherOperations { diff --git a/server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantOperations.cs b/server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantOperations.cs index f9f781a..2f7651c 100644 --- a/server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantOperations.cs +++ b/server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantOperations.cs @@ -2,7 +2,7 @@ using NeonSprawl.Server.Game.Mastery; namespace NeonSprawl.Server.Game.Skills; -/// Shared NEO-38 skill XP grant apply (used by HTTP POST and NEO-41 gather interact hook). +/// Shared NEO-38 skill XP grant apply (HTTP POST, gather engine via NEO-63 interact, and other callers). public static class SkillProgressionGrantOperations { /// From 0f846f90b9639be85067d93e6c0221a7a7403aaf Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 24 May 2026 15:51:32 -0400 Subject: [PATCH 8/8] NEO-63: fix Bruno depletion test node isolation. Use prototype_urban_bulk_delta so earlier alpha gathers in the collection do not pre-deplete capacity under CI Postgres persistence. --- .../interaction/Post interact depleted node deny.bru | 9 +++++---- docs/manual-qa/NEO-63.md | 12 ++++++++++-- docs/plans/NEO-63-implementation-plan.md | 2 +- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/bruno/neon-sprawl-server/interaction/Post interact depleted node deny.bru b/bruno/neon-sprawl-server/interaction/Post interact depleted node deny.bru index 9964c31..0f9bc77 100644 --- a/bruno/neon-sprawl-server/interaction/Post interact depleted node deny.bru +++ b/bruno/neon-sprawl-server/interaction/Post interact depleted node deny.bru @@ -5,18 +5,19 @@ meta { } docs { - NEO-63: pre-request exhausts prototype_resource_node_alpha capacity (10 successful gathers), then this interact should deny with node_depleted. + NEO-63: uses prototype_urban_bulk_delta (not alpha) so earlier interaction requests in this collection do not pre-deplete capacity. Pre-request exhausts the node (10 successful gathers), then this interact should deny with node_depleted. } script:pre-request { const axios = require("axios"); const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl"); const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId"); + const nodeId = "prototype_urban_bulk_delta"; await axios.post( `${baseUrl}/game/players/${playerId}/move`, { schemaVersion: 1, - target: { x: 10, y: 0.9, z: -6 }, + target: { x: 16, y: 0.9, z: 0 }, }, { headers: { "Content-Type": "application/json" } }, ); @@ -25,7 +26,7 @@ script:pre-request { `${baseUrl}/game/players/${playerId}/interact`, { schemaVersion: 1, - interactableId: "prototype_resource_node_alpha", + interactableId: nodeId, }, { headers: { "Content-Type": "application/json" } }, ); @@ -48,7 +49,7 @@ headers { body:json { { "schemaVersion": 1, - "interactableId": "prototype_resource_node_alpha" + "interactableId": "prototype_urban_bulk_delta" } } diff --git a/docs/manual-qa/NEO-63.md b/docs/manual-qa/NEO-63.md index ec1be0e..c067ea1 100644 --- a/docs/manual-qa/NEO-63.md +++ b/docs/manual-qa/NEO-63.md @@ -46,12 +46,20 @@ curl -sS "${BASE}/game/players/${ID}/skill-progression" ## Depletion deny -Repeat interact on the same node until capacity is exhausted (**10** successful gathers on a fresh node), then interact again: +Uses **`prototype_urban_bulk_delta`** (anchor **18, 0**) so earlier Bruno interaction requests on alpha do not pre-deplete capacity in the shared CI Postgres store. Move in range (**16, 0**), exhaust with **10** successful interacts on delta, then interact again: + +```bash +curl -sS -i -X POST "${BASE}/game/players/${ID}/move" \ + -H "Content-Type: application/json" \ + -d '{"schemaVersion":1,"target":{"x":16,"y":0.9,"z":0}}' +``` + +(Repeat interact on **`prototype_urban_bulk_delta`** ten times with **`allowed: true`**, then:) ```bash curl -sS -i -X POST "${BASE}/game/players/${ID}/interact" \ -H "Content-Type: application/json" \ - -d '{"schemaVersion":1,"interactableId":"prototype_resource_node_alpha"}' + -d '{"schemaVersion":1,"interactableId":"prototype_urban_bulk_delta"}' ``` Expect **HTTP 200**, **`allowed`:** **false**, **`reasonCode`:** **`node_depleted`**. Inventory and XP totals unchanged on that denied request. diff --git a/docs/plans/NEO-63-implementation-plan.md b/docs/plans/NEO-63-implementation-plan.md index 5b4b804..8b68270 100644 --- a/docs/plans/NEO-63-implementation-plan.md +++ b/docs/plans/NEO-63-implementation-plan.md @@ -52,7 +52,7 @@ |------|---------| | `server/NeonSprawl.Server.Tests/Game/Interaction/InteractionResourceNodeGatherIntegrationTests.cs` | HTTP integration: inventory, XP, depletion, terminal, deny registry. | | `bruno/neon-sprawl-server/interaction/Post interact gather then get inventory.bru` | Happy path: interact + assert scrap in bag. | -| `bruno/neon-sprawl-server/interaction/Post interact depleted node deny.bru` | Pre-loop 10 gathers; 11th **`node_depleted`**. | +| `bruno/neon-sprawl-server/interaction/Post interact depleted node deny.bru` | Pre-loop 10 gathers on **`prototype_urban_bulk_delta`** (isolated from alpha requests earlier in collection); 11th **`node_depleted`**. | | `docs/manual-qa/NEO-63.md` | curl gather → inventory + depletion deny. | | `docs/plans/NEO-63-implementation-plan.md` | This plan. |