using Microsoft.Extensions.DependencyInjection; using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Factions; 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 const string GridOperatorsFactionId = PrototypeE7M3QuestFactionRules.GridContractGateFactionId; private const string RustCollectiveFactionId = "prototype_faction_rust_collective"; private const string SalvageScrapEfficiencyPerk = "salvage_scrap_efficiency_1"; 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.Empty(first.GrantedReputation); 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 = PrototypeE7M3QuestFactionRules.ExpectedCompletionBundles[OperatorChainQuestId]; 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); Assert.Single(result.GrantedReputation); Assert.Equal(GridOperatorsFactionId, result.GrantedReputation[0].FactionId); Assert.Equal(15, result.GrantedReputation[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 _)); Assert.Equal( PrototypeE7M3QuestFactionRules.GridContractMinStanding, deps.StandingStore.TryGetStanding(PlayerId, GridOperatorsFactionId).Standing); var idempotencyKey = RewardDeliveryIds.MakeIdempotencyKey(PlayerId, OperatorChainQuestId); var expectedDeltaId = RewardDeliveryIds.MakeReputationDeltaId(idempotencyKey, GridOperatorsFactionId); var auditRow = Assert.Single( deps.AuditStore.GetDeltasForPlayer(PlayerId), row => row.Id == expectedDeltaId); Assert.Equal(GridOperatorsFactionId, auditRow.FactionId); Assert.Equal(15, auditRow.DeltaAmount); Assert.Equal(ReputationDeltaSourceKinds.QuestCompletion, auditRow.SourceKind); Assert.Equal(OperatorChainQuestId, auditRow.SourceId); } [Fact] public async Task TryDeliverQuestCompletion_ShouldNotDoubleApplyReputation_WhenAlreadyDelivered() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); var bundle = PrototypeE7M3QuestFactionRules.ExpectedCompletionBundles[OperatorChainQuestId]; var standingBefore = deps.StandingStore.TryGetStanding(PlayerId, GridOperatorsFactionId).Standing; // Act var first = Deliver(deps, OperatorChainQuestId, bundle, TimeProvider.System); var second = Deliver(deps, OperatorChainQuestId, bundle, TimeProvider.System); // Assert Assert.True(first.Success); Assert.Single(first.GrantedReputation); Assert.Equal(15, first.GrantedReputation[0].Amount); Assert.True(second.Success); Assert.Equal(RewardDeliveryReasonCodes.AlreadyDelivered, second.ReasonCode); Assert.Equal( standingBefore + 15, deps.StandingStore.TryGetStanding(PlayerId, GridOperatorsFactionId).Standing); } [Fact] public async Task TryDeliverQuestCompletion_ShouldDenyUnknownFaction_WithoutStoreRecord() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); var bundle = new QuestRewardBundleRow( [], [], [new ReputationGrantRow("prototype_faction_unknown", 10)]); // Act var result = Deliver(deps, GatherQuestId, bundle, TimeProvider.System); // Assert Assert.False(result.Success); Assert.Equal(RewardDeliveryReasonCodes.UnknownFaction, result.ReasonCode); Assert.False(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out _)); Assert.Equal(0, deps.StandingStore.TryGetStanding(PlayerId, "prototype_faction_unknown").Standing); } [Fact] public async Task TryDeliverQuestCompletion_ShouldRollbackItemsAndSkillXp_WhenReputationApplyDenied() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory); var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId); var salvageBefore = xpBefore.TryGetValue("salvage", out var beforeSalvage) ? beforeSalvage : 0; var bundle = new QuestRewardBundleRow( [new RewardGrantRow("survey_drone_kit", 1)], [new QuestSkillXpGrantRow("salvage", 50)], [new ReputationGrantRow("prototype_faction_unknown", 10)]); // Act var result = Deliver(deps, OperatorChainQuestId, bundle, TimeProvider.System); // Assert Assert.False(result.Success); Assert.Equal(RewardDeliveryReasonCodes.UnknownFaction, result.ReasonCode); deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory); Assert.Equal( CountBagItem(beforeInventory!, "survey_drone_kit"), CountBagItem(afterInventory!, "survey_drone_kit")); var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId); var salvageAfter = xpAfter.TryGetValue("salvage", out var afterSalvage) ? afterSalvage : 0; Assert.Equal(salvageBefore, salvageAfter); Assert.False(deps.DeliveryStore.TryGet(PlayerId, OperatorChainQuestId, out _)); } [Fact] public async Task TryDeliverQuestCompletion_ShouldRollbackAllGrants_WhenSecondReputationAuditAppendFails() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); var auditStore = new AuditStoreFailingOnSecondForwardRepAppend(); deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory); var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId); var salvageBefore = xpBefore.TryGetValue("salvage", out var beforeSalvage) ? beforeSalvage : 0; var gridStandingBefore = deps.StandingStore.TryGetStanding(PlayerId, GridOperatorsFactionId).Standing; var rustStandingBefore = deps.StandingStore.TryGetStanding(PlayerId, RustCollectiveFactionId).Standing; var bundle = new QuestRewardBundleRow( [new RewardGrantRow("survey_drone_kit", 1)], [new QuestSkillXpGrantRow("salvage", 50)], [ new ReputationGrantRow(GridOperatorsFactionId, 15), new ReputationGrantRow(RustCollectiveFactionId, 10), ]); // Act var result = RewardRouterOperations.TryDeliverQuestCompletion( PlayerId, OperatorChainQuestId, bundle, deps.ItemRegistry, deps.InventoryStore, deps.SkillRegistry, deps.SkillProgressionStore, deps.LevelCurve, deps.PerkUnlockEngine, deps.PerkStore, deps.DeliveryStore, deps.StandingStore, auditStore, TimeProvider.System); // Assert Assert.False(result.Success); Assert.Equal(RewardDeliveryReasonCodes.AuditAppendFailed, result.ReasonCode); deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory); Assert.Equal( CountBagItem(beforeInventory!, "survey_drone_kit"), CountBagItem(afterInventory!, "survey_drone_kit")); var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId); var salvageAfter = xpAfter.TryGetValue("salvage", out var afterSalvage) ? afterSalvage : 0; Assert.Equal(salvageBefore, salvageAfter); Assert.Equal(gridStandingBefore, deps.StandingStore.TryGetStanding(PlayerId, GridOperatorsFactionId).Standing); Assert.Equal(rustStandingBefore, deps.StandingStore.TryGetStanding(PlayerId, RustCollectiveFactionId).Standing); Assert.False(deps.DeliveryStore.TryGet(PlayerId, OperatorChainQuestId, out _)); Assert.Equal(2, auditStore.GetDeltasForPlayer(PlayerId).Count); } [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.Empty(result.GrantedReputation); 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.Empty(result.GrantedReputation); Assert.NotNull(result.DeliveryEvent); Assert.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out var stored)); Assert.Empty(stored.GrantedItems); Assert.Empty(stored.GrantedSkillXp); Assert.Empty(stored.GrantedReputation); 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.PerkStore, deps.DeliveryStore, deps.StandingStore, deps.AuditStore, 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.PerkStore, deps.DeliveryStore, deps.StandingStore, deps.AuditStore, TimeProvider.System); // Assert Assert.False(result.Success); Assert.Equal(RewardDeliveryReasonCodes.InvalidQuestId, result.ReasonCode); Assert.False(deps.DeliveryStore.TryGet(PlayerId, " ", out _)); } [Fact] public async Task TryDeliverQuestCompletion_ShouldRollbackGrants_WhenTryRecordLosesRace() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); var winnerEvent = new RewardDeliveryEvent( PlayerId, GatherQuestId, DeliveredAt, [], [new RewardSkillXpGrantApplied("salvage", 25)], [], RewardDeliveryIds.MakeIdempotencyKey(PlayerId, GatherQuestId)); var store = new DeliveryStoreSimulatingRecordRaceLoss(winnerEvent); var bundle = new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("salvage", 25)]); var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId); var salvageBefore = xpBefore.TryGetValue("salvage", out var beforeSalvage) ? beforeSalvage : 0; // Act var result = RewardRouterOperations.TryDeliverQuestCompletion( PlayerId, GatherQuestId, bundle, deps.ItemRegistry, deps.InventoryStore, deps.SkillRegistry, deps.SkillProgressionStore, deps.LevelCurve, deps.PerkUnlockEngine, deps.PerkStore, store, deps.StandingStore, deps.AuditStore, TimeProvider.System); // Assert Assert.True(result.Success); Assert.Equal(RewardDeliveryReasonCodes.AlreadyDelivered, result.ReasonCode); Assert.Equal(winnerEvent.IdempotencyKey, result.DeliveryEvent!.Value.IdempotencyKey); var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId); var salvageAfter = xpAfter.TryGetValue("salvage", out var afterSalvage) ? afterSalvage : 0; Assert.Equal(salvageBefore, salvageAfter); } [Fact] public async Task TryDeliverQuestCompletion_ShouldRollbackReputation_WhenTryRecordLosesRace() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); var bundle = PrototypeE7M3QuestFactionRules.ExpectedCompletionBundles[OperatorChainQuestId]; var standingBefore = deps.StandingStore.TryGetStanding(PlayerId, GridOperatorsFactionId).Standing; var winnerEvent = new RewardDeliveryEvent( PlayerId, OperatorChainQuestId, DeliveredAt, [new RewardItemGrantApplied("survey_drone_kit", 1)], [new RewardSkillXpGrantApplied("salvage", 50)], [new RewardReputationGrantApplied(GridOperatorsFactionId, 15)], RewardDeliveryIds.MakeIdempotencyKey(PlayerId, OperatorChainQuestId)); var store = new DeliveryStoreSimulatingRecordRaceLoss(winnerEvent); // Act var result = RewardRouterOperations.TryDeliverQuestCompletion( PlayerId, OperatorChainQuestId, bundle, deps.ItemRegistry, deps.InventoryStore, deps.SkillRegistry, deps.SkillProgressionStore, deps.LevelCurve, deps.PerkUnlockEngine, deps.PerkStore, store, deps.StandingStore, deps.AuditStore, TimeProvider.System); // Assert Assert.True(result.Success); Assert.Equal(RewardDeliveryReasonCodes.AlreadyDelivered, result.ReasonCode); Assert.Equal(standingBefore, deps.StandingStore.TryGetStanding(PlayerId, GridOperatorsFactionId).Standing); } [Fact] public async Task TryDeliverQuestCompletion_ShouldRestorePerkState_WhenTryRecordLosesRaceAfterLevelUp() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); Assert.True(deps.SkillProgressionStore.TrySetSkillXpTotal(PlayerId, "salvage", 425)); var pick = deps.PerkUnlockEngine.TrySelectBranch(PlayerId, "salvage", 1, "scrap_efficiency"); Assert.True(pick.Success); var perkBefore = deps.PerkStore.GetSnapshot(PlayerId); Assert.DoesNotContain(SalvageScrapEfficiencyPerk, perkBefore.UnlockedPerkIds); var winnerEvent = new RewardDeliveryEvent( PlayerId, GatherQuestId, DeliveredAt, [], [new RewardSkillXpGrantApplied("salvage", 25)], [], RewardDeliveryIds.MakeIdempotencyKey(PlayerId, GatherQuestId)); var store = new DeliveryStoreSimulatingRecordRaceLoss(winnerEvent); var bundle = new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("salvage", 25)]); // Act var result = RewardRouterOperations.TryDeliverQuestCompletion( PlayerId, GatherQuestId, bundle, deps.ItemRegistry, deps.InventoryStore, deps.SkillRegistry, deps.SkillProgressionStore, deps.LevelCurve, deps.PerkUnlockEngine, deps.PerkStore, store, deps.StandingStore, deps.AuditStore, TimeProvider.System); // Assert Assert.True(result.Success); Assert.Equal(RewardDeliveryReasonCodes.AlreadyDelivered, result.ReasonCode); var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId); Assert.True(xpAfter.TryGetValue("salvage", out var salvageAfter)); Assert.Equal(425, salvageAfter); var perkAfter = deps.PerkStore.GetSnapshot(PlayerId); Assert.DoesNotContain(SalvageScrapEfficiencyPerk, perkAfter.UnlockedPerkIds); Assert.Equal(perkBefore.UnlockedPerkIds, perkAfter.UnlockedPerkIds); } [Fact] public async Task TryDeliverQuestCompletion_ShouldNotDoubleGrant_WhenConcurrentRecordRaces() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); var bundle = new QuestRewardBundleRow( [new RewardGrantRow("survey_drone_kit", 1)], [new QuestSkillXpGrantRow("salvage", 25)]); deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventoryBefore); var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId); var salvageBefore = xpBefore.TryGetValue("salvage", out var beforeSalvage) ? beforeSalvage : 0; var kitBefore = CountBagItem(inventoryBefore!, "survey_drone_kit"); var barrier = new Barrier(2); RewardDeliveryResult result1 = default; RewardDeliveryResult result2 = default; // Act await Task.WhenAll( Task.Run(() => { barrier.SignalAndWait(); result1 = Deliver(deps, OperatorChainQuestId, bundle, TimeProvider.System); }), Task.Run(() => { barrier.SignalAndWait(); result2 = Deliver(deps, OperatorChainQuestId, bundle, TimeProvider.System); })); // Assert Assert.True(result1.Success); Assert.True(result2.Success); deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventoryAfter); var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId); Assert.True(xpAfter.TryGetValue("salvage", out var salvageAfter)); Assert.Equal(salvageBefore + 25, salvageAfter); Assert.Equal(kitBefore + 1, CountBagItem(inventoryAfter!, "survey_drone_kit")); Assert.True(deps.DeliveryStore.TryGet(PlayerId, OperatorChainQuestId, out _)); } [Fact] public async Task TryDeliverQuestCompletion_ShouldPreserveWinnerPerks_WhenConcurrentRecordRacesAfterLevelUp() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); Assert.True(deps.SkillProgressionStore.TrySetSkillXpTotal(PlayerId, "salvage", 425)); Assert.True(deps.PerkUnlockEngine.TrySelectBranch(PlayerId, "salvage", 1, "scrap_efficiency").Success); var bundle = new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("salvage", 25)]); var barrier = new Barrier(2); RewardDeliveryResult result1 = default; RewardDeliveryResult result2 = default; // Act await Task.WhenAll( Task.Run(() => { barrier.SignalAndWait(); result1 = Deliver(deps, GatherQuestId, bundle, TimeProvider.System); }), Task.Run(() => { barrier.SignalAndWait(); result2 = Deliver(deps, GatherQuestId, bundle, TimeProvider.System); })); // Assert Assert.True(result1.Success); Assert.True(result2.Success); var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId); Assert.True(xpAfter.TryGetValue("salvage", out var salvageAfter)); Assert.Equal(450, salvageAfter); var perkAfter = deps.PerkStore.GetSnapshot(PlayerId); Assert.Contains(SalvageScrapEfficiencyPerk, perkAfter.UnlockedPerkIds); Assert.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, 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.PerkStore, deps.DeliveryStore, deps.StandingStore, deps.AuditStore, 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(), 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, IPlayerPerkStateStore PerkStore, IRewardDeliveryStore DeliveryStore, IFactionStandingStore StandingStore, IReputationDeltaStore AuditStore); /// TryGet misses until after a failed — simulates concurrent race loser. private sealed class DeliveryStoreSimulatingRecordRaceLoss(RewardDeliveryEvent winnerEvent) : IRewardDeliveryStore { private bool recordAttempted; public bool TryRecord(RewardDeliveryEvent deliveryEvent) { recordAttempted = true; return false; } public bool TryGet(string playerId, string questId, out RewardDeliveryEvent deliveryEvent) { if (!recordAttempted) { deliveryEvent = default; return false; } deliveryEvent = winnerEvent; return true; } public bool TryClear(string playerId, string questId) => true; } /// Second forward rep audit append fails; rollback appends still succeed. private sealed class AuditStoreFailingOnSecondForwardRepAppend : IReputationDeltaStore { private int forwardRepAppendCount; private readonly List rows = []; public bool TryAppend(ReputationDeltaRow row) { if (row.Id.EndsWith(":rollback", StringComparison.Ordinal)) { rows.Add(row); return true; } if (row.Id.Contains(":rep:", StringComparison.Ordinal)) { forwardRepAppendCount++; if (forwardRepAppendCount >= 2) { return false; } } rows.Add(row); return true; } public IReadOnlyList GetDeltasForPlayer(string playerId, int? limit = null) { var normalized = FactionStandingIds.NormalizePlayerId(playerId); var filtered = rows.Where(r => r.PlayerId == normalized).ToList(); if (limit is > 0) { return [.. filtered.Take(limit.Value)]; } return filtered; } public bool TryClearQuestCompletionAudit(string playerId, string questId) => true; } }