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); } }