using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Quests; using NeonSprawl.Server.Game.Skills; namespace NeonSprawl.Server.Game.Rewards; /// /// Applies quest completion grants idempotently (NEO-127). /// Quest-state wiring: (NEO-128). /// public static class RewardRouterOperations { private static readonly RewardItemGrantApplied[] EmptyItemGrants = []; private static readonly RewardSkillXpGrantApplied[] EmptySkillXpGrants = []; /// /// Applies item + skill XP rows for a quest completion and records /// when grants succeed. /// public static RewardDeliveryResult TryDeliverQuestCompletion( string playerId, string questId, QuestRewardBundleRow? bundle, IItemDefinitionRegistry itemRegistry, IPlayerInventoryStore inventoryStore, ISkillDefinitionRegistry skillRegistry, IPlayerSkillProgressionStore skillProgressionStore, ISkillLevelCurve levelCurve, PerkUnlockEngine perkUnlockEngine, IPlayerPerkStateStore perkStore, IRewardDeliveryStore deliveryStore, TimeProvider timeProvider) { var normalizedPlayerId = RewardDeliveryIds.NormalizePlayerId(playerId); if (normalizedPlayerId.Length == 0) { return Deny(RewardDeliveryReasonCodes.InvalidPlayerId); } var normalizedQuestId = RewardDeliveryIds.NormalizeQuestId(questId); if (normalizedQuestId.Length == 0) { return Deny(RewardDeliveryReasonCodes.InvalidQuestId); } if (deliveryStore.TryGet(normalizedPlayerId, normalizedQuestId, out var existingEvent)) { return SuccessFromEvent(existingEvent, RewardDeliveryReasonCodes.AlreadyDelivered); } var itemGrants = bundle?.ItemGrants ?? Array.Empty(); var skillGrants = bundle?.SkillXpGrants ?? Array.Empty(); if (itemGrants.Count > 0) { if (!inventoryStore.TryGetSnapshot(playerId, out var snapshot)) { return Deny(RewardDeliveryReasonCodes.InventoryStoreMissing); } var simulated = snapshot.Clone(); foreach (var grant in itemGrants) { if (!PlayerInventoryOperations.TrySimulateAddStack( ref simulated, grant.ItemId, grant.Quantity, itemRegistry)) { return Deny(RewardDeliveryReasonCodes.InventoryFull); } } } var appliedItems = new List(itemGrants.Count); foreach (var grant in itemGrants) { var addOutcome = PlayerInventoryOperations.TryAddStack( playerId, grant.ItemId, grant.Quantity, itemRegistry, inventoryStore); if (addOutcome.Kind == PlayerInventoryMutationKind.StoreMissing) { CompensatingRemoveItems(playerId, appliedItems, itemRegistry, inventoryStore); return Deny(RewardDeliveryReasonCodes.InventoryStoreMissing); } if (addOutcome.Kind == PlayerInventoryMutationKind.Denied) { CompensatingRemoveItems(playerId, appliedItems, itemRegistry, inventoryStore); return Deny(MapInventoryReason(addOutcome.ReasonCode)); } appliedItems.Add(new RewardItemGrantApplied(grant.ItemId, grant.Quantity)); } var perksUnlockedByThisCall = new HashSet(StringComparer.Ordinal); var appliedSkillXp = new List(skillGrants.Count); foreach (var grant in skillGrants) { var perkBeforeGrant = perkStore.GetSnapshot(playerId); var skillOutcome = SkillProgressionGrantOperations.TryApplyGrant( playerId, grant.SkillId, grant.Amount, MissionRewardSkillXpConstants.MissionRewardSourceKind, skillRegistry, skillProgressionStore, levelCurve, perkUnlockEngine); if (skillOutcome.Kind == SkillProgressionGrantApplyKind.ProgressionStoreMissing) { CompensatingRemoveItems(playerId, appliedItems, itemRegistry, inventoryStore); return Deny(RewardDeliveryReasonCodes.ProgressionStoreMissing); } if (skillOutcome.Kind == SkillProgressionGrantApplyKind.Denied) { CompensatingRemoveItems(playerId, appliedItems, itemRegistry, inventoryStore); var reasonCode = skillOutcome.Response?.ReasonCode ?? RewardDeliveryReasonCodes.UnknownSkill; return Deny(reasonCode); } appliedSkillXp.Add(new RewardSkillXpGrantApplied(grant.SkillId, grant.Amount)); SkillProgressionGrantOperations.CollectPerksUnlockedSince( perkBeforeGrant, perkStore.GetSnapshot(playerId), perksUnlockedByThisCall); } var deliveredAt = timeProvider.GetUtcNow(); var deliveryEvent = new RewardDeliveryEvent( normalizedPlayerId, normalizedQuestId, deliveredAt, appliedItems, appliedSkillXp, RewardDeliveryIds.MakeIdempotencyKey(normalizedPlayerId, normalizedQuestId)); if (deliveryStore.TryRecord(deliveryEvent)) { return SuccessFromEvent(deliveryEvent, reasonCode: null); } // Lost TryRecord race after apply — rollback this call's grants (mirror // EncounterCompletionOperations when TryMarkCompleted returns false). CompensatingRemoveItems(playerId, appliedItems, itemRegistry, inventoryStore); _ = SkillProgressionGrantOperations.TryRevertAppliedSkillGrants( playerId, appliedSkillXp, perksUnlockedByThisCall, skillProgressionStore, perkStore); ResyncPathAutoPerksAfterRaceRollback( playerId, appliedSkillXp, skillProgressionStore, levelCurve, perkUnlockEngine); if (deliveryStore.TryGet(normalizedPlayerId, normalizedQuestId, out var racedEvent)) { return SuccessFromEvent(racedEvent, RewardDeliveryReasonCodes.AlreadyDelivered); } return Deny(RewardDeliveryReasonCodes.AlreadyDelivered); } private static void ResyncPathAutoPerksAfterRaceRollback( string playerId, IReadOnlyList appliedSkillXp, IPlayerSkillProgressionStore skillProgressionStore, ISkillLevelCurve levelCurve, PerkUnlockEngine perkUnlockEngine) { if (appliedSkillXp.Count == 0) { return; } var xpBySkill = skillProgressionStore.GetXpTotals(playerId); foreach (var grant in appliedSkillXp) { xpBySkill.TryGetValue(grant.SkillId, out var totalXp); var level = levelCurve.LevelFromTotalXp(totalXp); _ = perkUnlockEngine.ReevaluateAfterLevelUp(playerId, grant.SkillId, level); } } private static string MapInventoryReason(string? reasonCode) => reasonCode switch { PlayerInventoryReasonCodes.InventoryFull => RewardDeliveryReasonCodes.InventoryFull, PlayerInventoryReasonCodes.InvalidItem => RewardDeliveryReasonCodes.InvalidItem, PlayerInventoryReasonCodes.InvalidQuantity => RewardDeliveryReasonCodes.InvalidQuantity, null => RewardDeliveryReasonCodes.InventoryFull, _ => reasonCode, }; private static void CompensatingRemoveItems( string playerId, IReadOnlyList grants, IItemDefinitionRegistry itemRegistry, IPlayerInventoryStore inventoryStore) { for (var i = grants.Count - 1; i >= 0; i--) { var grant = grants[i]; _ = PlayerInventoryOperations.TryRemoveStack( playerId, grant.ItemId, grant.Quantity, itemRegistry, inventoryStore); } } private static RewardDeliveryResult Deny(string reasonCode) => new( Success: false, ReasonCode: reasonCode, GrantedItems: EmptyItemGrants, GrantedSkillXp: EmptySkillXpGrants, DeliveryEvent: null, DeliveredAt: null); private static RewardDeliveryResult SuccessFromEvent(RewardDeliveryEvent deliveryEvent, string? reasonCode) => new( Success: true, ReasonCode: reasonCode, GrantedItems: deliveryEvent.GrantedItems, GrantedSkillXp: deliveryEvent.GrantedSkillXp, DeliveryEvent: deliveryEvent, DeliveredAt: deliveryEvent.DeliveredAt); }