From 0cf82b9c0ae7960dd9aa105c8263106c52e41541 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 31 May 2026 16:57:53 -0400 Subject: [PATCH] NEO-105: Add encounter completion operations and inventory grants. TryCompleteAndGrant applies reward-table fixed grants with pre-flight simulate, compensating rollback, and idempotent completion marking. Returns EncounterCompleteEvent payload for NEO-107 persistence. --- .../EncounterCompletionOperationsTests.cs | 334 ++++++++++++++++++ .../Game/Encounters/EncounterCompleteEvent.cs | 13 + .../EncounterCompletionOperations.cs | 172 +++++++++ .../EncounterCompletionReasonCodes.cs | 26 ++ .../Encounters/EncounterCompletionResult.cs | 14 + .../Game/Encounters/EncounterGrantApplied.cs | 4 + 6 files changed, 563 insertions(+) create mode 100644 server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs create mode 100644 server/NeonSprawl.Server/Game/Encounters/EncounterCompleteEvent.cs create mode 100644 server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperations.cs create mode 100644 server/NeonSprawl.Server/Game/Encounters/EncounterCompletionReasonCodes.cs create mode 100644 server/NeonSprawl.Server/Game/Encounters/EncounterCompletionResult.cs create mode 100644 server/NeonSprawl.Server/Game/Encounters/EncounterGrantApplied.cs diff --git a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs new file mode 100644 index 0000000..94aa65f --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs @@ -0,0 +1,334 @@ +using Microsoft.Extensions.DependencyInjection; +using NeonSprawl.Server.Game.Encounters; +using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Tests; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Encounters; + +public sealed class EncounterCompletionOperationsTests +{ + private const string PlayerId = "dev-local-1"; + private const string EncounterId = "prototype_combat_pocket"; + private const string MeleeNpc = "prototype_npc_melee"; + private const string RangedNpc = "prototype_npc_ranged"; + private const string EliteNpc = "prototype_npc_elite"; + + [Fact] + public async Task TryCompleteAndGrant_ShouldGrantPrototypeLootOnce_WhenAllTargetsDefeated() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + DefeatAllRequiredTargets(deps); + var timeProvider = TimeProvider.System; + + // Act + var result = EncounterCompletionOperations.TryCompleteAndGrant( + PlayerId, + EncounterId, + deps.EncounterRegistry, + deps.RewardTableRegistry, + deps.ProgressStore, + deps.CompletionStore, + deps.ItemRegistry, + deps.InventoryStore, + timeProvider); + + // Assert + deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory); + Assert.True(result.Success); + Assert.Null(result.ReasonCode); + Assert.Equal(2, result.GrantsApplied.Count); + Assert.Contains(result.GrantsApplied, g => g.ItemId == "scrap_metal_bulk" && g.Quantity == 10); + Assert.Contains(result.GrantsApplied, g => g.ItemId == "contract_handoff_token" && g.Quantity == 1); + Assert.NotNull(result.CompleteEvent); + Assert.Equal(PlayerId, result.CompleteEvent!.Value.PlayerId); + Assert.Equal(EncounterId, result.CompleteEvent.Value.EncounterId); + Assert.Equal("prototype_combat_pocket_clear", result.CompleteEvent.Value.RewardTableId); + Assert.Equal($"{PlayerId}:{EncounterId}", result.CompleteEvent.Value.IdempotencyKey); + Assert.NotNull(result.CompletedAt); + Assert.True(deps.CompletionStore.IsCompleted(PlayerId, EncounterId)); + Assert.Equal(10, CountBagItem(inventory!, "scrap_metal_bulk")); + Assert.Equal(1, CountBagItem(inventory!, "contract_handoff_token")); + } + + [Fact] + public async Task TryCompleteAndGrant_ShouldDenyAlreadyCompleted_WhenCalledTwice() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + DefeatAllRequiredTargets(deps); + var timeProvider = TimeProvider.System; + var first = EncounterCompletionOperations.TryCompleteAndGrant( + PlayerId, + EncounterId, + deps.EncounterRegistry, + deps.RewardTableRegistry, + deps.ProgressStore, + deps.CompletionStore, + deps.ItemRegistry, + deps.InventoryStore, + timeProvider); + deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterFirst); + + // Act + var second = EncounterCompletionOperations.TryCompleteAndGrant( + PlayerId, + EncounterId, + deps.EncounterRegistry, + deps.RewardTableRegistry, + deps.ProgressStore, + deps.CompletionStore, + deps.ItemRegistry, + deps.InventoryStore, + timeProvider); + + // Assert + deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterSecond); + Assert.True(first.Success); + Assert.False(second.Success); + Assert.Equal(EncounterCompletionReasonCodes.AlreadyCompleted, second.ReasonCode); + Assert.Empty(second.GrantsApplied); + Assert.Null(second.CompleteEvent); + Assert.Equal(10, CountBagItem(afterSecond!, "scrap_metal_bulk")); + Assert.Equal(1, CountBagItem(afterSecond!, "contract_handoff_token")); + Assert.Equal(CountBagItem(afterFirst!, "scrap_metal_bulk"), CountBagItem(afterSecond!, "scrap_metal_bulk")); + Assert.Equal(CountBagItem(afterFirst!, "contract_handoff_token"), CountBagItem(afterSecond!, "contract_handoff_token")); + } + + [Fact] + public async Task TryCompleteAndGrant_ShouldDenyNotReady_WhenOnlyTwoTargetsDefeated() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + _ = EncounterProgressOperations.TryActivateOnFirstEngagement( + PlayerId, + MeleeNpc, + deps.EncounterRegistry, + deps.ProgressStore, + deps.CompletionStore); + _ = EncounterProgressOperations.TryMarkTargetDefeated( + PlayerId, + MeleeNpc, + deps.EncounterRegistry, + deps.ProgressStore, + deps.CompletionStore); + _ = EncounterProgressOperations.TryMarkTargetDefeated( + PlayerId, + RangedNpc, + deps.EncounterRegistry, + deps.ProgressStore, + deps.CompletionStore); + deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory); + + // Act + var result = EncounterCompletionOperations.TryCompleteAndGrant( + PlayerId, + EncounterId, + deps.EncounterRegistry, + deps.RewardTableRegistry, + deps.ProgressStore, + deps.CompletionStore, + deps.ItemRegistry, + deps.InventoryStore, + TimeProvider.System); + + // Assert + deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory); + Assert.False(result.Success); + Assert.Equal(EncounterCompletionReasonCodes.NotReady, result.ReasonCode); + Assert.False(deps.CompletionStore.IsCompleted(PlayerId, EncounterId)); + Assert.Equal(TotalBagQuantity(beforeInventory!), TotalBagQuantity(afterInventory!)); + } + + [Fact] + public async Task TryCompleteAndGrant_ShouldDenyInventoryFull_WhenBagHasNoFreeSlots() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + DefeatAllRequiredTargets(deps); + FillBag(deps); + deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory); + + // Act + var result = EncounterCompletionOperations.TryCompleteAndGrant( + PlayerId, + EncounterId, + deps.EncounterRegistry, + deps.RewardTableRegistry, + deps.ProgressStore, + deps.CompletionStore, + deps.ItemRegistry, + deps.InventoryStore, + TimeProvider.System); + + // Assert + deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory); + Assert.False(result.Success); + Assert.Equal(EncounterCompletionReasonCodes.InventoryFull, result.ReasonCode); + Assert.False(deps.CompletionStore.IsCompleted(PlayerId, EncounterId)); + Assert.Equal(TotalBagQuantity(beforeInventory!), TotalBagQuantity(afterInventory!)); + Assert.Equal(0, CountBagItem(afterInventory!, "scrap_metal_bulk")); + Assert.Equal(0, CountBagItem(afterInventory!, "contract_handoff_token")); + } + + [Fact] + public async Task TryCompleteAndGrant_ShouldRollbackGrants_WhenCompletionMarkFails() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + DefeatAllRequiredTargets(deps); + var failingCompletionStore = new CompletionStoreDeniesMark(deps.CompletionStore); + deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory); + + // Act + var result = EncounterCompletionOperations.TryCompleteAndGrant( + PlayerId, + EncounterId, + deps.EncounterRegistry, + deps.RewardTableRegistry, + deps.ProgressStore, + failingCompletionStore, + deps.ItemRegistry, + deps.InventoryStore, + TimeProvider.System); + + // Assert + deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory); + Assert.False(result.Success); + Assert.Equal(EncounterCompletionReasonCodes.AlreadyCompleted, result.ReasonCode); + Assert.Equal(TotalBagQuantity(beforeInventory!), TotalBagQuantity(afterInventory!)); + Assert.Equal(0, CountBagItem(afterInventory!, "scrap_metal_bulk")); + Assert.Equal(0, CountBagItem(afterInventory!, "contract_handoff_token")); + } + + [Fact] + public async Task TryCompleteAndGrant_ShouldDenyUnknownEncounter_WhenIdIsInvalid() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + + // Act + var result = EncounterCompletionOperations.TryCompleteAndGrant( + PlayerId, + "unknown_encounter", + deps.EncounterRegistry, + deps.RewardTableRegistry, + deps.ProgressStore, + deps.CompletionStore, + deps.ItemRegistry, + deps.InventoryStore, + TimeProvider.System); + + // Assert + Assert.False(result.Success); + Assert.Equal(EncounterCompletionReasonCodes.UnknownEncounter, result.ReasonCode); + } + + private static void DefeatAllRequiredTargets(EncounterCompletionTestDependencies deps) + { + _ = EncounterProgressOperations.TryActivateOnFirstEngagement( + PlayerId, + MeleeNpc, + deps.EncounterRegistry, + deps.ProgressStore, + deps.CompletionStore); + _ = EncounterProgressOperations.TryMarkTargetDefeated( + PlayerId, + MeleeNpc, + deps.EncounterRegistry, + deps.ProgressStore, + deps.CompletionStore); + _ = EncounterProgressOperations.TryMarkTargetDefeated( + PlayerId, + RangedNpc, + deps.EncounterRegistry, + deps.ProgressStore, + deps.CompletionStore); + _ = EncounterProgressOperations.TryMarkTargetDefeated( + PlayerId, + EliteNpc, + deps.EncounterRegistry, + deps.ProgressStore, + deps.CompletionStore); + } + + private static void FillBag(EncounterCompletionTestDependencies deps) + { + 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); + } + } + + private static EncounterCompletionTestDependencies ResolveDependencies(InMemoryWebApplicationFactory factory) + { + var services = factory.Services; + return new EncounterCompletionTestDependencies( + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService()); + } + + private static int CountBagItem(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) + { + if (!slot.IsEmpty) + { + total += slot.Quantity; + } + } + + return total; + } + + private sealed record EncounterCompletionTestDependencies( + IEncounterDefinitionRegistry EncounterRegistry, + IRewardTableDefinitionRegistry RewardTableRegistry, + IEncounterProgressStore ProgressStore, + IEncounterCompletionStore CompletionStore, + IItemDefinitionRegistry ItemRegistry, + IPlayerInventoryStore InventoryStore); + + private sealed class CompletionStoreDeniesMark(IEncounterCompletionStore inner) : IEncounterCompletionStore + { + public bool IsCompleted(string playerId, string encounterId) => + inner.IsCompleted(playerId, encounterId); + + public bool TryMarkCompleted(string playerId, string encounterId, DateTimeOffset completedAt) => false; + + public bool TryGetCompletedAt(string playerId, string encounterId, out DateTimeOffset completedAt) => + inner.TryGetCompletedAt(playerId, encounterId, out completedAt); + } +} diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterCompleteEvent.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterCompleteEvent.cs new file mode 100644 index 0000000..e3a18ac --- /dev/null +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterCompleteEvent.cs @@ -0,0 +1,13 @@ +namespace NeonSprawl.Server.Game.Encounters; + +/// +/// Internal completion payload returned on successful encounter grant (NEO-105). +/// Persistence and E7.M2 quest-credit routing land in NEO-107. +/// +public readonly record struct EncounterCompleteEvent( + string PlayerId, + string EncounterId, + string RewardTableId, + IReadOnlyList GrantedItems, + DateTimeOffset CompletedAt, + string IdempotencyKey); diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperations.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperations.cs new file mode 100644 index 0000000..880e145 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperations.cs @@ -0,0 +1,172 @@ +using NeonSprawl.Server.Game.Items; + +namespace NeonSprawl.Server.Game.Encounters; + +/// +/// Applies encounter reward-table grants and marks completion idempotently (NEO-105). +/// Combat defeat wiring: (NEO-106). +/// +public static class EncounterCompletionOperations +{ + private static readonly EncounterGrantApplied[] EmptyGrants = []; + + /// + /// When all required targets are defeated and the encounter is not yet completed, applies + /// 's reward-table fixed grants then marks completion. + /// + public static EncounterCompletionResult TryCompleteAndGrant( + string playerId, + string encounterId, + IEncounterDefinitionRegistry encounterRegistry, + IRewardTableDefinitionRegistry rewardTableRegistry, + IEncounterProgressStore progressStore, + IEncounterCompletionStore completionStore, + IItemDefinitionRegistry itemRegistry, + IPlayerInventoryStore inventoryStore, + TimeProvider timeProvider) + { + if (!encounterRegistry.TryNormalizeKnown(encounterId, out var normalizedEncounterId) || + !encounterRegistry.TryGetDefinition(normalizedEncounterId, out var encounterDefinition)) + { + return Deny(EncounterCompletionReasonCodes.UnknownEncounter); + } + + if (completionStore.IsCompleted(playerId, normalizedEncounterId)) + { + return Deny(EncounterCompletionReasonCodes.AlreadyCompleted); + } + + if (!EncounterProgressOperations.IsAllRequiredTargetsDefeated( + playerId, + normalizedEncounterId, + encounterRegistry, + progressStore)) + { + return Deny(EncounterCompletionReasonCodes.NotReady); + } + + if (!rewardTableRegistry.TryGetDefinition(encounterDefinition.RewardTableId, out var rewardTable)) + { + return Deny(EncounterCompletionReasonCodes.UnknownRewardTable); + } + + var grants = rewardTable.FixedGrants; + if (grants.Count == 0) + { + return Deny(EncounterCompletionReasonCodes.UnknownRewardTable); + } + + if (!inventoryStore.TryGetSnapshot(playerId, out var snapshot)) + { + return Deny(EncounterCompletionReasonCodes.InventoryStoreMissing); + } + + var simulated = CloneSnapshot(snapshot); + foreach (var grant in grants) + { + if (!PlayerInventoryOperations.TrySimulateAddStack( + ref simulated, + grant.ItemId, + grant.Quantity, + itemRegistry)) + { + return Deny(EncounterCompletionReasonCodes.InventoryFull); + } + } + + var applied = new List(grants.Count); + foreach (var grant in grants) + { + var addOutcome = PlayerInventoryOperations.TryAddStack( + playerId, + grant.ItemId, + grant.Quantity, + itemRegistry, + inventoryStore); + + if (addOutcome.Kind == PlayerInventoryMutationKind.StoreMissing) + { + CompensatingRemoveGrants(playerId, applied, itemRegistry, inventoryStore); + return Deny(EncounterCompletionReasonCodes.InventoryStoreMissing); + } + + if (addOutcome.Kind == PlayerInventoryMutationKind.Denied) + { + CompensatingRemoveGrants(playerId, applied, itemRegistry, inventoryStore); + return Deny(MapInventoryReason(addOutcome.ReasonCode)); + } + + applied.Add(new EncounterGrantApplied(grant.ItemId, grant.Quantity)); + } + + var completedAt = timeProvider.GetUtcNow(); + if (!completionStore.TryMarkCompleted(playerId, normalizedEncounterId, completedAt)) + { + CompensatingRemoveGrants(playerId, applied, itemRegistry, inventoryStore); + return Deny(EncounterCompletionReasonCodes.AlreadyCompleted); + } + + // TODO(E7.M2): quest-credit router consumes EncounterCompleteEvent (NEO-107 expands persistence + hook). + + var normalizedPlayerId = EncounterProgressIds.NormalizePlayerId(playerId); + var completeEvent = new EncounterCompleteEvent( + normalizedPlayerId, + normalizedEncounterId, + rewardTable.Id, + applied, + completedAt, + MakeIdempotencyKey(normalizedPlayerId, normalizedEncounterId)); + + return new EncounterCompletionResult( + Success: true, + ReasonCode: null, + GrantsApplied: applied, + CompleteEvent: completeEvent, + CompletedAt: completedAt); + } + + private static string MapInventoryReason(string? reasonCode) => + reasonCode switch + { + PlayerInventoryReasonCodes.InventoryFull => EncounterCompletionReasonCodes.InventoryFull, + PlayerInventoryReasonCodes.InvalidItem => EncounterCompletionReasonCodes.InvalidItem, + PlayerInventoryReasonCodes.InvalidQuantity => EncounterCompletionReasonCodes.InvalidQuantity, + _ => reasonCode ?? EncounterCompletionReasonCodes.InventoryFull, + }; + + private static string MakeIdempotencyKey(string normalizedPlayerId, string normalizedEncounterId) => + $"{normalizedPlayerId}:{normalizedEncounterId}"; + + private static PlayerInventorySnapshot CloneSnapshot(PlayerInventorySnapshot snapshot) => + new() + { + BagSlots = PlayerInventorySnapshot.CloneSlots(snapshot.BagSlots), + EquipmentSlots = PlayerInventorySnapshot.CloneSlots(snapshot.EquipmentSlots), + }; + + private static void CompensatingRemoveGrants( + string playerId, + IReadOnlyList grants, + IItemDefinitionRegistry itemRegistry, + IPlayerInventoryStore inventoryStore) + { + for (var i = grants.Count - 1; i >= 0; i--) + { + var grant = grants[i]; + _ = PlayerInventoryOperations.TryRemoveStack( + playerId, + grant.ItemId, + grant.Quantity, + itemRegistry, + inventoryStore); + } + } + + private static EncounterCompletionResult Deny(string reasonCode) => + new( + Success: false, + ReasonCode: reasonCode, + GrantsApplied: EmptyGrants, + CompleteEvent: null, + CompletedAt: null); +} diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterCompletionReasonCodes.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterCompletionReasonCodes.cs new file mode 100644 index 0000000..bd534ac --- /dev/null +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterCompletionReasonCodes.cs @@ -0,0 +1,26 @@ +using NeonSprawl.Server.Game.Items; + +namespace NeonSprawl.Server.Game.Encounters; + +/// Stable deny reason codes for encounter completion operations (NEO-105). +public static class EncounterCompletionReasonCodes +{ + public const string AlreadyCompleted = "already_completed"; + + public const string NotReady = "not_ready"; + + public const string UnknownEncounter = "unknown_encounter"; + + public const string UnknownRewardTable = "unknown_reward_table"; + + public const string InventoryStoreMissing = "inventory_store_missing"; + + /// Passthrough from . + public const string InventoryFull = PlayerInventoryReasonCodes.InventoryFull; + + /// Passthrough from . + public const string InvalidItem = PlayerInventoryReasonCodes.InvalidItem; + + /// Passthrough from . + public const string InvalidQuantity = PlayerInventoryReasonCodes.InvalidQuantity; +} diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterCompletionResult.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterCompletionResult.cs new file mode 100644 index 0000000..a8c66f5 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterCompletionResult.cs @@ -0,0 +1,14 @@ +namespace NeonSprawl.Server.Game.Encounters; + +/// +/// Server-internal encounter completion resolution envelope (NEO-105). +/// Combat wiring promotes via NEO-106; HTTP DTOs via NEO-108. +/// +/// Item grants on success; empty when denied. +/// Completion payload on success; null when denied. +public readonly record struct EncounterCompletionResult( + bool Success, + string? ReasonCode, + IReadOnlyList GrantsApplied, + EncounterCompleteEvent? CompleteEvent, + DateTimeOffset? CompletedAt); diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterGrantApplied.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterGrantApplied.cs new file mode 100644 index 0000000..fc10156 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterGrantApplied.cs @@ -0,0 +1,4 @@ +namespace NeonSprawl.Server.Game.Encounters; + +/// One item grant applied on a successful encounter completion (NEO-105). +public readonly record struct EncounterGrantApplied(string ItemId, int Quantity);