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 EncounterCombatWiringTests { 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 TryProcessCastOutcome_ShouldActivateEncounter_WhenDamagingHitWithoutDefeat() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); // Act EncounterCombatWiring.TryProcessCastOutcome( PlayerId, MeleeNpc, damageDealt: 25, targetDefeated: false, deps.EncounterRegistry, deps.RewardTableRegistry, deps.ProgressStore, deps.CompletionStore, deps.ItemRegistry, deps.InventoryStore, TimeProvider.System); // Assert Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, EncounterId, out var progress)); Assert.True(progress.Started); Assert.Empty(progress.DefeatedNpcInstanceIds); Assert.False(deps.CompletionStore.IsCompleted(PlayerId, EncounterId)); } [Fact] public async Task TryProcessCastOutcome_ShouldMarkDefeatWithoutCompletion_WhenOnlyOneTargetDefeated() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); // Act EncounterCombatWiring.TryProcessCastOutcome( PlayerId, MeleeNpc, damageDealt: 25, targetDefeated: true, deps.EncounterRegistry, deps.RewardTableRegistry, deps.ProgressStore, deps.CompletionStore, deps.ItemRegistry, deps.InventoryStore, TimeProvider.System); // Assert Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, EncounterId, out var progress)); Assert.Contains(MeleeNpc, progress.DefeatedNpcInstanceIds); Assert.False(deps.CompletionStore.IsCompleted(PlayerId, EncounterId)); deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory); Assert.Equal(0, CountBagItem(inventory!, "scrap_metal_bulk")); Assert.Equal(0, CountBagItem(inventory!, "contract_handoff_token")); } [Fact] public async Task TryProcessCastOutcome_ShouldGrantLootOnce_WhenThirdTargetDefeated() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); DefeatNpc(deps, MeleeNpc); DefeatNpc(deps, RangedNpc); // Act EncounterCombatWiring.TryProcessCastOutcome( PlayerId, EliteNpc, damageDealt: 25, targetDefeated: true, deps.EncounterRegistry, deps.RewardTableRegistry, deps.ProgressStore, deps.CompletionStore, deps.ItemRegistry, deps.InventoryStore, TimeProvider.System); // Assert deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory); 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 TryProcessCastOutcome_ShouldNoOp_WhenNpcNotInAnyEncounter() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); // Act EncounterCombatWiring.TryProcessCastOutcome( PlayerId, "unknown_npc_instance", damageDealt: 25, targetDefeated: true, deps.EncounterRegistry, deps.RewardTableRegistry, deps.ProgressStore, deps.CompletionStore, deps.ItemRegistry, deps.InventoryStore, TimeProvider.System); // Assert Assert.False(deps.ProgressStore.TryGetProgress(PlayerId, EncounterId, out _)); Assert.False(deps.CompletionStore.IsCompleted(PlayerId, EncounterId)); } [Fact] public async Task TryProcessCastOutcome_ShouldSkipActivateAndMark_WhenEncounterAlreadyCompleted() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); DefeatAllRequiredTargets(deps); _ = EncounterCompletionOperations.TryCompleteAndGrant( PlayerId, EncounterId, deps.EncounterRegistry, deps.RewardTableRegistry, deps.ProgressStore, deps.CompletionStore, deps.ItemRegistry, deps.InventoryStore, TimeProvider.System); deps.InventoryStore.TryGetSnapshot(PlayerId, out var before); // Act EncounterCombatWiring.TryProcessCastOutcome( PlayerId, MeleeNpc, damageDealt: 25, targetDefeated: true, deps.EncounterRegistry, deps.RewardTableRegistry, deps.ProgressStore, deps.CompletionStore, deps.ItemRegistry, deps.InventoryStore, TimeProvider.System); // Assert deps.InventoryStore.TryGetSnapshot(PlayerId, out var after); Assert.Equal(CountBagItem(before!, "scrap_metal_bulk"), CountBagItem(after!, "scrap_metal_bulk")); Assert.Equal(CountBagItem(before!, "contract_handoff_token"), CountBagItem(after!, "contract_handoff_token")); } [Fact] public async Task TryProcessCastOutcome_ShouldNotComplete_WhenOnlyTwoTargetsDefeatedAndThirdHitNonLethal() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); DefeatNpc(deps, MeleeNpc); DefeatNpc(deps, RangedNpc); // Act EncounterCombatWiring.TryProcessCastOutcome( PlayerId, EliteNpc, damageDealt: 25, targetDefeated: false, deps.EncounterRegistry, deps.RewardTableRegistry, deps.ProgressStore, deps.CompletionStore, deps.ItemRegistry, deps.InventoryStore, TimeProvider.System); // Assert Assert.False( EncounterProgressOperations.IsAllRequiredTargetsDefeated( PlayerId, EncounterId, deps.EncounterRegistry, deps.ProgressStore)); Assert.False(deps.CompletionStore.IsCompleted(PlayerId, EncounterId)); deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory); Assert.Equal(0, CountBagItem(inventory!, "scrap_metal_bulk")); Assert.Equal(0, CountBagItem(inventory!, "contract_handoff_token")); } [Fact] public async Task TryProcessCastOutcome_ShouldRetainProgressWithoutCompletion_WhenBagFullOnThirdDefeat() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); DefeatNpc(deps, MeleeNpc); DefeatNpc(deps, RangedNpc); FillBag(deps); deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory); // Act EncounterCombatWiring.TryProcessCastOutcome( PlayerId, EliteNpc, damageDealt: 25, targetDefeated: true, deps.EncounterRegistry, deps.RewardTableRegistry, deps.ProgressStore, deps.CompletionStore, deps.ItemRegistry, deps.InventoryStore, TimeProvider.System); // Assert Assert.True( EncounterProgressOperations.IsAllRequiredTargetsDefeated( PlayerId, EncounterId, deps.EncounterRegistry, deps.ProgressStore)); Assert.False(deps.CompletionStore.IsCompleted(PlayerId, EncounterId)); deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory); 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 TryProcessCastOutcome_ShouldNotActivate_WhenZeroDamageWithoutDefeat() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); // Act EncounterCombatWiring.TryProcessCastOutcome( PlayerId, MeleeNpc, damageDealt: 0, targetDefeated: false, deps.EncounterRegistry, deps.RewardTableRegistry, deps.ProgressStore, deps.CompletionStore, deps.ItemRegistry, deps.InventoryStore, TimeProvider.System); // Assert Assert.False(deps.ProgressStore.TryGetProgress(PlayerId, EncounterId, out var progress) && progress.Started); } private static void DefeatNpc(EncounterCombatWiringTestDependencies deps, string npcId) { EncounterCombatWiring.TryProcessCastOutcome( PlayerId, npcId, damageDealt: 25, targetDefeated: true, deps.EncounterRegistry, deps.RewardTableRegistry, deps.ProgressStore, deps.CompletionStore, deps.ItemRegistry, deps.InventoryStore, TimeProvider.System); } private static void DefeatAllRequiredTargets(EncounterCombatWiringTestDependencies deps) { DefeatNpc(deps, MeleeNpc); DefeatNpc(deps, RangedNpc); DefeatNpc(deps, EliteNpc); } private static void FillBag(EncounterCombatWiringTestDependencies 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 int TotalBagQuantity(PlayerInventorySnapshot snapshot) { var total = 0; foreach (var slot in snapshot.BagSlots) { if (!slot.IsEmpty) { total += slot.Quantity; } } return total; } private static EncounterCombatWiringTestDependencies ResolveDependencies(InMemoryWebApplicationFactory factory) { var services = factory.Services; return new EncounterCombatWiringTestDependencies( 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 sealed record EncounterCombatWiringTestDependencies( IEncounterDefinitionRegistry EncounterRegistry, IRewardTableDefinitionRegistry RewardTableRegistry, IEncounterProgressStore ProgressStore, IEncounterCompletionStore CompletionStore, IItemDefinitionRegistry ItemRegistry, IPlayerInventoryStore InventoryStore); }