NEO-62: add GatherOperations engine and tests.
Orchestrates inventory grant, salvage XP, and depletion commit with compensating rollback on XP failure.pull/97/head
parent
acf9fe125f
commit
f23af01687
|
|
@ -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<IResourceNodeDefinitionRegistry>(),
|
||||
services.GetRequiredService<IItemDefinitionRegistry>(),
|
||||
services.GetRequiredService<IPlayerInventoryStore>(),
|
||||
services.GetRequiredService<ISkillDefinitionRegistry>(),
|
||||
services.GetRequiredService<IPlayerSkillProgressionStore>(),
|
||||
services.GetRequiredService<ISkillLevelCurve>(),
|
||||
services.GetRequiredService<PerkUnlockEngine>(),
|
||||
services.GetRequiredService<IResourceNodeInstanceStore>());
|
||||
}
|
||||
|
||||
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<string, int> GetXpTotals(string playerId) =>
|
||||
new Dictionary<string, int>(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<string, int> skillXpTotals) => false;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
namespace NeonSprawl.Server.Game.Gathering;
|
||||
|
||||
/// <summary>One item grant applied on a successful gather (NEO-62).</summary>
|
||||
public readonly record struct GatherGrantApplied(string ItemId, int Quantity);
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.Mastery;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Gathering;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates gather side effects: capacity pre-check, inventory grant, skill XP, depletion commit (NEO-62).
|
||||
/// Interact wiring is NEO-63.
|
||||
/// </summary>
|
||||
public static class GatherOperations
|
||||
{
|
||||
private static readonly GatherGrantApplied[] EmptyGrants = [];
|
||||
|
||||
/// <summary>
|
||||
/// Resolves one gather for <paramref name="playerId"/> at <paramref name="interactableId"/>.
|
||||
/// Mutations commit in order: inventory add → XP grant → depletion decrement (last).
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
using NeonSprawl.Server.Game.Items;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Gathering;
|
||||
|
||||
/// <summary>Stable deny reason codes for gather operations (NEO-62).</summary>
|
||||
public static class GatherReasonCodes
|
||||
{
|
||||
/// <summary>Passthrough from <see cref="ResourceNodeInstanceReasonCodes.UnknownNode"/>.</summary>
|
||||
public const string UnknownNode = ResourceNodeInstanceReasonCodes.UnknownNode;
|
||||
|
||||
/// <summary>Passthrough from <see cref="ResourceNodeInstanceReasonCodes.NodeDepleted"/>.</summary>
|
||||
public const string NodeDepleted = ResourceNodeInstanceReasonCodes.NodeDepleted;
|
||||
|
||||
/// <summary>Passthrough from <see cref="PlayerInventoryReasonCodes.InventoryFull"/>.</summary>
|
||||
public const string InventoryFull = PlayerInventoryReasonCodes.InventoryFull;
|
||||
|
||||
/// <summary><see cref="Skills.IPlayerSkillProgressionStore.TryApplyXpDelta"/> returned false.</summary>
|
||||
public const string ProgressionStoreMissing = "progression_store_missing";
|
||||
|
||||
/// <summary>Player inventory store could not write (e.g. unknown player bucket).</summary>
|
||||
public const string InventoryStoreMissing = "inventory_store_missing";
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
namespace NeonSprawl.Server.Game.Gathering;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
/// <param name="GrantsApplied">Item grants on success; empty when denied.</param>
|
||||
/// <param name="RemainingGathers">Authoritative capacity after success, or current capacity on depletion deny.</param>
|
||||
/// <param name="XpGrantSummary">Skill XP applied on success; null when denied.</param>
|
||||
public readonly record struct GatherResult(
|
||||
bool Success,
|
||||
string? ReasonCode,
|
||||
IReadOnlyList<GatherGrantApplied> GrantsApplied,
|
||||
int? RemainingGathers,
|
||||
GatherXpGrantSummary? XpGrantSummary);
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
namespace NeonSprawl.Server.Game.Gathering;
|
||||
|
||||
/// <summary>Compact skill XP summary on a successful gather (NEO-62).</summary>
|
||||
public readonly record struct GatherXpGrantSummary(string SkillId, int Amount, string SourceKind);
|
||||
Loading…
Reference in New Issue