using Microsoft.Extensions.DependencyInjection; 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.Rewards; public sealed class RewardRouterOperationsTests { private const string PlayerId = "dev-local-1"; private const string GatherQuestId = "prototype_quest_gather_intro"; private const string OperatorChainQuestId = "prototype_quest_operator_chain"; private static readonly DateTimeOffset DeliveredAt = new(2026, 6, 7, 14, 0, 0, TimeSpan.Zero); [Fact] public async Task TryDeliverQuestCompletion_ShouldApplyGatherIntroSkillXp_WhenBundleIsSkillOnly() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); var bundle = new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("salvage", 25)]); var timeProvider = TimeProvider.System; var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId); // Act var first = Deliver(deps, GatherQuestId, bundle, timeProvider); var second = Deliver(deps, GatherQuestId, bundle, timeProvider); // Assert Assert.True(first.Success); Assert.Null(first.ReasonCode); Assert.Empty(first.GrantedItems); Assert.Single(first.GrantedSkillXp); Assert.Equal("salvage", first.GrantedSkillXp[0].SkillId); Assert.Equal(25, first.GrantedSkillXp[0].Amount); Assert.NotNull(first.DeliveryEvent); Assert.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out var stored)); Assert.Equal(first.DeliveryEvent!.Value.IdempotencyKey, stored.IdempotencyKey); var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId); var salvageBefore = xpBefore.TryGetValue("salvage", out var beforeSalvage) ? beforeSalvage : 0; Assert.True(xpAfter.TryGetValue("salvage", out var salvageXp)); Assert.Equal(salvageBefore + 25, salvageXp); Assert.True(second.Success); Assert.Equal(RewardDeliveryReasonCodes.AlreadyDelivered, second.ReasonCode); var xpAfterReplay = deps.SkillProgressionStore.GetXpTotals(PlayerId); Assert.True(xpAfterReplay.TryGetValue("salvage", out var salvageAfterReplay)); Assert.Equal(salvageBefore + 25, salvageAfterReplay); } [Fact] public async Task TryDeliverQuestCompletion_ShouldApplyItemAndSkillXp_WhenOperatorChainBundle() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); var bundle = new QuestRewardBundleRow( [new RewardGrantRow("survey_drone_kit", 1)], [new QuestSkillXpGrantRow("salvage", 50)]); var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId); // Act var result = Deliver(deps, OperatorChainQuestId, bundle, TimeProvider.System); // Assert Assert.True(result.Success); Assert.Null(result.ReasonCode); Assert.Single(result.GrantedItems); Assert.Equal("survey_drone_kit", result.GrantedItems[0].ItemId); Assert.Equal(1, result.GrantedItems[0].Quantity); Assert.Single(result.GrantedSkillXp); Assert.Equal("salvage", result.GrantedSkillXp[0].SkillId); Assert.Equal(50, result.GrantedSkillXp[0].Amount); deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory); Assert.Equal(1, CountBagItem(inventory!, "survey_drone_kit")); var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId); var salvageBefore = xpBefore.TryGetValue("salvage", out var beforeSalvage) ? beforeSalvage : 0; Assert.True(xpAfter.TryGetValue("salvage", out var salvageXp)); Assert.Equal(salvageBefore + 50, salvageXp); Assert.True(deps.DeliveryStore.TryGet(PlayerId, OperatorChainQuestId, out _)); } [Fact] public async Task TryDeliverQuestCompletion_ShouldDenyInventoryFull_WithoutStoreRecord() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); FillBag(deps); var bundle = new QuestRewardBundleRow( [new RewardGrantRow("survey_drone_kit", 1)], [new QuestSkillXpGrantRow("salvage", 50)]); // Act var result = Deliver(deps, OperatorChainQuestId, bundle, TimeProvider.System); // Assert Assert.False(result.Success); Assert.Equal(RewardDeliveryReasonCodes.InventoryFull, result.ReasonCode); Assert.Empty(result.GrantedItems); Assert.Empty(result.GrantedSkillXp); Assert.Null(result.DeliveryEvent); Assert.False(deps.DeliveryStore.TryGet(PlayerId, OperatorChainQuestId, out _)); } [Fact] public async Task TryDeliverQuestCompletion_ShouldRollbackItems_WhenSkillApplyDenied() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory); var bundle = new QuestRewardBundleRow( [new RewardGrantRow("survey_drone_kit", 1)], [new QuestSkillXpGrantRow("salvage", 0)]); // Act var result = Deliver(deps, OperatorChainQuestId, bundle, TimeProvider.System); // Assert Assert.False(result.Success); Assert.Equal(RewardDeliveryReasonCodes.InvalidAmount, result.ReasonCode); deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory); Assert.Equal( CountBagItem(beforeInventory!, "survey_drone_kit"), CountBagItem(afterInventory!, "survey_drone_kit")); Assert.False(deps.DeliveryStore.TryGet(PlayerId, OperatorChainQuestId, out _)); } [Fact] public async Task TryDeliverQuestCompletion_ShouldReturnExistingEvent_WhenAlreadyDelivered() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); var existingEvent = new RewardDeliveryEvent( PlayerId, OperatorChainQuestId, DeliveredAt, [new RewardItemGrantApplied("survey_drone_kit", 1)], [new RewardSkillXpGrantApplied("salvage", 50)], RewardDeliveryIds.MakeIdempotencyKey(PlayerId, OperatorChainQuestId)); Assert.True(deps.DeliveryStore.TryRecord(existingEvent)); deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory); var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId); var bundle = new QuestRewardBundleRow( [new RewardGrantRow("survey_drone_kit", 1)], [new QuestSkillXpGrantRow("salvage", 50)]); // Act var result = Deliver(deps, OperatorChainQuestId, bundle, TimeProvider.System); // Assert Assert.True(result.Success); Assert.Equal(RewardDeliveryReasonCodes.AlreadyDelivered, result.ReasonCode); Assert.Equal(existingEvent.DeliveredAt, result.DeliveredAt); Assert.Equal(existingEvent.IdempotencyKey, result.DeliveryEvent!.Value.IdempotencyKey); deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory); Assert.Equal( CountBagItem(beforeInventory!, "survey_drone_kit"), CountBagItem(afterInventory!, "survey_drone_kit")); Assert.Equal(xpBefore, deps.SkillProgressionStore.GetXpTotals(PlayerId)); } [Fact] public async Task TryDeliverQuestCompletion_ShouldUseMissionRewardSourceKind_WhenCatalogAllowsIt() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); var bundle = new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("refine", 25)]); // Act var result = Deliver(deps, "prototype_quest_refine_intro", bundle, TimeProvider.System); // Assert Assert.True(result.Success); Assert.Null(result.ReasonCode); var totals = deps.SkillProgressionStore.GetXpTotals(PlayerId); Assert.True(totals.TryGetValue("refine", out var refineXp)); Assert.Equal(25, refineXp); } [Fact] public async Task TryDeliverQuestCompletion_ShouldDenyUnknownSkill_WhenSkillIdIsInvalid() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); var bundle = new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("unknown_skill_id", 25)]); // Act var result = Deliver(deps, GatherQuestId, bundle, TimeProvider.System); // Assert Assert.False(result.Success); Assert.Equal(RewardDeliveryReasonCodes.UnknownSkill, result.ReasonCode); Assert.False(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out _)); } [Fact] public async Task TryDeliverQuestCompletion_ShouldRecordEmptyDelivery_WhenBundleIsNull() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId); deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventoryBefore); // Act var result = Deliver(deps, GatherQuestId, bundle: null, TimeProvider.System); // Assert Assert.True(result.Success); Assert.Null(result.ReasonCode); Assert.Empty(result.GrantedItems); Assert.Empty(result.GrantedSkillXp); Assert.NotNull(result.DeliveryEvent); Assert.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out var stored)); Assert.Empty(stored.GrantedItems); Assert.Empty(stored.GrantedSkillXp); Assert.Equal(xpBefore, deps.SkillProgressionStore.GetXpTotals(PlayerId)); deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventoryAfter); Assert.Equal(TotalBagQuantity(inventoryBefore!), TotalBagQuantity(inventoryAfter!)); } [Fact] public async Task TryDeliverQuestCompletion_ShouldDenyInvalidPlayerId_WhenPlayerIdIsEmpty() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); var bundle = new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("salvage", 25)]); // Act var result = RewardRouterOperations.TryDeliverQuestCompletion( " ", GatherQuestId, bundle, deps.ItemRegistry, deps.InventoryStore, deps.SkillRegistry, deps.SkillProgressionStore, deps.LevelCurve, deps.PerkUnlockEngine, deps.DeliveryStore, TimeProvider.System); // Assert Assert.False(result.Success); Assert.Equal(RewardDeliveryReasonCodes.InvalidPlayerId, result.ReasonCode); Assert.False(deps.DeliveryStore.TryGet(" ", GatherQuestId, out _)); } [Fact] public async Task TryDeliverQuestCompletion_ShouldDenyInvalidQuestId_WhenQuestIdIsEmpty() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); var bundle = new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("salvage", 25)]); // Act var result = RewardRouterOperations.TryDeliverQuestCompletion( PlayerId, " ", bundle, deps.ItemRegistry, deps.InventoryStore, deps.SkillRegistry, deps.SkillProgressionStore, deps.LevelCurve, deps.PerkUnlockEngine, deps.DeliveryStore, TimeProvider.System); // Assert Assert.False(result.Success); Assert.Equal(RewardDeliveryReasonCodes.InvalidQuestId, result.ReasonCode); Assert.False(deps.DeliveryStore.TryGet(PlayerId, " ", out _)); } private static RewardDeliveryResult Deliver( RewardRouterTestDependencies deps, string questId, QuestRewardBundleRow? bundle, TimeProvider timeProvider) => RewardRouterOperations.TryDeliverQuestCompletion( PlayerId, questId, bundle, deps.ItemRegistry, deps.InventoryStore, deps.SkillRegistry, deps.SkillProgressionStore, deps.LevelCurve, deps.PerkUnlockEngine, deps.DeliveryStore, timeProvider); private static void FillBag(RewardRouterTestDependencies 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 RewardRouterTestDependencies ResolveDependencies(InMemoryWebApplicationFactory factory) { var services = factory.Services; return new RewardRouterTestDependencies( 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 RewardRouterTestDependencies( IItemDefinitionRegistry ItemRegistry, IPlayerInventoryStore InventoryStore, ISkillDefinitionRegistry SkillRegistry, IPlayerSkillProgressionStore SkillProgressionStore, ISkillLevelCurve LevelCurve, PerkUnlockEngine PerkUnlockEngine, IRewardDeliveryStore DeliveryStore); }