using SPTarkov.Common.Extensions; 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.Enums; using SPTarkov.Server.Core.Models.Spt.Config; using SPTarkov.Server.Core.Models.Spt.Fence; using SPTarkov.Server.Core.Models.Utils; using SPTarkov.Server.Core.Servers; using SPTarkov.Server.Core.Utils; using SPTarkov.Server.Core.Utils.Cloners; namespace SPTarkov.Server.Core.Services; [Injectable(InjectionType.Singleton)] public class FenceService( ISptLogger logger, TimeUtil timeUtil, RandomUtil randomUtil, DatabaseService databaseService, HandbookHelper handbookHelper, ItemHelper itemHelper, PresetHelper presetHelper, ServerLocalisationService localisationService, ConfigServer configServer, ICloner _cloner ) { /// /// Desired baseline counts - Hydrated on initial assort generation as part of generateFenceAssorts() /// protected FenceAssortGenerationValues desiredAssortCounts; /// /// Main assorts you see at all rep levels /// protected TraderAssort? fenceAssort; /// /// Assorts shown on a separate tab when you max out fence rep /// protected TraderAssort? fenceDiscountAssort; protected readonly HashSet fenceItemUpdCompareProperties = [ "Buff", "Repairable", "RecodableComponent", "Key", "Resource", "MedKit", "FoodDrink", "Dogtag", "RepairKit", ]; /// /// Time when some items in assort will be replaced /// protected long nextPartialRefreshTimestamp; protected readonly TraderConfig traderConfig = configServer.GetConfig(); /// /// Replace main fence assort with new assort /// /// New assorts to replace old with public void SetFenceAssort(TraderAssort assort) { fenceAssort = assort; } /// /// Replace discount fence assort with new assort /// /// New assorts to replace old with public void SetFenceDiscountAssort(TraderAssort discountAssort) { fenceDiscountAssort = discountAssort; } /// /// Get main fence assort /// /// TraderAssort public TraderAssort? GetMainFenceAssort() { return fenceAssort; } /// /// Get discount fence assort /// /// TraderAssort /// @return ITraderAssort public TraderAssort? GetDiscountFenceAssort() { return fenceDiscountAssort; } /// /// Get assorts player can purchase
/// Adjust prices based on fence level of player ///
/// Player profile /// TraderAssort 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) /// /// The items to add with all its children /// 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(items.FindAndReturnChildrenAsItems(mainItem.Id)); // I BLAME LACY FOR THIS ISSUE, I SPENT HOURS FIXING IT /s // i think on node the one with hideout usually came first var root = clonedItems.FirstOrDefault(x => x.SlotId == "hideout"); 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 Dictionary>>(), LoyalLevelItems = new Dictionary(), }; 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) /// /// The item tpl to calculate the fence price for /// The items (with its children) to calculate fence price for /// Price of the item for Fence 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 /// /// The ammo box (and all its children ammo items) /// 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 /// /// (clone) Assort that contains items with prices to adjust /// Multiplier to use on items /// Multiplier to use on presets protected void AdjustAssortItemPricesByConfigMultiplier( TraderAssort assort, double itemMultiplier, double presetMultiplier ) { // Only get root items foreach (var item in assort.Items.Where(x => x.SlotId is "hideout")) { AdjustItemPriceByModifier(item, assort, itemMultiplier, presetMultiplier); } } /// /// Merge two trader assort files together /// /// Assort #1 /// Assort #2 /// Merged assort // TODO: can be moved to a helper? 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 /// /// Assort item details /// Assort to be modified /// Value to multiply item price by /// Value to multiply preset price by protected void AdjustItemPriceByModifier( Item item, TraderAssort assort, double modifier, double presetModifier ) { if (assort?.BarterScheme is null) { logger.Warning( $"Unable to adjust item: {item.Id} on assort as it lacks a barterScheme object" ); return; } // Is preset if (item.Upd?.SptPresetId != null) { if (assort.BarterScheme.TryGetValue(item.Id, out var barterSchemeForPreset)) { barterSchemeForPreset[0][0].Count *= presetModifier; } return; } // Normal item if (assort.BarterScheme.TryGetValue(item.Id, out var barterScheme)) { barterScheme[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 /// /// TraderAssort 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 /// /// 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 /// /// Assorts to fold into existing fence assorts /// 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 = existingFenceAssorts.Items.FindAndReturnChildrenAsItems( existingRootItem.Id ); if ( itemHelper.IsSameItems( itemWithChildren, existingFullItemTree, fenceItemUpdCompareProperties ) ) { // Guard against a missing stack count if (existingRootItem.Upd?.StackObjectsCount == null) { existingRootItem.Upd ??= new Upd(); existingRootItem.Upd.StackObjectsCount = 1; } // Merge new items count into existing, don't add new loyalty/barter data as it already exists existingRootItem.Upd.StackObjectsCount += newRootItem?.Upd?.StackObjectsCount ?? 1; continue; } } // if the Upd doesn't exist just initialize it newRootItem.Upd ??= new Upd { StackObjectsCount = 1 }; // 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 /// /// Current assorts after items have been removed /// Base counts assorts should be adjusted to /// GenerationAssortValues 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) /// /// Number of items to replace /// Assort to adjust 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 /// /// Trader assort to remove item from /// Pool of root items to pick from to remove protected void RemoveRandomItemFromAssorts(TraderAssort assort, List rootItems) { // Pick a random root item to remove from Fence 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 if its > 1 var itemCountToRemove = randomUtil.GetDouble(1, stackSize); // Check if we're removing all or just part of the item 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 Upd(); } // Reduce stack to at smallest, 1 rootItemToAdjust.Upd.StackObjectsCount -= Math.Max(1, itemCountToRemove); return; } // Remove item + child mods (if any) var itemWithChildren = assort.Items.FindAndReturnChildrenAsItems(rootItemToAdjust.Id); foreach (var itemToDelete in itemWithChildren) // Delete item from assort items array { assort.Items.Remove(itemToDelete); } // 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 percentage from traderConfig value /// /// Total item count /// 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 /// /// Count of fence offers 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 generation 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 /// /// Generated assorts that will be converted /// TraderAssort in the correct data format for Fence protected TraderAssort ConvertIntoFenceAssort(CreateFenceAssortsResult intermediaryAssorts) { var result = CreateFenceAssortSkeleton(); foreach (var itemWithChildren in intermediaryAssorts.SptItems) { result.Items.AddRange(itemWithChildren); } 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( traderConfig.Fence.WeaponPresetMinMax.Min, traderConfig.Fence.WeaponPresetMinMax.Max ); result.Normal.EquipmentPreset = randomUtil.GetInt( traderConfig.Fence.EquipmentPresetMinMax.Min, traderConfig.Fence.EquipmentPresetMinMax.Max ); result.Discount.Item = traderConfig.Fence.DiscountOptions.AssortSize; result.Discount.WeaponPreset = randomUtil.GetInt( traderConfig.Fence.DiscountOptions.WeaponPresetMinMax.Min, traderConfig.Fence.DiscountOptions.WeaponPresetMinMax.Max ); result.Discount.EquipmentPreset = randomUtil.GetInt( traderConfig.Fence.DiscountOptions.EquipmentPresetMinMax.Min, traderConfig.Fence.DiscountOptions.EquipmentPresetMinMax.Max ); desiredAssortCounts = result; } /// /// Create skeleton to hold assort items /// /// TraderAssort object protected TraderAssort CreateFenceAssortSkeleton() { return new TraderAssort { Items = [], BarterScheme = new Dictionary>>(), LoyalLevelItems = new Dictionary(), NextResupply = GetNextFenceUpdateTimestamp(), }; } /// /// Hydrate assorts parameter object with generated assorts /// /// Number of items to generate per type (Item, WeaponPreset, EquipmentPreset) /// Loyalty level to set new item to /// CreateFenceAssortResult object protected CreateFenceAssortsResult CreateAssorts( GenerationAssortValues itemCounts, int loyaltyLevel ) { var result = new CreateFenceAssortsResult { SptItems = [], BarterScheme = new Dictionary>>(), LoyalLevelItems = new Dictionary(), }; 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) { AddPresetsToAssort( itemCounts.WeaponPreset, itemCounts.EquipmentPreset, result, baseFenceAssortClone, loyaltyLevel ); } return result; } /// /// Add item assorts to existing assort data /// /// Number to add /// Data to add to /// Base data to draw from /// Item limits per base class /// 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 => string.Equals(item.ParentId, "hideout", StringComparison.OrdinalIgnoreCase) && item.Upd?.SptPresetId == null ) .ToList(); if (assortRootItems.Count == 0) { logger.Error(localisationService.GetText("fence-unable_to_find_root_item_to_add")); 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; } // Filter out root items from pool var childItemsAndSingleRoot = baseFenceAssortClone .Items.Where(item => !string.Equals(item.ParentId, "hideout", StringComparison.Ordinal) || string.Equals(item.Id, chosenBaseAssortRoot.Id, StringComparison.Ordinal) ) .ToList(); var desiredAssortItemAndChildrenClone = _cloner.Clone( childItemsAndSingleRoot.FindAndReturnChildrenAsItems(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 don't 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 /// /// item to look for a match against /// DB details of matching item /// Items to search through /// 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 && string.Equals(item.ParentId, "hideout", StringComparison.OrdinalIgnoreCase) ) != 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 it's 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 /// /// Existing item from fence assort /// Item we want to add DB details /// 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) /// /// All barter scheme for item having price adjusted /// Root item having price adjusted /// 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 multiplier = current / itemTotalMax; // Multiply item cost by desired multiplier var basePrice = barterSchemes[itemRoot.Id][0][0].Count; barterSchemes[itemRoot.Id][0][0].Count = Math.Round(basePrice.Value * multiplier.Value); 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 /// /// How many WeaponPresets to add /// How many WeaponPresets to add /// Assorts to add preset to /// Base data to draw from /// Loyalty level to set new presets to protected void AddPresetsToAssort( int? desiredWeaponPresetsCount, int? desiredEquipmentPresetsCount, CreateFenceAssortsResult assorts, TraderAssort baseFenceAssort, int loyaltyLevel ) { var failedAttemptsCount = 0; var weaponPresetsAddedCount = 0; if (desiredWeaponPresetsCount > 0) { var weaponPresetRootItems = baseFenceAssort.Items.Where(item => item.Upd?.SptPresetId is not null && itemHelper.IsOfBaseclass(item.Template, BaseClasses.WEAPON) && !traderConfig.Fence.Blacklist.Contains(item.Template) ); while (weaponPresetsAddedCount < desiredWeaponPresetsCount) { var randomPresetRoot = randomUtil.GetArrayValue(weaponPresetRootItems); var rootItemDb = itemHelper.GetItem(randomPresetRoot.Template).Value; var presetWithChildrenClone = _cloner.Clone( baseFenceAssort.Items.FindAndReturnChildrenAsItems(randomPresetRoot.Id) ); RandomiseItemUpdProperties(rootItemDb, presetWithChildrenClone[0]); // Simulate players listing weapons with parts removed RemoveRandomModsOfItem(presetWithChildrenClone); // Check chosen preset is below listing cap in config var presetPrice = handbookHelper.GetTemplatePriceForItems(presetWithChildrenClone) * itemHelper.GetItemQualityModifierForItems(presetWithChildrenClone); if ( traderConfig.Fence.ItemCategoryRoublePriceLimit.TryGetValue( rootItemDb.Parent, out var priceLimitRouble ) ) { if (presetPrice > priceLimitRouble) // Too expensive, try again { failedAttemptsCount++; if (failedAttemptsCount > 25) { logger.Warning( $"Unable to add: {desiredWeaponPresetsCount} presets to Fence as all presets found after 25 attempts were too expensive." ); break; } 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, fix 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(presetPrice), }, ], ]; 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( baseFenceAssort.Items.FindAndReturnChildrenAsItems(randomPresetRoot.Id) ); // Need to add mods to armors so they don't 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 /// /// Armor item array to add mods into /// 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 /// /// Slots of items to randomise /// 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 => string.Equals( mod.SlotId, requiredSlot.Name.ToLower(), StringComparison.OrdinalIgnoreCase ) ); itemHelper.AddUpdObjectToItem(modItemToAdjust); // Ensure property isn't 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 ///
/// Slots of items to randomise /// 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 => !string.Equals( item.SlotId, plateSlot.Name, StringComparison.CurrentCultureIgnoreCase ) ) .ToList(); continue; } var durabilityValues = GetRandomisedArmorDurabilityValues( modItemDbDetails, traderConfig.Fence.ArmorMaxDurabilityPercentMinMax ); // Find items mod to apply durability changes to var modItemToAdjust = armorWithMods.FirstOrDefault(mod => string.Equals(mod.SlotId, plateSlot.Name, StringComparison.OrdinalIgnoreCase) ); 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) /// /// Item being added to fence /// 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(overrideValues.Min, overrideValues.Max); } // No override, use stack max size from item db return itemDbDetails.Properties.StackMaxSize == 1 ? 1 : randomUtil.GetInt( itemDbDetails.Properties.StackMinRandom.Value, itemDbDetails.Properties.StackMaxRandom.Value ); } // Check for override in config, use values if exists if ( traderConfig.Fence.ItemStackSizeOverrideMinMax.TryGetValue( itemDbDetails.Id, out overrideValues ) ) { return randomUtil.GetInt(overrideValues.Min, overrideValues.Max); } // Check for parent override if ( traderConfig.Fence.ItemStackSizeOverrideMinMax.TryGetValue( itemDbDetails.Parent, out overrideValues ) ) { return randomUtil.GetInt(overrideValues.Min, overrideValues.Max); } return 1; } /// /// Remove parts of a weapon prior to being listed on flea /// /// Weapon to remove parts from protected void RemoveRandomModsOfItem(List itemAndMods) { // Items to be removed from inventory var toDelete = new HashSet(); // 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.UnionWith(itemAndMods.FindAndReturnChildrenByItems(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 /// /// Weapon mod being checked /// Current list of items on weapon being deleted /// True if item will be removed protected bool PresetModItemWillBeRemoved(Item weaponMod, HashSet 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 /// /// Item being randomised /// 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, itemDetails.Properties.MaxHpResource.Value), }; } // Randomise armor durability if ( itemDetails.Parent is BaseClasses.ARMORED_EQUIPMENT or BaseClasses.FACECOVER or BaseClasses.ARMOR_PLATE && itemDetails.Properties.MaxDurability.GetValueOrDefault(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.GetDouble(maxDuraMin.Value, maxDuraMax.Value); var currentDuraMin = weaponDurabilityLimits.Current.Min / 100 * itemDetails.Properties.MaxDurability; var currentDuraMax = weaponDurabilityLimits.Current.Max / 100 * itemDetails.Properties.MaxDurability; var currentDurability = Math.Min( randomUtil.GetDouble(currentDuraMin.Value, currentDuraMax.Value), 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.GetDouble(1, itemDetails.Properties.MaxRepairResource.Value), }; 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, itemDetails.Properties.MaximumNumberOfUsage.Value - 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, itemDetails.Properties.MaxResource.Value); itemToAdjust.Upd.Resource = new UpdResource { Value = resourceMax - resourceCurrent, UnitsConsumed = resourceCurrent, }; } } /// /// Generate a randomised current and max durability value for an armor item /// /// Item to create values for /// Max durability percent min/max values /// 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.GetDouble(maxDuraMin.Value, maxDuraMax.Value); var currentDuraMin = equipmentDurabilityLimits.Current.Min / 100 * itemDetails.Properties.MaxDurability; var currentDuraMax = equipmentDurabilityLimits.Current.Max / 100 * itemDetails.Properties.MaxDurability; var chosenCurrentDurability = Math.Min( randomUtil.GetDouble(currentDuraMin.Value, currentDuraMax.Value), chosenMaxDurability ); return new UpdRepairable { Durability = chosenCurrentDurability, MaxDurability = chosenMaxDurability, }; } /// /// Construct item limit record to hold max and current item count /// /// Limits as defined in config /// 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 ValueTuple(0, limits[x]); } return itemTypeCounts; } /// /// Get the next Update timestamp for fence /// /// Future timestamp public long GetNextFenceUpdateTimestamp() { var time = timeUtil.GetTimeStamp(); var updateSeconds = GetFenceRefreshTime(); return time + updateSeconds; } /// /// Get fence refresh time in seconds /// /// Refresh time in seconds protected int GetFenceRefreshTime() { var fence = traderConfig .UpdateTime.FirstOrDefault(x => x.TraderId == Traders.FENCE) .Seconds; return randomUtil.GetInt(fence.Min, fence.Max); } /// /// Get fence level the passed in profile has /// /// Player profile /// FenceLevel object public FenceLevel GetFenceInfo(PmcData pmcData) { var fenceSettings = databaseService.GetGlobals().Configuration.FenceSettings; if (!pmcData.TradersInfo.TryGetValue(fenceSettings.FenceIdentifier, out var pmcFenceInfo)) { return fenceSettings.Levels[0]; } var fenceLevels = fenceSettings.Levels.Keys; var minLevel = fenceLevels.Min(); var maxLevel = fenceLevels.Max(); var pmcFenceLevel = Math.Floor(pmcFenceInfo.Standing.Value); if (pmcFenceLevel < minLevel) { return fenceSettings.Levels[minLevel]; } if (pmcFenceLevel > maxLevel) { return fenceSettings.Levels[maxLevel]; } return fenceSettings.Levels[pmcFenceLevel]; } /// /// Remove or lower stack size of an assort from fence by id /// /// Assort ID to adjust /// `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 = assorts.FindAndReturnChildrenAsItems(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); } } }