using Microsoft.Extensions.DependencyInjection; using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Quests; using NeonSprawl.Server.Game.Rewards; using NeonSprawl.Server.Game.Skills; 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, deps.SkillRegistry, deps.SkillProgressionStore, deps.LevelCurve, deps.PerkUnlockEngine, deps.PerkStore, deps.DeliveryStore, deps.StandingStore, deps.AuditStore, 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, deps.SkillRegistry, deps.SkillProgressionStore, deps.LevelCurve, deps.PerkUnlockEngine, deps.PerkStore, deps.DeliveryStore, deps.StandingStore, deps.AuditStore, 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, deps.SkillRegistry, deps.SkillProgressionStore, deps.LevelCurve, deps.PerkUnlockEngine, deps.PerkStore, deps.DeliveryStore, deps.StandingStore, deps.AuditStore, 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, deps.SkillRegistry, deps.SkillProgressionStore, deps.LevelCurve, deps.PerkUnlockEngine, deps.PerkStore, deps.DeliveryStore, deps.StandingStore, deps.AuditStore, 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, deps.SkillRegistry, deps.SkillProgressionStore, deps.LevelCurve, deps.PerkUnlockEngine, deps.PerkStore, deps.DeliveryStore, deps.StandingStore, deps.AuditStore, 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, deps.SkillRegistry, deps.SkillProgressionStore, deps.LevelCurve, deps.PerkUnlockEngine, deps.PerkStore, deps.DeliveryStore, deps.StandingStore, deps.AuditStore, 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, deps.SkillRegistry, deps.SkillProgressionStore, deps.LevelCurve, deps.PerkUnlockEngine, deps.PerkStore, deps.DeliveryStore, deps.StandingStore, deps.AuditStore, 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, deps.SkillRegistry, deps.SkillProgressionStore, deps.LevelCurve, deps.PerkUnlockEngine, deps.PerkStore, deps.DeliveryStore, deps.StandingStore, deps.AuditStore, 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(), 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, ISkillDefinitionRegistry SkillRegistry, IPlayerSkillProgressionStore SkillProgressionStore, ISkillLevelCurve LevelCurve, PerkUnlockEngine PerkUnlockEngine, IPlayerPerkStateStore PerkStore, IRewardDeliveryStore DeliveryStore, IFactionStandingStore StandingStore, IReputationDeltaStore AuditStore); 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; } } } } }