using SPTarkov.DI.Annotations; using SPTarkov.Server.Core.Extensions; using SPTarkov.Server.Core.Helpers; using SPTarkov.Server.Core.Models.Common; using SPTarkov.Server.Core.Models.Eft.Common; using SPTarkov.Server.Core.Models.Eft.Common.Tables; using SPTarkov.Server.Core.Models.Eft.Insurance; using SPTarkov.Server.Core.Models.Eft.ItemEvent; using SPTarkov.Server.Core.Models.Eft.Trade; using SPTarkov.Server.Core.Models.Enums; using SPTarkov.Server.Core.Models.Spt.Config; using SPTarkov.Server.Core.Models.Utils; using SPTarkov.Server.Core.Routers; using SPTarkov.Server.Core.Servers; using SPTarkov.Server.Core.Services; using SPTarkov.Server.Core.Utils; using SPTarkov.Server.Core.Utils.Cloners; using SPTarkov.Server.Core.Utils.Collections; using Insurance = SPTarkov.Server.Core.Models.Eft.Profile.Insurance; using LogLevel = SPTarkov.Server.Core.Models.Spt.Logging.LogLevel; namespace SPTarkov.Server.Core.Controllers; [Injectable] public class InsuranceController( ISptLogger logger, RandomUtil randomUtil, TimeUtil timeUtil, EventOutputHolder eventOutputHolder, ItemHelper itemHelper, ProfileHelper profileHelper, WeightedRandomHelper weightedRandomHelper, PaymentService paymentService, InsuranceService insuranceService, DatabaseService databaseService, MailSendService mailSendService, RagfairPriceService ragfairPriceService, ServerLocalisationService serverLocalisationService, SaveServer saveServer, TraderStore traderStore, ConfigServer configServer, ICloner cloner ) { protected readonly InsuranceConfig _insuranceConfig = configServer.GetConfig(); /// /// Process insurance items of all profiles prior to being given back to the player through the mail service /// public void ProcessReturn() { // Process each installed profile. foreach (var sessionId in saveServer.GetProfiles()) { ProcessReturnByProfile(sessionId.Key); } } /// /// Process insurance items of a single profile prior to being given back to the player through the mail service /// /// Player id public void ProcessReturnByProfile(MongoId sessionId) { // Filter out items that don't need to be processed yet. var insuranceDetails = FilterInsuredItems(sessionId); // Skip profile if no insured items to process if (insuranceDetails.Count == 0) { return; } ProcessInsuredItems(insuranceDetails, sessionId); } /// /// Get all insured items that are ready to be processed in a specific profile /// /// Session/Player id /// The time to check ready status against. Current time by default /// All insured items that are ready to be processed protected List FilterInsuredItems(MongoId sessionId, long? time = null) { // Use the current time by default. var insuranceTime = time ?? timeUtil.GetTimeStamp(); var profileInsuranceDetails = saveServer.GetProfile(sessionId).InsuranceList; if (profileInsuranceDetails.Count > 0) { if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug( $"Found {profileInsuranceDetails.Count} insurance packages in profile {sessionId}" ); } } return profileInsuranceDetails .Where(insured => insuranceTime >= insured.ScheduledTime) .ToList(); } /// /// This method orchestrates the processing of insured items in a profile /// /// The insured items to process /// session ID that should receive the processed items protected void ProcessInsuredItems(List insuranceDetails, MongoId sessionId) { if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug( $"Processing {insuranceDetails.Count} insurance packages, which includes a total of: {CountAllInsuranceItems(insuranceDetails)} items, in profile: {sessionId}" ); } // Iterate over each of the insurance packages. foreach (var insured in insuranceDetails) { // Create a new root parent ID for the message we'll be sending the player var rootItemParentId = new MongoId(); // Update the insured items to have the new root parent ID for root/orphaned items insured.Items = insured.Items.AdoptOrphanedItems(rootItemParentId); var simulateItemsBeingTaken = _insuranceConfig.SimulateItemsBeingTaken; if (simulateItemsBeingTaken) { // Find items that could be taken by another player off the players body var itemsToDelete = FindItemsToDelete(rootItemParentId, insured); // Actually remove them. RemoveItemsFromInsurance(insured, itemsToDelete); // There's a chance we've orphaned weapon attachments, so adopt any orphaned items again insured.Items = insured.Items.AdoptOrphanedItems(rootItemParentId); } SendMail(sessionId, insured); // Remove the fully processed insurance package from the profile. RemoveInsurancePackageFromProfile(sessionId, insured); } } /// /// Count all items in all insurance packages /// /// /// Count of insured items protected int CountAllInsuranceItems(List insuranceDetails) { return insuranceDetails.Select(ins => ins.Items.Count).Count(); } /// /// Remove an insurance package from a profile using the package's system data information. /// /// The session ID of the profile to remove the package from. /// The array index of the insurance package to remove. protected void RemoveInsurancePackageFromProfile(MongoId sessionId, Insurance insPackage) { var profile = saveServer.GetProfile(sessionId); profile.InsuranceList = profile .InsuranceList.Where(insurance => insurance.TraderId != insPackage.TraderId || insurance.SystemData?.Date != insPackage.SystemData?.Date || insurance.SystemData?.Time != insPackage.SystemData?.Time || insurance.SystemData?.Location != insPackage.SystemData?.Location ) .ToList(); if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug( $"Removed processed insurance package. Remaining packages: {profile.InsuranceList.Count}" ); } } /// /// Finds the items that should be deleted based on the given Insurance object /// /// The ID that should be assigned to all "hideout"/root items /// The insurance object containing the items to evaluate for deletion /// A Set containing the IDs of items that should be deleted protected HashSet FindItemsToDelete(string rootItemParentId, Insurance insured) { var toDelete = new HashSet(); // Populate a Map object of items for quick lookup by their ID and use it to populate a Map of main-parent items // and each of their attachments. For example, a gun mapped to each of its attachments. var itemsMap = insured.Items.GenerateItemsMap(); var parentAttachmentsMap = PopulateParentAttachmentsMap( rootItemParentId, insured, itemsMap ); // Check to see if any regular items are present. var hasRegularItems = itemsMap.Values.Any(item => !itemHelper.IsAttachmentAttached(item)); // Process all items that are not attached, attachments; those are handled separately, by value. if (hasRegularItems) { ProcessRegularItems(insured, toDelete, parentAttachmentsMap); } // Process attached, attachments, by value, only if there are any. if (parentAttachmentsMap.Count > 0) { // Remove attachments that can not be moddable in-raid from the parentAttachmentsMap. We only want to // process moddable attachments from here on out. parentAttachmentsMap = RemoveNonModdableAttachments(parentAttachmentsMap, itemsMap); ProcessAttachments(parentAttachmentsMap, itemsMap, insured.TraderId, toDelete); } // Log the number of items marked for deletion, if any if (logger.IsLogEnabled(LogLevel.Debug)) { if (toDelete.Any()) { logger.Debug($"Marked {toDelete.Count} items for deletion from insurance."); } } return toDelete; } /// /// Initialize a dictionary that holds main-parents to all of their attachments. Note that "main-parent" in this /// context refers to the parent item that an attachment is attached to. For example, a suppressor attached to a gun, /// not the backpack that the gun is located in (the gun's parent). /// /// The ID that should be assigned to all "hideout"/root items /// The insurance object containing the items to evaluate /// A Dictionary for quick item look-up by item ID /// A dictionary containing parent item IDs to arrays of their attachment items protected Dictionary> PopulateParentAttachmentsMap( string rootItemParentID, Insurance insured, Dictionary itemsMap ) { var mainParentToAttachmentsMap = new Dictionary>(); foreach (var insuredItem in insured.Items) { // Use the parent ID from the item to get the parent item. var parentItem = insured.Items.FirstOrDefault(item => item.Id == insuredItem.ParentId); // The parent (not the hideout) could not be found. Skip and warn. if (parentItem is null && insuredItem.ParentId != rootItemParentID) { logger.Warning( serverLocalisationService.GetText( "insurance-unable_to_find_parent_of_item", new { insuredItemId = insuredItem.Id, insuredItemTpl = insuredItem.Template, parentId = insuredItem.ParentId, } ) ); continue; } // Not attached to parent, skip if (!itemHelper.IsAttachmentAttached(insuredItem)) { continue; } // Make sure the template for the item exists. if (!itemHelper.GetItem(insuredItem.Template).Key) { logger.Warning( serverLocalisationService.GetText( "insurance-unable_to_find_attachment_in_db", new { insuredItemId = insuredItem.Id, insuredItemTpl = insuredItem.Template, } ) ); continue; } // Get the main parent of this attachment. (e.g., The gun that this suppressor is attached to.) var mainParent = itemHelper.GetAttachmentMainParent(insuredItem.Id, itemsMap); if (mainParent is null) { // Odd. The parent couldn't be found. Skip this attachment and warn. logger.Warning( serverLocalisationService.GetText( "insurance-unable_to_find_main_parent_for_attachment", new { insuredItemId = insuredItem.Id, insuredItemTpl = insuredItem.Template, parentId = insuredItem.ParentId, } ) ); continue; } // Update (or add to) the main-parent to attachments map. if (mainParentToAttachmentsMap.ContainsKey(mainParent.Id)) { if (mainParentToAttachmentsMap.TryGetValue(mainParent.Id, out var parent)) { parent.Add(insuredItem); } } else { mainParentToAttachmentsMap.TryAdd(mainParent.Id, [insuredItem]); } } return mainParentToAttachmentsMap; } /// /// Remove attachments that can not be moddable in-raid from the parentAttachmentsMap. If no moddable attachments /// remain, the parent is removed from the map as well /// /// Dictionary containing parent item IDs to arrays of their attachment items /// Hashset containing parent item IDs to arrays of their attachment items which are not moddable in-raid /// protected Dictionary> RemoveNonModdableAttachments( Dictionary> parentAttachmentsMap, Dictionary itemsMap ) { var updatedMap = new Dictionary>(); foreach (var map in parentAttachmentsMap) { itemsMap.TryGetValue(map.Key, out var parentItem); List moddableAttachments = []; foreach (var attachment in map.Value) { // By default, assume the parent of the current attachment is the main-parent included in the map. var attachmentParentItem = parentItem; // If the attachment includes a parentId, use it to find its direct parent item, even if it's another // attachment on the main-parent. For example, if the attachment is a stock, we need to check to see if // it's moddable in the upper receiver (attachment/parent), which is attached to the gun (main-parent). if (attachment.ParentId is not null) { if (itemsMap.TryGetValue(attachment.ParentId, out var directParentItem)) { attachmentParentItem = directParentItem; } } if (itemHelper.IsRaidModdable(attachment, attachmentParentItem) ?? false) { moddableAttachments.Add(attachment); } } // If any moddable attachments remain, add them to the updated map. if (moddableAttachments.Count > 0) { updatedMap.TryAdd(map.Key, moddableAttachments); } } return updatedMap; } /// /// Process "regular" insurance items. Any insured item that is not an attached, attachment is considered a "regular" /// item. This method iterates over them, preforming item deletion rolls to see if they should be deleted. If so, /// they (and their attached, attachments, if any) are marked for deletion in the toDelete Dictionary /// /// Insurance object containing the items to evaluate /// Hashset to keep track of items marked for deletion /// Dictionary containing parent item IDs to arrays of their attachment items protected void ProcessRegularItems( Insurance insured, HashSet toDelete, Dictionary> parentAttachmentsMap ) { foreach (var insuredItem in insured.Items) { // Skip if the item is an attachment. These are handled separately. if (itemHelper.IsAttachmentAttached(insuredItem)) { continue; } // Roll for item deletion var itemRoll = RollForDelete(insured.TraderId, insuredItem); if (itemRoll ?? false) { // Check to see if this item is a parent in the parentAttachmentsMap. If so, do a look-up for *all* of // its children and mark them for deletion as well. Also remove parent (and its children) // from the parentAttachmentsMap so that it's children are not rolled for later in the process. if (parentAttachmentsMap.ContainsKey(insuredItem.Id)) { // This call will also return the parent item itself, queueing it for deletion as well. var itemAndChildren = insured.Items.FindAndReturnChildrenAsItems( insuredItem.Id ); foreach (var item in itemAndChildren) { toDelete.Add(item.Id); } // Remove the parent (and its children) from the parentAttachmentsMap. parentAttachmentsMap.Remove(insuredItem.Id); } else { // This item doesn't have any children. Simply mark it for deletion. toDelete.Add(insuredItem.Id); } } } } /// /// Process parent items and their attachments, updating the toDelete Set accordingly /// /// Dictionary containing parent item IDs to arrays of their attachment items /// Dictionary for quick item look-up by item ID /// Trader ID from the Insurance object /// Tracked attachment ids to be removed protected void ProcessAttachments( Dictionary> mainParentToAttachmentsMap, Dictionary itemsMap, string? insuredTraderId, HashSet toDelete ) { foreach (var parentObj in mainParentToAttachmentsMap) { // Skip processing if parentId is already marked for deletion, as all attachments for that parent will // already be marked for deletion as well. if (toDelete.Contains(parentObj.Key)) { continue; } // Log the parent item's name. itemsMap.TryGetValue(parentObj.Key, out var parentItem); var parentName = itemHelper.GetItemName(parentItem.Template); if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug($"Processing attachments of parent {parentName}"); } // Process the attachments for this individual parent item. ProcessAttachmentByParent(parentObj.Value, insuredTraderId, toDelete); } } /// /// Takes an array of attachment items that belong to the same main-parent item, sorts them in descending order by /// their maximum price. For each attachment, a roll is made to determine if a deletion should be made. Once the /// number of deletions has been counted, the attachments are added to the toDelete Set, starting with the most /// valuable attachments first /// /// Array of attachment items to sort, filter, and roll /// ID of the trader to that has ensured these items /// array that accumulates the IDs of the items to be deleted protected void ProcessAttachmentByParent( List attachments, string? traderId, HashSet toDelete ) { // Create dict of item ids + their flea/handbook price (highest is chosen) var weightedAttachmentByPrice = WeightAttachmentsByPrice(attachments); // Get how many attachments we want to pull off parent var countOfAttachmentsToRemove = GetAttachmentCountToRemove( weightedAttachmentByPrice, traderId ); // Create prob array and add all attachments with rouble price as the weight var attachmentsProbabilityArray = new ProbabilityObjectArray(cloner); foreach (var (itemTpl, price) in weightedAttachmentByPrice) { attachmentsProbabilityArray.Add( new ProbabilityObject(itemTpl, price, null) ); } // Draw x attachments from weighted array to remove from parent, remove from pool after being picked var attachmentIdsToRemove = attachmentsProbabilityArray.Draw( (int)countOfAttachmentsToRemove, false ); foreach (var attachmentId in attachmentIdsToRemove) { toDelete.Add(attachmentId); } LogAttachmentsBeingRemoved(attachmentIdsToRemove, attachments, weightedAttachmentByPrice); if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug($"Number of attachments to be deleted: {attachmentIdsToRemove.Count}"); } } /// /// Write out attachments being removed /// /// /// /// protected void LogAttachmentsBeingRemoved( List attachmentIdsToRemove, List attachments, Dictionary attachmentPrices ) { var index = 1; foreach (var attachmentId in attachmentIdsToRemove) { if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug( $"Attachment {index} Id: {attachmentId} Tpl: {attachments.FirstOrDefault(x => x.Id == attachmentId)?.Template} - " + $"Price: {attachmentPrices[attachmentId]}" ); } index++; } } /// /// Get dictionary of items with their corresponding price /// /// Item attachments /// protected Dictionary WeightAttachmentsByPrice(List attachments) { var result = new Dictionary(); // Get a dictionary of item tpls + their rouble price foreach (var attachment in attachments) { var price = ragfairPriceService.GetDynamicItemPrice(attachment.Template, Money.ROUBLES); if (price is not null) { result[attachment.Id] = Math.Round(price ?? 0); } } weightedRandomHelper.ReduceWeightValues(result); return result; } /// /// Get count of items to remove from weapon (take into account trader + price of attachment) /// /// Dict of item Tpls and their rouble price /// Trader the attachment is insured against /// Attachment count to remove protected double GetAttachmentCountToRemove( Dictionary weightedAttachmentByPrice, string? traderId ) { const int removeCount = 0; if (randomUtil.GetChance100(_insuranceConfig.ChanceNoAttachmentsTakenPercent)) { return removeCount; } // Get attachments count above or equal to price set in config return weightedAttachmentByPrice .Where(attachment => attachment.Value >= _insuranceConfig.MinAttachmentRoublePriceToBeTaken ) .Count(_ => RollForDelete(traderId) ?? false); } /// /// Remove items from the insured items that should not be returned to the player /// /// The insured items to process /// The items that should be deleted protected void RemoveItemsFromInsurance(Insurance insured, HashSet toDelete) { insured.Items = insured.Items.Where(item => !toDelete.Contains(item.Id)).ToList(); } /// /// Handle sending the insurance message to the user that potentially contains the valid insurance items /// /// Profile that should receive the insurance message /// context of insurance to use protected void SendMail(MongoId sessionId, Insurance insurance) { // If there are no items remaining after the item filtering, the insurance has // successfully "failed" to return anything and an appropriate message should be sent to the player. var traderDialogMessages = databaseService.GetTrader(insurance.TraderId).Dialogue; // Map is labs + insurance is disabled in base.json if (IsMapLabsAndInsuranceDisabled(insurance)) // Trader has labs-specific messages // Wipe out returnable items { HandleLabsInsurance(traderDialogMessages, insurance); } else if (IsMapLabyrinthAndInsuranceDisabled(insurance)) { HandleLabyrinthInsurance(traderDialogMessages, insurance); } else if (insurance.Items?.Count == 0) // Not labs and no items to return { if ( traderDialogMessages.TryGetValue( "insuranceFailed", out var insuranceFailedTemplates ) ) { insurance.MessageTemplateId = randomUtil.GetArrayValue(insuranceFailedTemplates); } } // Send the insurance message mailSendService.SendLocalisedNpcMessageToPlayer( sessionId, insurance.TraderId, insurance.MessageType ?? MessageType.SystemMessage, insurance.MessageTemplateId, insurance.Items, insurance.MaxStorageTime, insurance.SystemData ); } /// /// Edge case - labs doesn't allow for insurance returns unless location config is edited /// /// The insured items to process /// OPTIONAL - id of labs location /// protected bool IsMapLabsAndInsuranceDisabled(Insurance insurance, string labsId = "laboratory") { return string.Equals( insurance.SystemData?.Location, labsId, StringComparison.OrdinalIgnoreCase ) && !( databaseService.GetLocation(labsId)?.Base?.Insurance.GetValueOrDefault(false) ?? false ); } /// /// Edge case - labyrinth doesn't allow for insurance returns unless location config is edited /// /// The insured items to process /// OPTIONAL - id of labs location /// protected bool IsMapLabyrinthAndInsuranceDisabled( Insurance insurance, string labyrinthId = "labyrinth" ) { return string.Equals( insurance.SystemData?.Location, labyrinthId, StringComparison.OrdinalIgnoreCase ) && !( databaseService.GetLocation(labyrinthId)?.Base?.Insurance.GetValueOrDefault(false) ?? false ); } /// /// Update IInsurance object with new messageTemplateId and wipe out items array data /// /// /// protected void HandleLabsInsurance( Dictionary?>? traderDialogMessages, Insurance insurance ) { // Use labs specific messages if available, otherwise use default var responseMesageIds = traderDialogMessages["insuranceFailedLabs"]?.Count > 0 ? traderDialogMessages["insuranceFailedLabs"] : traderDialogMessages["insuranceFailed"]; insurance.MessageTemplateId = randomUtil.GetArrayValue(responseMesageIds); // Remove all insured items taken into labs insurance.Items = []; } /// /// Update IInsurance object with new messageTemplateId and wipe out items array data /// /// /// protected void HandleLabyrinthInsurance( Dictionary?>? traderDialogMessages, Insurance insurance ) { // Use labs specific messages if available, otherwise use default var responseMessageIds = traderDialogMessages["insuranceFailedLabyrinth"]?.Count > 0 ? traderDialogMessages["insuranceFailedLabyrinth"] : traderDialogMessages["insuranceFailed"]; insurance.MessageTemplateId = randomUtil.GetArrayValue(responseMessageIds); // Remove all insured items taken into labs insurance.Items = []; } /// /// Roll for chance of item being 'lost' /// /// Trader item was insured with /// Item being rolled on /// Should item be deleted protected bool? RollForDelete(MongoId traderId, Item? insuredItem = null) { var trader = traderStore.GetTraderById(traderId); if (trader is null) { return null; } const int maxRoll = 9999; const int conversionFactor = 100; var returnChance = randomUtil.GetInt(0, maxRoll) / conversionFactor; var traderReturnChance = _insuranceConfig.ReturnChancePercent[traderId]; var roll = returnChance >= traderReturnChance; // Log the roll with as much detail as possible. var itemName = insuredItem is not null ? $"{itemHelper.GetItemName(insuredItem.Template)}" : ""; var status = roll ? "Delete" : "Keep"; if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug( $"Rolling {itemName} with {trader} - Return {traderReturnChance}% - Roll: {returnChance} - Status: {status}" ); } return roll; } /// /// Handle Insure event, Add insurance to an item /// /// Players PMC profile /// Insurance request /// Session/Player id /// ItemEventRouterResponse object to send to client public ItemEventRouterResponse Insure( PmcData pmcData, InsureRequestData request, MongoId sessionId ) { var output = eventOutputHolder.GetOutput(sessionId); var itemsToInsureCount = request.Items.Count; List itemsToPay = []; // Create hash of player inventory items (keyed by item id) var inventoryItemsHash = pmcData.Inventory.Items.ToDictionary(item => item.Id); // Get price of all items being insured, add to 'itemsToPay' foreach (var key in request.Items) { itemsToPay.Add( new IdWithCount { Id = Money.ROUBLES, // TODO: update to handle different currencies Count = insuranceService.GetRoublePriceToInsureItemWithTrader( pmcData, inventoryItemsHash[key], request.TransactionId ), } ); } var options = new ProcessBuyTradeRequestData { SchemeItems = itemsToPay, TransactionId = request.TransactionId, Action = "SptInsure", Type = "", ItemId = "", Count = 0, SchemeId = 0, }; // pay for the item insurance paymentService.PayMoney(pmcData, options, sessionId, output); if (output.Warnings?.Count > 0) { return output; } // add items to InsuredItems list once money has been paid pmcData.InsuredItems ??= []; foreach (var key in request.Items) { pmcData.InsuredItems.Add( new InsuredItem { TId = request.TransactionId, ItemId = inventoryItemsHash[key].Id } ); // If Item is Helmet or Body Armour -> Handle insurance of soft inserts if (itemHelper.ArmorItemHasRemovableOrSoftInsertSlots(inventoryItemsHash[key].Template)) { InsureSoftInserts(inventoryItemsHash[key], pmcData, request); } } profileHelper.AddSkillPointsToPlayer( pmcData, SkillTypes.Charisma, itemsToInsureCount * 0.01 ); return output; } /// /// Ensure soft inserts of Armor that has soft insert slots, Allows armors to come back after being lost correctly /// /// Armor item to be insured /// Players PMC profile /// Insurance request data public void InsureSoftInserts( Item itemWithSoftInserts, PmcData pmcData, InsureRequestData request ) { var softInsertSlots = pmcData.Inventory.Items.Where(item => item.ParentId == itemWithSoftInserts.Id && itemHelper.IsSoftInsertId(item.SlotId.ToLowerInvariant()) ); foreach (var softInsertSlot in softInsertSlots) { if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug($"SoftInsertSlots: {softInsertSlot.SlotId}"); } pmcData.InsuredItems.Add( new InsuredItem { TId = request.TransactionId, ItemId = softInsertSlot.Id } ); } } /// /// Handle client/insurance/items/list/cost /// Calculate insurance cost /// /// request object /// Session/Player id /// GetInsuranceCostResponseData object to send to client public GetInsuranceCostResponseData Cost(GetInsuranceCostRequestData request, MongoId sessionId) { var response = new GetInsuranceCostResponseData(); var pmcData = profileHelper.GetPmcProfile(sessionId); // Create hash of inventory items, keyed by item Id pmcData.Inventory.Items ??= []; var inventoryItemsHash = pmcData.Inventory.Items.ToDictionary(item => item.Id); // Loop over each trader in request foreach (var trader in request.Traders ?? []) { var items = new Dictionary(); foreach (var itemId in request.Items ?? []) { // Ensure hash has item in it if (!inventoryItemsHash.ContainsKey(itemId)) { if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug( $"Item with id: {itemId} missing from player inventory, skipping" ); } continue; } items.TryAdd( inventoryItemsHash[itemId].Template, insuranceService.GetRoublePriceToInsureItemWithTrader( pmcData, inventoryItemsHash[itemId], trader ) ); } response.Add(trader, items); } return response; } }