using Core.Helpers; using Core.Models.Common; using Core.Models.Eft.Common; using Core.Models.Eft.Common.Tables; using Core.Models.Enums; using Core.Models.Spt.Config; using Core.Models.Spt.Fence; using Core.Models.Utils; using Core.Servers; using Core.Utils; using Core.Utils.Cloners; using SptCommon.Annotations; using SptCommon.Extensions; namespace Core.Services; [Injectable(InjectionType.Singleton)] public class FenceService( ISptLogger logger, TimeUtil timeUtil, RandomUtil randomUtil, DatabaseService databaseService, HandbookHelper handbookHelper, ItemHelper itemHelper, PresetHelper presetHelper, LocalisationService localisationService, ConfigServer configServer, ICloner _cloner ) { protected TraderConfig traderConfig = configServer.GetConfig(); /** Time when some items in assort will be replaced */ protected long nextPartialRefreshTimestamp; /** Main assorts you see at all rep levels */ protected TraderAssort? fenceAssort = null; /** Assorts shown on a separate tab when you max out fence rep */ protected TraderAssort? fenceDiscountAssort = null; /** Desired baseline counts - Hydrated on initial assort generation as part of generateFenceAssorts() */ protected FenceAssortGenerationValues desiredAssortCounts; protected HashSet fenceItemUpdCompareProperties = [ "Buff", "Repairable", "RecodableComponent", "Key", "Resource", "MedKit", "FoodDrink", "Dogtag", "RepairKit", ]; /** * Replace main fence assort with new assort * @param assort New assorts to replace old with */ public void SetFenceAssort(TraderAssort assort) { fenceAssort = assort; } /** * Replace discount fence assort with new assort * @param assort New assorts to replace old with */ public void SetDiscountFenceAssort(TraderAssort assort) { fenceDiscountAssort = assort; } /** * Get main fence assort * @return ITraderAssort */ public TraderAssort? GetMainFenceAssort() { return fenceAssort; } /** * Get discount fence assort * @return ITraderAssort */ public TraderAssort? GetDiscountFenceAssort() { return fenceDiscountAssort; } /** * Replace high rep level fence assort with new assort * @param discountAssort New assorts to replace old with */ public void SetFenceDiscountAssort(TraderAssort discountAssort) { fenceDiscountAssort = discountAssort; } /** * Get assorts player can purchase * Adjust prices based on fence level of player * @param pmcProfile Player profile * @returns ITraderAssort */ public TraderAssort GetFenceAssorts(PmcData pmcProfile) { if (traderConfig.Fence.RegenerateAssortsOnRefresh) { // Using base assorts made earlier, do some alterations and store in fenceAssort GenerateFenceAssorts(); } // Clone assorts so we can adjust prices before sending to client var assort = _cloner.Clone(fenceAssort); AdjustAssortItemPricesByConfigMultiplier(assort, 1, traderConfig.Fence.PresetPriceMult); // merge normal fence assorts + discount assorts if player standing is large enough if (pmcProfile.TradersInfo[Traders.FENCE].Standing >= 6) { var discountAssort = _cloner.Clone(fenceDiscountAssort); AdjustAssortItemPricesByConfigMultiplier( discountAssort, traderConfig.Fence.DiscountOptions.ItemPriceMult, traderConfig.Fence.DiscountOptions.PresetPriceMult ); var mergedAssorts = MergeAssorts(assort, discountAssort); return mergedAssorts; } return assort; } /** * Adds to fence assort a single item (with its children) * @param items the items to add with all its childrens * @param mainItem the most parent item of the array */ public void AddItemsToFenceAssort(List items, Item mainItem) { // HUGE THANKS TO LACYWAY AND LEAVES FOR PROVIDING THIS SOLUTION FOR SPT TO IMPLEMENT!! // Copy the item and its children var clonedItems = _cloner.Clone(itemHelper.FindAndReturnChildrenAsItems(items, mainItem.Id)); var root = clonedItems[0]; var cost = GetItemPrice(root.Template, clonedItems); // Fix IDs clonedItems = itemHelper.ReparentItemAndChildren(root, clonedItems); root.ParentId = "hideout"; if (root.Upd?.SpawnedInSession != null) { root.Upd.SpawnedInSession = false; } // Clean up the items // We may need to find an alternative to nodes: delete root.location; root.Location = null; var createAssort = new CreateFenceAssortsResult() { SptItems = [], BarterScheme = new(), LoyalLevelItems = new() }; createAssort.BarterScheme[root.Id] = [[new BarterScheme() { Count = cost, Template = Money.ROUBLES }]]; createAssort.SptItems.Add(clonedItems); createAssort.LoyalLevelItems[root.Id] = 1; UpdateFenceAssorts(createAssort, fenceAssort); } /** * Calculates the overall price for an item (with all its children) * @param itemTpl the item tpl to calculate the fence price for * @param items the items (with its children) to calculate fence price for * @returns the fence price of the item */ public double? GetItemPrice(string itemTpl, List items) { return itemHelper.IsOfBaseclass(itemTpl, BaseClasses.AMMO_BOX) ? GetAmmoBoxPrice(items) * traderConfig.Fence.ItemPriceMult : handbookHelper.GetTemplatePrice(itemTpl) * traderConfig.Fence.ItemPriceMult; } /** * Calculate the overall price for an ammo box, where only one item is * the ammo box itself and every other items are the bullets in that box * @param items the ammo box (and all its children ammo items) * @returns the price of the ammo box */ protected double? GetAmmoBoxPrice(List items) { double? total = 0D; foreach (var item in items) { if (itemHelper.IsOfBaseclass(item.Template, BaseClasses.AMMO)) { total += handbookHelper.GetTemplatePrice(item.Template) * (item.Upd?.StackObjectsCount ?? 1); } } return total; } /** * Adjust all items contained inside an assort by a multiplier * @param assort (clone)Assort that contains items with prices to adjust * @param itemMultipler multipler to use on items * @param presetMultiplier preset multipler to use on presets */ protected void AdjustAssortItemPricesByConfigMultiplier( TraderAssort assort, double itemMultipler, double presetMultiplier ) { foreach (var item in assort.Items) { // Skip sub-items when adjusting prices if (item.SlotId != "hideout") { continue; } AdjustItemPriceByModifier(item, assort, itemMultipler, presetMultiplier); } } /** * Merge two trader assort files together * @param firstAssort assort 1# * @param secondAssort assort #2 * @returns merged assort */ protected TraderAssort MergeAssorts(TraderAssort firstAssort, TraderAssort secondAssort) { foreach (var itemId in secondAssort.BarterScheme.Keys) { firstAssort.BarterScheme[itemId] = secondAssort.BarterScheme[itemId]; } foreach (var item in secondAssort.Items) { firstAssort.Items.Add(item); } foreach (var itemId in secondAssort.LoyalLevelItems.Keys) { firstAssort.LoyalLevelItems[itemId] = secondAssort.LoyalLevelItems[itemId]; } return firstAssort; } /** * Adjust assorts price by a modifier * @param item assort item details * @param assort assort to be modified * @param modifier value to multiply item price by * @param presetModifier value to multiply preset price by */ protected void AdjustItemPriceByModifier( Item item, TraderAssort assort, double modifier, double presetModifier ) { // Is preset if (item.Upd?.SptPresetId != null) { if (assort.BarterScheme?[item.Id] != null) { assort.BarterScheme[item.Id][0][0].Count *= presetModifier; } } else if (assort.BarterScheme?[item.Id] != null) { assort.BarterScheme[item.Id][0][0].Count *= modifier; } else { logger.Warning($"adjustItemPriceByModifier() - no action taken for item: {item.Template}"); } } /** * Get fence assorts with no price adjustments based on fence rep * @returns ITraderAssort */ public TraderAssort GetRawFenceAssorts() { return MergeAssorts(_cloner.Clone(fenceAssort), _cloner.Clone(fenceDiscountAssort)); } /** * Does fence need to perform a partial refresh because its passed the refresh timer defined in trader.json * @returns true if it needs a partial refresh */ public bool NeedsPartialRefresh() { return timeUtil.GetTimeStamp() > nextPartialRefreshTimestamp; } /** * Replace a percentage of fence assorts with freshly generated items */ public void PerformPartialRefresh() { var itemCountToReplace = GetCountOfItemsToReplace(traderConfig.Fence.AssortSize); var discountItemCountToReplace = GetCountOfItemsToReplace( traderConfig.Fence.DiscountOptions.AssortSize ); // Simulate players buying items DeleteRandomAssorts(itemCountToReplace, fenceAssort); DeleteRandomAssorts(discountItemCountToReplace, fenceDiscountAssort); var normalItemCountsToGenerate = GetItemCountsToGenerate( fenceAssort.Items, desiredAssortCounts.Normal ); var newItems = CreateAssorts(normalItemCountsToGenerate, 1); // Push newly generated assorts into existing data UpdateFenceAssorts(newItems, fenceAssort); var discountItemCountsToGenerate = GetItemCountsToGenerate( fenceDiscountAssort.Items, desiredAssortCounts.Discount ); var newDiscountItems = CreateAssorts(discountItemCountsToGenerate, 2); // Push newly generated discount assorts into existing data UpdateFenceAssorts(newDiscountItems, fenceDiscountAssort); // Add new barter items to fence barter scheme foreach (var barterItemKey in newItems.BarterScheme.Keys) { fenceAssort.BarterScheme[barterItemKey] = newItems.BarterScheme[barterItemKey]; } // Add loyalty items to fence assorts loyalty object foreach (var loyaltyItemKey in newItems.LoyalLevelItems.Keys) { fenceAssort.LoyalLevelItems[loyaltyItemKey] = newItems.LoyalLevelItems[loyaltyItemKey]; } // Add new barter items to fence assorts discounted barter scheme foreach (var barterItemKey in newDiscountItems.BarterScheme.Keys) { fenceDiscountAssort.BarterScheme[barterItemKey] = newDiscountItems.BarterScheme[barterItemKey]; } // Add loyalty items to fence discount assorts loyalty object foreach (var loyaltyItemKey in newDiscountItems.LoyalLevelItems.Keys) { fenceDiscountAssort.LoyalLevelItems[loyaltyItemKey] = newDiscountItems.LoyalLevelItems[loyaltyItemKey]; } // Reset the clock IncrementPartialRefreshTime(); } /** * Handle the process of folding new assorts into existing assorts, when a new assort exists already, increment its StackObjectsCount instead * @param newFenceAssorts Assorts to fold into existing fence assorts * @param existingFenceAssorts Current fence assorts new assorts will be added to */ protected void UpdateFenceAssorts( CreateFenceAssortsResult newFenceAssorts, TraderAssort existingFenceAssorts ) { foreach (var itemWithChildren in newFenceAssorts.SptItems) { // Find the root item var newRootItem = itemWithChildren.FirstOrDefault((item) => item.SlotId == "hideout"); if (newRootItem == null) { var firstItem = itemWithChildren.FirstOrDefault((x) => x != null); logger.Error( $"Unable to process fence assort as root item is missing, {firstItem?.Template}, skipping" ); continue; } // Find a matching root item with same tpl in existing assort var existingRootItem = existingFenceAssorts.Items.FirstOrDefault( (item) => item.Template == newRootItem.Template && item.SlotId == "hideout" ); // Check if same type of item exists + its on list of item types to always stack if (existingRootItem != null && ItemInPreventDupeCategoryList(newRootItem.Template)) { var existingFullItemTree = itemHelper.FindAndReturnChildrenAsItems( existingFenceAssorts.Items, existingRootItem.Id ); if (itemHelper.isSameItems(itemWithChildren, existingFullItemTree, fenceItemUpdCompareProperties)) { // Guard against a missing stack count if (existingRootItem.Upd?.StackObjectsCount == null) { existingRootItem.Upd.StackObjectsCount = 1; } // Merge new items count into existing, dont add new loyalty/barter data as it already exists existingRootItem.Upd.StackObjectsCount += newRootItem?.Upd?.StackObjectsCount ?? 1; continue; } } // if the Upd doesnt exist just initialize it if (newRootItem.Upd == null) { newRootItem.Upd = new(); } // New assort to be added to existing assorts existingFenceAssorts.Items.AddRange(itemWithChildren); existingFenceAssorts.BarterScheme[newRootItem.Id] = newFenceAssorts.BarterScheme[newRootItem.Id]; existingFenceAssorts.LoyalLevelItems[newRootItem.Id] = newFenceAssorts.LoyalLevelItems[newRootItem.Id]; } } /** * Increment fence next refresh timestamp by current timestamp + partialRefreshTimeSeconds from config */ protected void IncrementPartialRefreshTime() { nextPartialRefreshTimestamp = timeUtil.GetTimeStamp() + traderConfig.Fence.PartialRefreshTimeSeconds; } /** * Get values that will hydrate the passed in assorts back to the desired counts * @param assortItems Current assorts after items have been removed * @param generationValues Base counts assorts should be adjusted to * @returns IGenerationAssortValues object with adjustments needed to reach desired state */ protected GenerationAssortValues GetItemCountsToGenerate( List assortItems, GenerationAssortValues generationValues ) { var allRootItems = assortItems.Where((item) => item.SlotId == "hideout"); var rootPresetItems = allRootItems.Where((item) => item?.Upd?.SptPresetId != null); // Get count of weapons var currentWeaponPresetCount = rootPresetItems.Aggregate( 0, (count, item) => itemHelper.IsOfBaseclass(item.Template, BaseClasses.WEAPON) ? count + 1 : count ); // Get count of equipment var currentEquipmentPresetCount = rootPresetItems.Aggregate( 0, (count, item) => itemHelper.ArmorItemCanHoldMods(item.Template) ? count + 1 : count ); // Normal item count is total count minus weapon + armor count var nonPresetItemAssortCount = allRootItems.Count() - (currentWeaponPresetCount + currentEquipmentPresetCount); // Get counts of items to generate, never var values fall below 0 var itemCountToGenerate = Math.Max(generationValues.Item.Value - nonPresetItemAssortCount, 0); var weaponCountToGenerate = Math.Max(generationValues.WeaponPreset.Value - currentWeaponPresetCount, 0); var equipmentCountToGenerate = Math.Max( generationValues.EquipmentPreset.Value - currentEquipmentPresetCount, 0 ); return new GenerationAssortValues { Item = itemCountToGenerate, WeaponPreset = weaponCountToGenerate, EquipmentPreset = equipmentCountToGenerate }; } /** * Delete desired number of items from assort (including children) * @param itemCountToReplace * @param discountItemCountToReplace */ protected void DeleteRandomAssorts(int itemCountToReplace, TraderAssort assort) { if (assort?.Items?.Count > 0) { var rootItems = assort.Items.Where((item) => item.SlotId == "hideout").ToList(); for (var index = 0; index < itemCountToReplace; index++) { RemoveRandomItemFromAssorts(assort, rootItems); } } } /** * Choose an item at random and remove it + mods from assorts * @param assort Trader assort to remove item from * @param rootItems Pool of root items to pick from to remove */ protected void RemoveRandomItemFromAssorts(TraderAssort assort, List rootItems) { var rootItemToAdjust = randomUtil.GetArrayValue(rootItems); // Items added by mods may not have a Upd object, assume item stack size is 1 var stackSize = rootItemToAdjust.Upd?.StackObjectsCount ?? 1; // Get a random count of the chosen item to remove var itemCountToRemove = randomUtil.GetInt(1, (int)stackSize); var isEntireStackToBeRemoved = Math.Abs(itemCountToRemove - stackSize) < 0.1; // Partial stack reduction if (!isEntireStackToBeRemoved) { if (rootItemToAdjust.Upd == null) { logger.Warning($"Fence Item: {rootItemToAdjust.Template} lacks a Upd object, adding"); rootItemToAdjust.Upd = new(); } // Reduce stack to at smallest, 1 rootItemToAdjust.Upd.StackObjectsCount -= Math.Max(1, itemCountToRemove); return; } // Remove item + child mods (if any) var itemWithChildren = itemHelper.FindAndReturnChildrenAsItems(assort.Items, rootItemToAdjust.Id); foreach (var itemToDelete in itemWithChildren) { // Delete item from assort items array assort.Items.Splice(assort.Items.IndexOf(itemToDelete), 1); } // Need to remove item from all areas of trader assort // delete assort.barter_scheme[rootItemToAdjust._id]; // delete assort.loyal_level_items[rootItemToAdjust._id]; assort.BarterScheme.Remove(rootItemToAdjust.Id); assort.LoyalLevelItems.Remove(rootItemToAdjust.Id); } /** * Get an integer rounded count of items to replace based on percentrage from traderConfig value * @param totalItemCount total item count * @returns rounded int of items to replace */ protected int GetCountOfItemsToReplace(int totalItemCount) { return (int)Math.Round(totalItemCount * (traderConfig.Fence.PartialRefreshChangePercent / 100)); } /** * Get the count of items fence offers * @returns number */ public int GetOfferCount() { if ((fenceAssort?.Items?.Count ?? 0) == 0) { return 0; } return fenceAssort.Items.Count; } /** * Create trader assorts for fence and store in fenceService cache * Uses fence base cache generatedon server start as a base */ public void GenerateFenceAssorts() { // Reset refresh time now assorts are being generated IncrementPartialRefreshTime(); // Choose assort counts using config CreateInitialFenceAssortGenerationValues(); // Create basic fence assort var assorts = CreateAssorts(desiredAssortCounts.Normal, 1); // Store in fenceAssort SetFenceAssort(ConvertIntoFenceAssort(assorts)); // Create level 2 assorts accessible at rep level 6 var discountAssorts = CreateAssorts(desiredAssortCounts.Discount, 2); // Store in fenceDiscountAssort SetFenceDiscountAssort(ConvertIntoFenceAssort(discountAssorts)); } /** * Convert the intermediary assort data generated into format client can process * @param intermediaryAssorts Generated assorts that will be converted * @returns ITraderAssort */ protected TraderAssort ConvertIntoFenceAssort(CreateFenceAssortsResult intermediaryAssorts) { var result = CreateFenceAssortSkeleton(); foreach (var itemWithChilden in intermediaryAssorts.SptItems) { result.Items.AddRange(itemWithChilden); } result.BarterScheme = intermediaryAssorts.BarterScheme; result.LoyalLevelItems = intermediaryAssorts.LoyalLevelItems; return result; } /** * Create object that contains calculated fence assort item values to make based on config * Stored in desiredAssortCounts */ protected void CreateInitialFenceAssortGenerationValues() { var result = new FenceAssortGenerationValues() { Normal = new GenerationAssortValues() { Item = 0, WeaponPreset = 0, EquipmentPreset = 0 }, Discount = new GenerationAssortValues() { Item = 0, WeaponPreset = 0, EquipmentPreset = 0 } }; result.Normal.Item = traderConfig.Fence.AssortSize; result.Normal.WeaponPreset = randomUtil.GetInt( (int)traderConfig.Fence.WeaponPresetMinMax.Min, (int)traderConfig.Fence.WeaponPresetMinMax.Max ); result.Normal.EquipmentPreset = randomUtil.GetInt( (int)traderConfig.Fence.EquipmentPresetMinMax.Min, (int)traderConfig.Fence.EquipmentPresetMinMax.Max ); result.Discount.Item = traderConfig.Fence.DiscountOptions.AssortSize; result.Discount.WeaponPreset = randomUtil.GetInt( (int)traderConfig.Fence.DiscountOptions.WeaponPresetMinMax.Min, (int)traderConfig.Fence.DiscountOptions.WeaponPresetMinMax.Max ); result.Discount.EquipmentPreset = randomUtil.GetInt( (int)traderConfig.Fence.DiscountOptions.EquipmentPresetMinMax.Min, (int)traderConfig.Fence.DiscountOptions.EquipmentPresetMinMax.Max ); desiredAssortCounts = result; } /** * Create skeleton to hold assort items * @returns ITraderAssort object */ protected TraderAssort CreateFenceAssortSkeleton() { return new TraderAssort() { Items = [], BarterScheme = new(), LoyalLevelItems = new(), NextResupply = GetNextFenceUpdateTimestamp(), }; } /** * Hydrate assorts parameter object with generated assorts * @param assortCount Number of assorts to generate * @param assorts object to add created assorts to */ protected CreateFenceAssortsResult CreateAssorts(GenerationAssortValues itemCounts, int loyaltyLevel) { var result = new CreateFenceAssortsResult() { SptItems = [], BarterScheme = new(), LoyalLevelItems = new() }; var baseFenceAssortClone = _cloner.Clone(databaseService.GetTrader(Traders.FENCE).Assort); var itemTypeLimitCounts = InitItemLimitCounter(traderConfig.Fence.ItemTypeLimits); if (itemCounts.Item > 0) { AddItemAssorts(itemCounts.Item, result, baseFenceAssortClone, itemTypeLimitCounts, loyaltyLevel); } if (itemCounts.WeaponPreset > 0 || itemCounts.EquipmentPreset > 0) { // Add presets AddPresetsToAssort( itemCounts.WeaponPreset, itemCounts.EquipmentPreset, result, baseFenceAssortClone, loyaltyLevel ); } return result; } /** * Add item assorts to existing assort data * @param assortCount Number to add * @param assorts Assorts data to add to * @param baseFenceAssortClone Base data to draw from * @param itemTypeLimits * @param loyaltyLevel Loyalty level to set new item to */ protected void AddItemAssorts( int? assortCount, CreateFenceAssortsResult assorts, TraderAssort baseFenceAssortClone, Dictionary itemTypeLimits, int loyaltyLevel ) { var priceLimits = traderConfig.Fence.ItemCategoryRoublePriceLimit; var assortRootItems = baseFenceAssortClone.Items .Where(item => item.ParentId == "hideout" && item.Upd?.SptPresetId == null) .ToList(); if (assortRootItems.Count == 0) { logger.Error("Unable to add assorts to Fence as no root items exist in items being added"); return; } for (var i = 0; i < assortCount; i++) { var chosenBaseAssortRoot = randomUtil.GetArrayValue(assortRootItems); if (chosenBaseAssortRoot == null) { logger.Error(localisationService.GetText("fence-unable_to_find_assort_by_id")); continue; } var desiredAssortItemAndChildrenClone = _cloner.Clone( itemHelper.FindAndReturnChildrenAsItems(baseFenceAssortClone.Items, chosenBaseAssortRoot.Id) ); var itemDbDetails = itemHelper.GetItem(chosenBaseAssortRoot.Template).Value; var itemLimitCount = GetMatchingItemLimit(itemTypeLimits, itemDbDetails.Id); if (itemLimitCount?.current >= itemLimitCount?.max) { // Skip adding item as assort as limit reached, decrement i counter so we still get another item i--; continue; } var itemIsPreset = presetHelper.IsPreset(chosenBaseAssortRoot.Id); var price = baseFenceAssortClone.BarterScheme?[chosenBaseAssortRoot.Id][0][0].Count; if (price == 0 || (price == 1 && !itemIsPreset) || price == 100) { // Don't allow "special" items / presets i--; continue; } if (priceLimits.ContainsKey(itemDbDetails.Parent) && price > priceLimits[itemDbDetails.Parent]) { // Too expensive for fence, try another item i--; continue; } // Increment count as item is being added if (itemLimitCount.HasValue) { var value = itemLimitCount.Value; value.current += 1; } // MUST randomise Ids as its possible to add the same base fence assort twice = duplicate IDs = dead client desiredAssortItemAndChildrenClone = itemHelper.ReplaceIDs(_cloner.Clone(desiredAssortItemAndChildrenClone)); itemHelper.RemapRootItemId(desiredAssortItemAndChildrenClone); var rootItemBeingAdded = desiredAssortItemAndChildrenClone[0]; // Set stack size based on possible overrides, e.g. ammos, otherwise set to 1 rootItemBeingAdded.Upd.StackObjectsCount = GetSingleItemStackCount(itemDbDetails); // Only randomise Upd values for single var isSingleStack = Math.Abs((rootItemBeingAdded.Upd?.StackObjectsCount ?? 0) - 1) < 0.1; if (isSingleStack) { RandomiseItemUpdProperties(itemDbDetails, rootItemBeingAdded); } // Skip items already in the assort if it exists in the prevent duplicate list var existingItemThatMatches = GetMatchingItem(rootItemBeingAdded, itemDbDetails, assorts.SptItems); var shouldBeStacked = ItemShouldBeForceStacked(existingItemThatMatches, itemDbDetails); if (shouldBeStacked && existingItemThatMatches != null) { // Decrement loop counter so another items gets added i--; existingItemThatMatches.Upd.StackObjectsCount++; continue; } // Add mods to armors so they dont show as red in the trade screen if (itemHelper.ItemRequiresSoftInserts(rootItemBeingAdded.Template)) { RandomiseArmorModDurability(desiredAssortItemAndChildrenClone, itemDbDetails); } assorts.SptItems.Add(desiredAssortItemAndChildrenClone); assorts.BarterScheme[rootItemBeingAdded.Id] = _cloner.Clone(baseFenceAssortClone.BarterScheme[chosenBaseAssortRoot.Id]); // Only adjust item price by quality for solo items, never multi-stack if (isSingleStack) { AdjustItemPriceByQuality(assorts.BarterScheme, rootItemBeingAdded, itemDbDetails); } assorts.LoyalLevelItems[rootItemBeingAdded.Id] = loyaltyLevel; } } /** * Find an assort item that matches the first parameter, also matches based on Upd properties * e.g. salewa hp resource units left * @param rootItemBeingAdded item to look for a match against * @param itemDbDetails Db details of matching item * @param itemsWithChildren Items to search through * @returns Matching assort item */ protected Item? GetMatchingItem( Item rootItemBeingAdded, TemplateItem itemDbDetails, List> itemsWithChildren ) { // Get matching root items var matchingItems = itemsWithChildren .Where( (itemWithChildren) => itemWithChildren.FirstOrDefault( (item) => item.Template == rootItemBeingAdded.Template && item.ParentId == "hideout" ) != null ) .SelectMany(i => i) .ToList(); if (matchingItems.Count == 0) { // Nothing matches by tpl and is root item, exit early return null; } var isMedical = itemHelper.IsOfBaseclasses( rootItemBeingAdded.Template, [ BaseClasses.MEDICAL, BaseClasses.MEDKIT ] ); var isGearAndHasSlots = itemHelper.IsOfBaseclasses( rootItemBeingAdded.Template, [ BaseClasses.ARMORED_EQUIPMENT, BaseClasses.SEARCHABLE_ITEM ] ) && (itemDbDetails.Properties.Slots?.Count ?? 0) > 0; // Only one match and its not medical or armored gear if (matchingItems.Count == 1 && !(isMedical || isGearAndHasSlots)) { return matchingItems[0]; } // Items have sub properties that need to be checked against foreach (var item in matchingItems) { if (isMedical && rootItemBeingAdded.Upd?.MedKit?.HpResource == item.Upd?.MedKit?.HpResource) { // e.g. bandages with multiple use // Both undefined === both max resoruce left return item; } // Armors/helmets etc if ( isGearAndHasSlots && rootItemBeingAdded.Upd.Repairable?.Durability == item.Upd.Repairable?.Durability && rootItemBeingAdded.Upd.Repairable?.MaxDurability == item.Upd.Repairable?.MaxDurability ) { return item; } } return null; } /** * Should this item be forced into only 1 stack on fence * @param existingItem Existing item from fence assort * @param itemDbDetails Item we want to add db details * @returns True item should be force stacked */ protected bool ItemShouldBeForceStacked(Item? existingItem, TemplateItem itemDbDetails) { // No existing item in assort if (existingItem == null) { return false; } // Don't stack child items, only root items if (existingItem.ParentId != "hideout") { return false; } return ItemInPreventDupeCategoryList(itemDbDetails.Id); } protected bool ItemInPreventDupeCategoryList(string tpl) { // Item type in config list return itemHelper.IsOfBaseclasses(tpl, traderConfig.Fence.PreventDuplicateOffersOfCategory); } /** * Adjust price of item based on what is left to buy (resource/uses left) * @param barterSchemes All barter scheme for item having price adjusted * @param itemRoot Root item having price adjusted * @param itemTemplate Db template of item */ protected void AdjustItemPriceByQuality( Dictionary>> barterSchemes, Item itemRoot, TemplateItem itemTemplate ) { // Healing items if (itemRoot.Upd?.MedKit != null) { var itemTotalMax = itemTemplate.Properties.MaxHpResource; var current = itemRoot.Upd.MedKit.HpResource; // Current and max match, no adjustment necessary if (itemTotalMax == current) { return; } var multipler = current / itemTotalMax; // Multiply item cost by desired multiplier var basePrice = barterSchemes[itemRoot.Id][0][0].Count; barterSchemes[itemRoot.Id][0][0].Count = Math.Round((double)(basePrice * multipler)); return; } // Adjust price based on durability if (itemRoot.Upd?.Repairable != null || itemHelper.IsOfBaseclass(itemRoot.Template, BaseClasses.KEY_MECHANICAL)) { var itemQualityModifier = itemHelper.GetItemQualityModifier(itemRoot); var basePrice = barterSchemes[itemRoot.Id][0][0].Count; barterSchemes[itemRoot.Id][0][0].Count = Math.Round((double)basePrice * itemQualityModifier); } } protected (int current, int max)? GetMatchingItemLimit( Dictionary itemTypeLimits, string itemTpl ) { foreach (var baseTypeKey in itemTypeLimits.Keys) { if (itemHelper.IsOfBaseclass(itemTpl, baseTypeKey)) { return itemTypeLimits[baseTypeKey]; } } return null; } /** * Find presets in base fence assort and add desired number to 'assorts' parameter * @param desiredWeaponPresetsCount * @param assorts Assorts to add preset to * @param baseFenceAssort Base data to draw from * @param loyaltyLevel Which loyalty level is required to see/buy item */ protected void AddPresetsToAssort( int? desiredWeaponPresetsCount, int? desiredEquipmentPresetsCount, CreateFenceAssortsResult assorts, TraderAssort baseFenceAssort, int loyaltyLevel ) { var weaponPresetsAddedCount = 0; if (desiredWeaponPresetsCount > 0) { var weaponPresetRootItems = baseFenceAssort.Items.Where( item => item.Upd?.SptPresetId != null && itemHelper.IsOfBaseclass(item.Template, BaseClasses.WEAPON) ); while (weaponPresetsAddedCount < desiredWeaponPresetsCount) { var randomPresetRoot = randomUtil.GetArrayValue(weaponPresetRootItems); if (traderConfig.Fence.Blacklist.Contains(randomPresetRoot.Template)) { continue; } var rootItemDb = itemHelper.GetItem(randomPresetRoot.Template).Value; var presetWithChildrenClone = _cloner.Clone( itemHelper.FindAndReturnChildrenAsItems(baseFenceAssort.Items, randomPresetRoot.Id) ); RandomiseItemUpdProperties(rootItemDb, presetWithChildrenClone[0]); RemoveRandomModsOfItem(presetWithChildrenClone); // Check chosen item is below price cap var itemPrice = handbookHelper.GetTemplatePriceForItems(presetWithChildrenClone) * itemHelper.GetItemQualityModifierForItems(presetWithChildrenClone); if (traderConfig.Fence.ItemCategoryRoublePriceLimit.TryGetValue(rootItemDb.Parent, out var priceLimitRouble)) { if (itemPrice > priceLimitRouble) { // Too expensive, try again continue; } } // MUST randomise Ids as its possible to add the same base fence assort twice = duplicate IDs = dead client itemHelper.ReparentItemAndChildren(presetWithChildrenClone[0], presetWithChildrenClone); itemHelper.RemapRootItemId(presetWithChildrenClone); // Remapping IDs causes parentid to be altered presetWithChildrenClone[0].ParentId = "hideout"; assorts.SptItems.Add(presetWithChildrenClone); // Set assort price // Must be careful to use correct id as the item has had its IDs regenerated assorts.BarterScheme[presetWithChildrenClone[0].Id] = [ [ new BarterScheme() { Template = Money.ROUBLES, Count = Math.Round(itemPrice), } ] ]; assorts.LoyalLevelItems[presetWithChildrenClone[0].Id] = loyaltyLevel; weaponPresetsAddedCount++; } } var equipmentPresetsAddedCount = 0; if (desiredEquipmentPresetsCount <= 0) { return; } var equipmentPresetRootItems = baseFenceAssort.Items.Where( (item) => item.Upd?.SptPresetId != null && itemHelper.ArmorItemCanHoldMods(item.Template) ); while (equipmentPresetsAddedCount < desiredEquipmentPresetsCount) { var randomPresetRoot = randomUtil.GetArrayValue(equipmentPresetRootItems); var rootItemDb = itemHelper.GetItem(randomPresetRoot.Template).Value; var presetWithChildrenClone = _cloner.Clone( itemHelper.FindAndReturnChildrenAsItems(baseFenceAssort.Items, randomPresetRoot.Id) ); // Need to add mods to armors so they dont show as red in the trade screen if (itemHelper.ItemRequiresSoftInserts(randomPresetRoot.Template)) { RandomiseArmorModDurability(presetWithChildrenClone, rootItemDb); } RemoveRandomModsOfItem(presetWithChildrenClone); // Check chosen item is below price cap var priceLimitRouble = traderConfig.Fence.ItemCategoryRoublePriceLimit[rootItemDb.Parent]; var itemPrice = handbookHelper.GetTemplatePriceForItems(presetWithChildrenClone) * itemHelper.GetItemQualityModifierForItems(presetWithChildrenClone); if (priceLimitRouble != null) { if (itemPrice > priceLimitRouble) { // Too expensive, try again continue; } } // MUST randomise Ids as its possible to add the same base fence assort twice = duplicate IDs = dead client itemHelper.ReparentItemAndChildren(presetWithChildrenClone[0], presetWithChildrenClone); itemHelper.RemapRootItemId(presetWithChildrenClone); // Remapping IDs causes parentid to be altered presetWithChildrenClone[0].ParentId = "hideout"; assorts.SptItems.Add(presetWithChildrenClone); // Set assort price // Must be careful to use correct id as the item has had its IDs regenerated assorts.BarterScheme[presetWithChildrenClone[0].Id] = [ [ new BarterScheme() { Template = Money.ROUBLES, Count = Math.Round(itemPrice), } ] ]; assorts.LoyalLevelItems[presetWithChildrenClone[0].Id] = loyaltyLevel; equipmentPresetsAddedCount++; } } /** * Adjust plate / soft insert durability values * @param armor Armor item array to add mods into * @param itemDbDetails Armor items db template */ protected void RandomiseArmorModDurability(List armor, TemplateItem itemDbDetails) { // Armor has no mods, nothing to randomise if (itemDbDetails.Properties.Slots == null) { return; } // Check for and adjust soft insert durability values var requiredSlots = itemDbDetails.Properties.Slots?.Where(slot => slot.Required ?? false).ToList(); if ((requiredSlots?.Count ?? 0) > 1) { RandomiseArmorSoftInsertDurabilities(requiredSlots, armor); } // Check for and adjust plate durability values var plateSlots = itemDbDetails.Properties.Slots?.Where(slot => itemHelper.IsRemovablePlateSlot(slot.Name)) .ToList(); if ((plateSlots?.Count ?? 0) > 1) { RandomiseArmorInsertsDurabilities(plateSlots, armor); } } /** * Randomise the durability values of items on armor with a passed in slot * @param softInsertSlots Slots of items to randomise * @param armorItemAndMods Array of armor + inserts to get items from */ protected void RandomiseArmorSoftInsertDurabilities(List softInsertSlots, List armorItemAndMods) { foreach (var requiredSlot in softInsertSlots) { var modItemDbDetails = itemHelper.GetItem(requiredSlot.Props.Filters[0].Plate).Value; var durabilityValues = GetRandomisedArmorDurabilityValues( modItemDbDetails, traderConfig.Fence.ArmorMaxDurabilityPercentMinMax ); var plateTpl = requiredSlot.Props.Filters[0].Plate ?? string.Empty; // "Plate" property appears to be the 'default' item for slot if (plateTpl == "") { // Some bsg plate properties are empty, skip mod continue; } // Find items mod to apply dura changes to var modItemToAdjust = armorItemAndMods.FirstOrDefault(mod => mod.SlotId.ToLower() == requiredSlot.Name.ToLower()); itemHelper.AddUpdObjectToItem(modItemToAdjust); if (modItemToAdjust.Upd.Repairable == null) { modItemToAdjust.Upd.Repairable = new UpdRepairable() { Durability = modItemDbDetails.Properties.MaxDurability, MaxDurability = modItemDbDetails.Properties.MaxDurability }; } modItemToAdjust.Upd.Repairable.Durability = durabilityValues.Durability; modItemToAdjust.Upd.Repairable.MaxDurability = durabilityValues.MaxDurability; // 25% chance to add shots to visor items when its below max durability if (randomUtil.GetChance100(25) && modItemToAdjust.ParentId == BaseClasses.ARMORED_EQUIPMENT && modItemToAdjust.SlotId == "mod_equipment_000" && modItemToAdjust.Upd.Repairable.Durability < modItemDbDetails.Properties.MaxDurability) { // Is damaged modItemToAdjust.Upd.FaceShield = new UpdFaceShield() { Hits = randomUtil.GetInt(1, 3) }; } } } /** * Randomise the durability values of plate items in armor * Has chance to remove plate * @param plateSlots Slots of items to randomise * @param armorItemAndMods Array of armor + inserts to get items from */ protected void RandomiseArmorInsertsDurabilities(List plateSlots, List armorItemAndMods) { foreach (var plateSlot in plateSlots) { var plateTpl = plateSlot.Props.Filters[0].Plate; if (string.IsNullOrEmpty(plateTpl)) { // Bsg data lacks a default plate, skip randomisng for this mod continue; } var armorWithMods = armorItemAndMods; var modItemDbDetails = itemHelper.GetItem(plateTpl).Value; // Chance to remove plate var plateExistsChance = traderConfig.Fence.ChancePlateExistsInArmorPercent[modItemDbDetails?.Properties?.ArmorClass?.ToString() ?? "3"]; if (!randomUtil.GetChance100(plateExistsChance)) { // Remove plate from armor armorItemAndMods = armorItemAndMods.Where(item => item.SlotId.ToLower() != plateSlot.Name.ToLower()) .ToList(); continue; } var durabilityValues = GetRandomisedArmorDurabilityValues( modItemDbDetails, traderConfig.Fence.ArmorMaxDurabilityPercentMinMax ); // Find items mod to apply dura changes to var modItemToAdjust = armorWithMods.FirstOrDefault(mod => mod.SlotId.ToLower() == plateSlot.Name.ToLower()); if (modItemToAdjust == null) { logger.Warning( $"Unable to randomise armor items {armorWithMods[0].Template} ${plateSlot.Name} slot as it cannot be found, skipping" ); continue; } itemHelper.AddUpdObjectToItem(modItemToAdjust); if (modItemToAdjust?.Upd?.Repairable == null) { modItemToAdjust.Upd.Repairable = new UpdRepairable() { Durability = modItemDbDetails.Properties.MaxDurability, MaxDurability = modItemDbDetails.Properties.MaxDurability }; } modItemToAdjust.Upd.Repairable.Durability = durabilityValues.Durability; modItemToAdjust.Upd.Repairable.MaxDurability = durabilityValues.MaxDurability; } } /** * Get stack size of a singular item (no mods) * @param itemDbDetails item being added to fence * @returns Stack size */ protected int GetSingleItemStackCount(TemplateItem itemDbDetails) { MinMax? overrideValues; if (itemHelper.IsOfBaseclass(itemDbDetails.Id, BaseClasses.AMMO)) { overrideValues = traderConfig.Fence.ItemStackSizeOverrideMinMax[itemDbDetails.Parent]; if (overrideValues != null) { return randomUtil.GetInt((int)overrideValues.Min, (int)overrideValues.Max); } // No override, use stack max size from item db return itemDbDetails.Properties.StackMaxSize == 1 ? 1 : randomUtil.GetInt( (int)itemDbDetails.Properties.StackMinRandom, (int)itemDbDetails.Properties.StackMaxRandom ); } // Check for override in config, use values if exists if (traderConfig.Fence.ItemStackSizeOverrideMinMax.TryGetValue(itemDbDetails.Id, out overrideValues)) { return randomUtil.GetInt((int)overrideValues.Min, (int)overrideValues.Max); } // Check for parent override if (traderConfig.Fence.ItemStackSizeOverrideMinMax.TryGetValue(itemDbDetails.Parent, out overrideValues)) { return randomUtil.GetInt((int)overrideValues.Min, (int)overrideValues.Max); } return 1; } /** * Remove parts of a weapon prior to being listed on flea * @param itemAndMods Weapon to remove parts from */ protected void RemoveRandomModsOfItem(List itemAndMods) { // Items to be removed from inventory var toDelete = new List(); // Find mods to remove from item that could've been scavenged by other players in-raid foreach (var itemMod in itemAndMods) { if (PresetModItemWillBeRemoved(itemMod, toDelete)) { // Skip if not an item var itemDbDetails = itemHelper.GetItem(itemMod.Template); if (!itemDbDetails.Key) { continue; } // Remove item and its sub-items to prevent orphans toDelete.AddRange(itemHelper.FindAndReturnChildrenByItems(itemAndMods, itemMod.Id)); } } // Reverse loop and remove items for (var index = itemAndMods.Count - 1; index >= 0; --index) { if (toDelete.Contains(itemAndMods[index].Id)) { itemAndMods.Splice(index, 1); } } } /** * Roll % chance check to see if item should be removed * @param weaponMod Weapon mod being checked * @param itemsBeingDeleted Current list of items on weapon being deleted * @returns True if item will be removed */ protected bool PresetModItemWillBeRemoved(Item weaponMod, List itemsBeingDeleted) { var slotIdsThatCanFail = traderConfig.Fence.PresetSlotsToRemoveChancePercent; if (!slotIdsThatCanFail.TryGetValue(weaponMod.SlotId, out var removalChance) || removalChance == 0.0) { return false; } // Roll from 0 to 9999, then divide it by 100: 9999 = 99.99% var randomChance = randomUtil.GetInt(0, 9999) / 100; return removalChance > randomChance && !itemsBeingDeleted.Contains(weaponMod.Id); } /** * Randomise items' Upd properties e.g. med packs/weapons/armor * @param itemDetails Item being randomised * @param itemToAdjust Item being edited */ protected void RandomiseItemUpdProperties(TemplateItem itemDetails, Item itemToAdjust) { if (itemDetails.Properties == null) { logger.Error( $"Item {itemDetails.Name} lacks a _props field, unable to randomise item: {itemToAdjust.Id}" ); return; } // Randomise hp resource of med items if (itemDetails.Properties.MaxHpResource != null && (itemDetails.Properties.MaxHpResource ?? 0) > 0) { itemToAdjust.Upd.MedKit = new UpdMedKit() { HpResource = randomUtil.GetInt(1, (int)itemDetails.Properties.MaxHpResource) }; } // Randomise armor durability if ( (itemDetails.Parent == BaseClasses.ARMORED_EQUIPMENT || itemDetails.Parent == BaseClasses.FACECOVER || itemDetails.Parent == BaseClasses.ARMOR_PLATE) && (itemDetails.Properties.MaxDurability ?? 0) > 0 ) { var values = GetRandomisedArmorDurabilityValues( itemDetails, traderConfig.Fence.ArmorMaxDurabilityPercentMinMax ); itemToAdjust.Upd.Repairable = new UpdRepairable() { Durability = values.Durability, MaxDurability = values.MaxDurability }; return; } // Randomise Weapon durability if (itemHelper.IsOfBaseclass(itemDetails.Id, BaseClasses.WEAPON)) { var weaponDurabilityLimits = traderConfig.Fence.WeaponDurabilityPercentMinMax; var maxDuraMin = (weaponDurabilityLimits.Max.Min / 100) * itemDetails.Properties.MaxDurability; var maxDuraMax = (weaponDurabilityLimits.Max.Max / 100) * itemDetails.Properties.MaxDurability; var chosenMaxDurability = randomUtil.GetInt((int)maxDuraMin, (int)maxDuraMax); var currentDuraMin = (weaponDurabilityLimits.Current.Min / 100) * itemDetails.Properties.MaxDurability; var currentDuraMax = (weaponDurabilityLimits.Current.Max / 100) * itemDetails.Properties.MaxDurability; var currentDurability = Math.Min( randomUtil.GetInt((int)currentDuraMin, (int)currentDuraMax), chosenMaxDurability ); itemToAdjust.Upd.Repairable = new UpdRepairable { Durability = currentDurability, MaxDurability = chosenMaxDurability }; return; } if (itemHelper.IsOfBaseclass(itemDetails.Id, BaseClasses.REPAIR_KITS)) { itemToAdjust.Upd.RepairKit = new UpdRepairKit { Resource = randomUtil.GetInt(1, (int)itemDetails.Properties.MaxRepairResource), }; return; } // Mechanical key + has limited uses if (itemHelper.IsOfBaseclass(itemDetails.Id, BaseClasses.KEY_MECHANICAL) && (itemDetails.Properties.MaximumNumberOfUsage ?? 0) > 1) { itemToAdjust.Upd.Key = new UpdKey { NumberOfUsages = randomUtil.GetInt(0, (int)itemDetails.Properties.MaximumNumberOfUsage - 1), }; return; } // Randomise items that use resources (e.g. fuel) if ((itemDetails.Properties.MaxResource ?? 0) > 0) { var resourceMax = itemDetails.Properties.MaxResource; var resourceCurrent = randomUtil.GetInt(1, (int)itemDetails.Properties.MaxResource); itemToAdjust.Upd.Resource = new UpdResource { Value = resourceMax - resourceCurrent, UnitsConsumed = resourceCurrent }; } } /** * Generate a randomised current and max durabiltiy value for an armor item * @param itemDetails Item to create values for * @param equipmentDurabilityLimits Max durabiltiy percent min/max values * @returns Durability + MaxDurability values */ protected UpdRepairable GetRandomisedArmorDurabilityValues( TemplateItem itemDetails, ItemDurabilityCurrentMax equipmentDurabilityLimits ) { var maxDuraMin = (equipmentDurabilityLimits.Max.Min / 100) * itemDetails.Properties.MaxDurability; var maxDuraMax = (equipmentDurabilityLimits.Max.Max / 100) * itemDetails.Properties.MaxDurability; var chosenMaxDurability = randomUtil.GetInt((int)maxDuraMin, (int)maxDuraMax); var currentDuraMin = (equipmentDurabilityLimits.Current.Min / 100) * itemDetails.Properties.MaxDurability; var currentDuraMax = (equipmentDurabilityLimits.Current.Max / 100) * itemDetails.Properties.MaxDurability; var chosenCurrentDurability = Math.Min( randomUtil.GetInt((int)currentDuraMin, (int)currentDuraMax), chosenMaxDurability ); return new UpdRepairable() { Durability = chosenCurrentDurability, MaxDurability = chosenMaxDurability }; } /** * Construct item limit record to hold max and current item count * @param limits limits as defined in config * @returns record, key: item tplId, value: current/max item count allowed */ protected Dictionary InitItemLimitCounter(Dictionary limits) { var itemTypeCounts = new Dictionary(); foreach (var x in limits.Keys) { itemTypeCounts[x] = new() { current = 0, max = limits[x] }; } return itemTypeCounts; } /** * Get the next Update timestamp for fence * @returns future timestamp */ public long GetNextFenceUpdateTimestamp() { var time = timeUtil.GetTimeStamp(); var UpdateSeconds = GetFenceRefreshTime(); return time + UpdateSeconds; } /** * Get fence refresh time in seconds * @returns Refresh time in seconds */ protected int GetFenceRefreshTime() { var fence = traderConfig.UpdateTime.FirstOrDefault((x) => x.TraderId == Traders.FENCE).Seconds; return randomUtil.GetInt((int)fence.Min, (int)fence.Max); } /** * Get fence level the passed in profile has * @param pmcData Player profile * @returns FenceLevel object */ public FenceLevel GetFenceInfo(PmcData pmcData) { var fenceSettings = databaseService.GetGlobals().Configuration.FenceSettings; var pmcFenceInfo = pmcData.TradersInfo[fenceSettings.FenceIdentifier]; if (pmcFenceInfo == null) { return fenceSettings.Levels["0"]; } var fenceLevels = fenceSettings.Levels.Keys.Select(int.Parse); var minLevel = fenceLevels.Min(); var maxLevel = fenceLevels.Max(); var pmcFenceLevel = Math.Floor((double)pmcFenceInfo.Standing); if (pmcFenceLevel < minLevel) { return fenceSettings.Levels[minLevel.ToString()]; } if (pmcFenceLevel > maxLevel) { return fenceSettings.Levels[maxLevel.ToString()]; } return fenceSettings.Levels[pmcFenceLevel.ToString()]; } /** * Remove or lower stack size of an assort from fence by id * @param assortId assort id to adjust * @param buyCount Count of items bought */ public void AmendOrRemoveFenceOffer(string assortId, int buyCount) { var isNormalAssort = true; var fenceAssortItem = fenceAssort.Items.FirstOrDefault((item) => item.Id == assortId); if (fenceAssortItem == null) { // Not in main assorts, check secondary section fenceAssortItem = fenceDiscountAssort.Items.FirstOrDefault((item) => item.Id == assortId); if (fenceAssortItem == null) { logger.Error(localisationService.GetText("fence-unable_to_find_offer_by_id", assortId)); return; } isNormalAssort = false; } // Player wants to buy whole stack, delete stack if ((fenceAssortItem.Upd?.StackObjectsCount ?? 0) == buyCount) { DeleteOffer(assortId, isNormalAssort ? fenceAssort.Items : fenceDiscountAssort.Items); return; } // Adjust stack size fenceAssortItem.Upd.StackObjectsCount -= buyCount; } protected void DeleteOffer(string assortId, List assorts) { // Assort could have child items, remove those too var itemWithChildrenToRemove = itemHelper.FindAndReturnChildrenAsItems(assorts, assortId); foreach (var itemToRemove in itemWithChildrenToRemove) { var indexToRemove = assorts.FindIndex((item) => item.Id == itemToRemove.Id); // No offer found in main assort, check discount items if (indexToRemove == -1) { indexToRemove = fenceDiscountAssort.Items.FindIndex((item) => item.Id == itemToRemove.Id); fenceDiscountAssort.Items.Splice(indexToRemove, 1); if (indexToRemove == -1) { logger.Warning( $"unable to remove fence assort item: {itemToRemove.Id} tpl: {itemToRemove.Template}" ); } return; } // Remove offer from assort assorts.Splice(indexToRemove, 1); } } }