using Microsoft.Extensions.DependencyInjection; using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Quests; 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.EventStore, deps.ItemRegistry, deps.InventoryStore, deps.QuestRegistry, deps.QuestProgressStore, 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.True(deps.EventStore.TryGet(PlayerId, EncounterId, out var storedEvent)); Assert.Equal(result.CompleteEvent!.Value.IdempotencyKey, storedEvent.IdempotencyKey); Assert.Equal(2, storedEvent.GrantedItems.Count); 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.EventStore, deps.ItemRegistry, deps.InventoryStore, deps.QuestRegistry, deps.QuestProgressStore, timeProvider); deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterFirst); // Act var second = EncounterCompletionOperations.TryCompleteAndGrant( PlayerId, EncounterId, deps.EncounterRegistry, deps.RewardTableRegistry, deps.ProgressStore, deps.CompletionStore, deps.EventStore, deps.ItemRegistry, deps.InventoryStore, deps.QuestRegistry, deps.QuestProgressStore, timeProvider); // Assert deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterSecond); Assert.True(first.Success); Assert.Null(first.ReasonCode); Assert.Equal(2, first.GrantsApplied.Count); 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")); Assert.True(deps.EventStore.TryGet(PlayerId, EncounterId, out var storedEvent)); Assert.Equal(first.CompleteEvent!.Value.IdempotencyKey, storedEvent.IdempotencyKey); } [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.EventStore, deps.ItemRegistry, deps.InventoryStore, deps.QuestRegistry, deps.QuestProgressStore, 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.False(deps.EventStore.TryGet(PlayerId, EncounterId, out _)); 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.EventStore, deps.ItemRegistry, deps.InventoryStore, deps.QuestRegistry, deps.QuestProgressStore, 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.False(deps.EventStore.TryGet(PlayerId, EncounterId, out _)); 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.EventStore, deps.ItemRegistry, deps.InventoryStore, deps.QuestRegistry, deps.QuestProgressStore, 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")); Assert.False(deps.EventStore.TryGet(PlayerId, EncounterId, out _)); } [Fact] public async Task TryCompleteAndGrant_ShouldRollbackFirstGrant_WhenSecondApplyFails() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); DefeatAllRequiredTargets(deps); var inventoryStore = new InventoryStoreFullAfterFirstGrant(deps.InventoryStore, deps.ItemRegistry); // Act var result = EncounterCompletionOperations.TryCompleteAndGrant( PlayerId, EncounterId, deps.EncounterRegistry, deps.RewardTableRegistry, deps.ProgressStore, deps.CompletionStore, deps.EventStore, deps.ItemRegistry, inventoryStore, deps.QuestRegistry, deps.QuestProgressStore, TimeProvider.System); // Assert deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory); Assert.False(result.Success); Assert.Equal(EncounterCompletionReasonCodes.InventoryFull, result.ReasonCode); Assert.False(deps.CompletionStore.IsCompleted(PlayerId, EncounterId)); Assert.Equal(0, CountBagItem(inventory!, "scrap_metal_bulk")); Assert.Equal(0, CountBagItem(inventory!, "contract_handoff_token")); Assert.False(deps.EventStore.TryGet(PlayerId, EncounterId, out _)); } [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.EventStore, deps.ItemRegistry, deps.InventoryStore, deps.QuestRegistry, deps.QuestProgressStore, TimeProvider.System); // Assert Assert.False(result.Success); Assert.Equal(EncounterCompletionReasonCodes.UnknownEncounter, result.ReasonCode); Assert.False(deps.EventStore.TryGet(PlayerId, "unknown_encounter", out _)); } 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(), 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, IEncounterCompleteEventStore EventStore, IItemDefinitionRegistry ItemRegistry, IPlayerInventoryStore InventoryStore, IQuestDefinitionRegistry QuestRegistry, IPlayerQuestStateStore QuestProgressStore); 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); } /// /// After the first successful inventory mutation, fills remaining bag slots so the next apply fails /// while pre-flight simulate (on an empty snapshot) still passes. /// private sealed class InventoryStoreFullAfterFirstGrant( IPlayerInventoryStore inner, IItemDefinitionRegistry itemRegistry) : IPlayerInventoryStore { private bool _firstMutationApplied; public bool TryGetSnapshot(string playerId, out PlayerInventorySnapshot snapshot) => inner.TryGetSnapshot(playerId, out snapshot); public bool TryReplaceSnapshot(string playerId, PlayerInventorySnapshot snapshot) => inner.TryReplaceSnapshot(playerId, snapshot); public bool TryMutateSnapshot( string playerId, Func mutator, out PlayerInventorySnapshot result) { if (!inner.TryMutateSnapshot(playerId, mutator, out result)) { return false; } if (!_firstMutationApplied) { _firstMutationApplied = true; FillRemainingBagSlots(playerId); inner.TryGetSnapshot(playerId, out result); } return true; } private void FillRemainingBagSlots(string playerId) { for (var i = 0; i < PlayerInventorySnapshot.BagSlotCount; i++) { var add = PlayerInventoryOperations.TryAddStack( playerId, "survey_drone_kit", quantity: 1, itemRegistry, inner); if (add.Kind != PlayerInventoryMutationKind.Applied) { break; } } } } }