From f23af01687bd6dbc4b41b095c0ad3afffd198fd6 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 24 May 2026 15:41:26 -0400 Subject: [PATCH] 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);