using System.Collections.Frozen; using SPTarkov.DI.Annotations; using SPTarkov.Server.Core.Exceptions.Helpers; using SPTarkov.Server.Core.Extensions; 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.Inventory; using SPTarkov.Server.Core.Models.Utils; using SPTarkov.Server.Core.Services; using SPTarkov.Server.Core.Utils; using SPTarkov.Server.Core.Utils.Cloners; using SPTarkov.Server.Core.Utils.Collections; using LogLevel = SPTarkov.Server.Core.Models.Spt.Logging.LogLevel; namespace SPTarkov.Server.Core.Helpers; [Injectable(InjectionType.Singleton)] public class ItemHelper( ISptLogger logger, RandomUtil randomUtil, DatabaseService databaseService, HandbookHelper handbookHelper, ItemBaseClassService itemBaseClassService, ItemFilterService itemFilterService, ServerLocalisationService serverLocalisationService, LocaleService localeService, ICloner cloner ) { protected static readonly FrozenSet _defaultInvalidBaseTypes = [ BaseClasses.LOOT_CONTAINER, BaseClasses.MOB_CONTAINER, BaseClasses.STASH, BaseClasses.SORTING_TABLE, BaseClasses.INVENTORY, BaseClasses.STATIONARY_CONTAINER, BaseClasses.POCKETS, ]; protected static readonly FrozenSet _slotsAsStrings = [ nameof(EquipmentSlots.Headwear), nameof(EquipmentSlots.Earpiece), nameof(EquipmentSlots.FaceCover), nameof(EquipmentSlots.ArmorVest), nameof(EquipmentSlots.Eyewear), nameof(EquipmentSlots.ArmBand), nameof(EquipmentSlots.TacticalVest), nameof(EquipmentSlots.Pockets), nameof(EquipmentSlots.Backpack), nameof(EquipmentSlots.SecuredContainer), nameof(EquipmentSlots.FirstPrimaryWeapon), nameof(EquipmentSlots.SecondPrimaryWeapon), nameof(EquipmentSlots.Holster), nameof(EquipmentSlots.Scabbard), ]; protected static readonly FrozenSet _dogTagTpls = [ ItemTpl.BARTER_DOGTAG_BEAR, ItemTpl.BARTER_DOGTAG_BEAR_EOD, ItemTpl.BARTER_DOGTAG_BEAR_TUE, ItemTpl.BARTER_DOGTAG_USEC, ItemTpl.BARTER_DOGTAG_USEC_EOD, ItemTpl.BARTER_DOGTAG_USEC_TUE, ItemTpl.BARTER_DOGTAG_BEAR_PRESTIGE_1, ItemTpl.BARTER_DOGTAG_BEAR_PRESTIGE_2, ItemTpl.BARTER_DOGTAG_BEAR_PRESTIGE_3, ItemTpl.BARTER_DOGTAG_BEAR_PRESTIGE_4, ItemTpl.BARTER_DOGTAG_USEC_PRESTIGE_1, ItemTpl.BARTER_DOGTAG_USEC_PRESTIGE_2, ItemTpl.BARTER_DOGTAG_USEC_PRESTIGE_3, ItemTpl.BARTER_DOGTAG_USEC_PRESTIGE_4, ]; protected static readonly FrozenSet _softInsertIds = [ "groin", "groin_back", "soft_armor_back", "soft_armor_front", "soft_armor_left", "soft_armor_right", "shoulder_l", "shoulder_r", "collar", "helmet_top", "helmet_back", "helmet_eyes", "helmet_jaw", "helmet_ears", ]; protected static readonly FrozenSet _removablePlateSlotIds = [ "front_plate", "back_plate", "left_side_plate", "right_side_plate", ]; protected static readonly FrozenSet _armorSlotsThatCanHoldMods = [BaseClasses.HEADWEAR, BaseClasses.VEST, BaseClasses.ARMOR]; /// /// Does the provided pool of items contain the desired item /// /// Item collection to check /// Item to look for /// OPTIONAL - slotId of desired item /// True if pool contains item public bool HasItemWithTpl(IEnumerable itemPool, MongoId itemTpl, string slotId = "") { // Filter the pool by slotId if provided var filteredPool = string.IsNullOrEmpty(slotId) ? itemPool : itemPool.Where(itemInPool => itemInPool.SlotId?.StartsWith(slotId, StringComparison.OrdinalIgnoreCase) ?? false); // Check if any item in the filtered pool matches the provided item return filteredPool.Any(poolItem => poolItem.Template == itemTpl); } /// /// Get the first item from provided pool with the desired tpl /// /// Item collection to search /// Item tpl to find /// OPTIONAL - slotId of desired item /// Item or null if no item found public Item? GetItemFromPoolByTpl(IEnumerable itemPool, MongoId tpl, string slotId = "") { // Filter the pool by slotId if provided var filteredPool = string.IsNullOrEmpty(slotId) ? itemPool : itemPool.Where(item => item.SlotId?.StartsWith(slotId, StringComparison.OrdinalIgnoreCase) ?? false); // Check if any item in the filtered pool matches the provided item return filteredPool.FirstOrDefault(poolItem => poolItem.Template == tpl); } /// /// This method will compare two items (with all its children) and see if they are equivalent /// This method will NOT compare IDs on the items /// /// first item with all its children to compare /// second item with all its children to compare /// Upd properties to compare between the items /// true if they are the same public bool IsSameItems(ICollection item1, ICollection item2, ISet? compareUpdProperties = null) { if (item1.Count != item2.Count) { // Items have different mod counts return false; } foreach (var itemOf1 in item1) { var itemOf2 = item2.FirstOrDefault(i2 => i2.Template.Equals(itemOf1.Template)); if (itemOf2 is null) { return false; } if (!itemOf1.IsSameItem(itemOf2, compareUpdProperties)) { return false; } } return true; } /// /// Helper method to generate an Upd based on a template /// /// The item template to generate an Upd for /// An Upd with all the default properties set public Upd GenerateUpdForItem(TemplateItem itemTemplate) { Upd itemProperties = new(); // Armors, etc if (itemTemplate.Properties?.MaxDurability is not null) { itemProperties.Repairable = new UpdRepairable { Durability = itemTemplate.Properties.MaxDurability, MaxDurability = itemTemplate.Properties.MaxDurability, }; } if (itemTemplate.Properties?.HasHinge ?? false) { itemProperties.Togglable = new UpdTogglable { On = true }; } if (itemTemplate.Properties?.Foldable ?? false) { itemProperties.Foldable = new UpdFoldable { Folded = false }; } if (itemTemplate.Properties?.WeapFireType?.Count == 0) { itemProperties.FireMode = itemTemplate.Properties.WeapFireType.Contains("fullauto") ? new UpdFireMode { FireMode = "fullauto" } : new UpdFireMode { FireMode = randomUtil.GetArrayValue(itemTemplate.Properties.WeapFireType) }; } if (itemTemplate.Properties?.MaxHpResource is not null) { itemProperties.MedKit = new UpdMedKit { HpResource = itemTemplate.Properties.MaxHpResource }; } if (itemTemplate.Properties?.MaxResource is not null && itemTemplate.Properties.FoodUseTime is not null) { itemProperties.FoodDrink = new UpdFoodDrink { HpPercent = itemTemplate.Properties.MaxResource }; } if (itemTemplate.Parent == BaseClasses.FLASHLIGHT || itemTemplate.Parent == BaseClasses.TACTICAL_COMBO) { itemProperties.Light = new UpdLight { IsActive = false, SelectedMode = 0 }; } if (itemTemplate.Parent == BaseClasses.NIGHT_VISION) { itemProperties.Togglable = new UpdTogglable { On = false }; } // Toggleable face shield if ((itemTemplate.Properties?.HasHinge ?? false) && (itemTemplate.Properties.FaceShieldComponent ?? false)) { itemProperties.Togglable = new UpdTogglable { On = false }; } return itemProperties; } /// /// Checks if a tpl is a valid item. Valid meaning that it's an item that can be stored in stash ///

/// Valid means: ///
/// Not quest item ///
/// 'Item' type ///
/// Not on the invalid base types array ///
/// Price above 0 roubles ///
///
/// Template id to check /// OPTIONAL - Base types deemed invalid /// true for items that may be in player possession and not quest items public bool IsValidItem(MongoId tpl, ISet? invalidBaseTypes = null) { var baseTypes = invalidBaseTypes ?? _defaultInvalidBaseTypes; var itemDetails = GetItem(tpl); if (itemDetails.Value is null) { return false; } return itemDetails.Key && IsValidItem(itemDetails.Value, baseTypes); } /// /// Checks if a tpl is a valid item. Valid meaning that it's an item that can be stored in stash /// Valid means: /// Not quest item /// 'Item' type /// Not on the invalid base types array /// Price above 0 roubles /// /// Item from DB to check /// OPTIONAL - Base types deemed invalid /// true for items that may be in player possession and not quest items public bool IsValidItem(TemplateItem item, ISet? invalidBaseTypes = null) { var baseTypes = invalidBaseTypes ?? _defaultInvalidBaseTypes; return !(item.Properties?.QuestItem ?? false) && string.Equals(item.Type, "Item", StringComparison.OrdinalIgnoreCase) && GetItemPrice(item.Id) > 0 && !itemFilterService.IsItemBlacklisted(item.Id) && baseTypes.All(x => !IsOfBaseclass(item.Id, x)); } /// /// Check if the tpl / template id provided is a descendant of the baseclass /// /// Item template id to check /// Baseclass to check for /// is the tpl a descendant public bool IsOfBaseclass(MongoId tpl, MongoId baseClassTpl) { return itemBaseClassService.ItemHasBaseClass(tpl, baseClassTpl); } /// /// Check if item has any of the supplied base classes /// /// Item to check base classes of /// Base classes to check for /// True if any supplied base classes match public bool IsOfBaseclasses(MongoId tpl, IEnumerable baseClassTpls) { return itemBaseClassService.ItemHasBaseClass(tpl, baseClassTpls); } /// /// Does the provided item have the chance to require soft armor inserts /// Only applies to helmets/vest/armors /// Not all headgear needs them /// /// Tpl to check /// Does item have the possibility ot need soft inserts public bool ArmorItemCanHoldMods(MongoId itemTpl) { return IsOfBaseclasses(itemTpl, _armorSlotsThatCanHoldMods); } /// /// Does the provided item tpl need soft/removable inserts to function /// /// Armor item /// True if item needs some kind of insert public bool ArmorItemHasRemovableOrSoftInsertSlots(MongoId itemTpl) { if (!ArmorItemCanHoldMods(itemTpl)) { return false; } return ArmorItemHasRemovablePlateSlots(itemTpl) || ItemRequiresSoftInserts(itemTpl); } /// /// Does the provided tpl have ability to hold removable plate items /// /// Item tpl to check for plate support /// True when armor can hold plates public bool ArmorItemHasRemovablePlateSlots(MongoId itemTpl) { var itemTemplate = GetItem(itemTpl); return itemTemplate.Value?.Properties?.Slots is not null && itemTemplate.Value.Properties.Slots.Any(slot => _removablePlateSlotIds.Contains(slot.Name?.ToLowerInvariant() ?? string.Empty) ); } /// /// Does the provided item tpl require soft inserts to become a valid armor item /// /// Item tpl to check /// True if it needs armor inserts public bool ItemRequiresSoftInserts(MongoId itemTpl) { // Not a slot that takes soft-inserts if (!ArmorItemCanHoldMods(itemTpl)) { return false; } // Check is an item var itemDbDetails = GetItem(itemTpl); if (!itemDbDetails.Key) { return false; } // Has no slots if (!(itemDbDetails.Value?.Properties?.Slots ?? []).Any()) { return false; } // Check if item has slots that match soft insert name ids return itemDbDetails.Value?.Properties?.Slots?.Any(slot => IsSoftInsertId(slot.Name?.ToLowerInvariant() ?? string.Empty)) ?? false; } /// /// Get all soft insert slot ids /// /// A List of soft insert ids (e.g. soft_armor_back, helmet_top) public static FrozenSet GetSoftInsertSlotIds() { return _softInsertIds; } /// /// Does the passed in slot id match a soft insert id /// /// slotId value to check /// public bool IsSoftInsertId(string slotId) { return _softInsertIds.Contains(slotId); } /// /// Returns the items total price based on the handbook or as a fallback from the prices.json if the item is not /// found in the handbook. If the price can't be found at all return 0 /// /// item tpls to look up the price of /// Total price in roubles public double GetItemAndChildrenPrice(IEnumerable tpls) { // Run getItemPrice for each tpl in tpls array, return sum return tpls.Aggregate(0, (total, tpl) => total + (int)GetItemPrice(tpl).GetValueOrDefault(0)); } /// /// Returns the item price based on the handbook or as a fallback from the prices.json if the item is not /// found in the handbook. If the price can't be found at all return 0 /// /// Item to look price up of /// Price in roubles public double? GetItemPrice(MongoId tpl) { var handbookPrice = GetStaticItemPrice(tpl); if (handbookPrice >= 1) { return handbookPrice; } return GetDynamicItemPrice(tpl); } /// /// Returns the item price based on the handbook or as a fallback from the prices.json if the item is not /// found in the handbook. If the price can't be found at all return 0 /// /// Item to look price up of /// Price in roubles public double GetItemMaxPrice(MongoId tpl) { var staticPrice = GetStaticItemPrice(tpl); var dynamicPrice = GetDynamicItemPrice(tpl); return Math.Max(staticPrice, dynamicPrice ?? 0d); } /// /// Get the static (handbook) price in roubles for an item by tpl /// /// Items tpl id to look up price /// Price in roubles (0 if not found) public double GetStaticItemPrice(MongoId tpl) { var handbookPrice = handbookHelper.GetTemplatePrice(tpl); if (handbookPrice >= 1) { return handbookPrice; } return 0; } /// /// Get the dynamic (flea) price in roubles for an item by tpl /// /// Items tpl id to look up price /// Price in roubles (undefined if not found) public double? GetDynamicItemPrice(MongoId tpl) { if (databaseService.GetPrices().TryGetValue(tpl, out var price)) { return price; } return null; } /// /// Get cloned copy of all item data from items.json /// /// List of TemplateItem objects public List? GetItemsClone() { return cloner.Clone(databaseService.GetItems().Values.ToList()); } /// /// Gets item data from items.json /// /// template id to look up /// KvP, key = bool, value = template item object public KeyValuePair GetItem(MongoId itemTpl) { // -> Gets item from if (databaseService.GetItems().TryGetValue(itemTpl, out var item)) { return new KeyValuePair(true, item); } return new KeyValuePair(false, null); } /// /// Checks if the item has slots /// /// Template id of the item to check /// True if the item has slots public bool ItemHasSlots(MongoId itemTpl) { if (databaseService.GetItems().TryGetValue(itemTpl, out var item)) { return item.Properties?.Slots is not null && item.Properties.Slots.Any(); } return false; } /// /// Checks if the item is in the database /// /// Id of the item to check /// true if the item is in the database public bool IsItemInDb(MongoId itemTpl) { return databaseService.GetItems().ContainsKey(itemTpl); } /// /// Calculate the average quality of an item and its children /// /// An offers item to process /// Skip over armor items without durability /// % quality modifier between 0 and 1 public double GetItemQualityModifierForItems(IEnumerable itemWithChildren, bool skipArmorItemsWithoutDurability = false) { if (IsOfBaseclass(itemWithChildren.First().Template, BaseClasses.WEAPON)) { // Only root of weapon has durability return Math.Round(GetItemQualityModifier(itemWithChildren.First()), 5); } var qualityModifier = 0D; var itemsWithQualityCount = 0D; foreach (var item in itemWithChildren) { var result = GetItemQualityModifier(item, skipArmorItemsWithoutDurability); if (Math.Abs(result - (-1)) < 0.001) { // Is/near zero - Skip continue; } qualityModifier += result; itemsWithQualityCount++; } if (itemsWithQualityCount == 0) // Can happen when rigs without soft inserts or plates are listed { return 1; } return Math.Min(Math.Round(qualityModifier / itemsWithQualityCount, 5), 1); } /// /// Get normalized value (0-1) based on item condition /// Will return -1 for base armor items with 0 durability /// /// Item to check /// return -1 for armor items that have max durability of 0 /// Number between 0 and 1 public double GetItemQualityModifier(Item item, bool skipArmorItemsWithoutDurability = false) { // Default to 100% var result = 1d; // Is armor and has 0 max durability var itemDetails = GetItem(item.Template).Value; if (itemDetails?.Properties is null) { logger.Warning($"Item: {item.Template} lacks properties, cannot ascertain quality level, assuming 100%"); return 1; } if ( skipArmorItemsWithoutDurability && IsOfBaseclass(item.Template, BaseClasses.ARMOR) && itemDetails.Properties?.MaxDurability == 0 ) { return -1; } if (item.Upd is null) { return result; } if (item.Upd.MedKit is not null) { // Meds result = (item.Upd.MedKit.HpResource ?? 0) / (itemDetails.Properties?.MaxHpResource ?? 0); } else if (item.Upd.Repairable is not null) { result = GetRepairableItemQualityValue(itemDetails, item.Upd.Repairable, item); } else if (item.Upd.FoodDrink is not null) { result = (item.Upd.FoodDrink.HpPercent ?? 0) / (itemDetails.Properties?.MaxResource ?? 0); } else if (item.Upd.Key?.NumberOfUsages > 0 && itemDetails.Properties?.MaximumNumberOfUsage > 0) { // keys - keys count upwards, not down like everything else double maxNumOfUsages = itemDetails.Properties.MaximumNumberOfUsage.GetValueOrDefault(0); result = (maxNumOfUsages - item.Upd.Key.NumberOfUsages!.Value) / maxNumOfUsages; } else if (item.Upd.Resource?.UnitsConsumed > 0) // Item is less than 100% usage { // E.g. fuel tank result = (item.Upd.Resource.Value ?? 0) / (itemDetails.Properties?.MaxResource ?? 0); } else if (item.Upd.RepairKit is not null) { result = (item.Upd.RepairKit.Resource ?? 0) / (itemDetails.Properties?.MaxRepairResource ?? 0); } if (result == 0) // make item non-zero but still very low { result = 0.01; } return result; } /// /// Get a quality value based on a repairable item's current state between current and max durability /// /// Db details for item we want quality value for /// Repairable properties /// Item quality value is for /// number between 0 and 1 protected double GetRepairableItemQualityValue(TemplateItem itemDetails, UpdRepairable repairable, Item item) { // Edge case, durability above max if (repairable.Durability > repairable.MaxDurability) { logger.Debug( $"Max durability: {repairable.MaxDurability} for item id: {item.Id} was below durability: {repairable.Durability}, adjusting values to match" ); repairable.MaxDurability = repairable.Durability; } // Attempt to get the max durability from _props. If not available, use Repairable max durability value instead. var maxPossibleDurability = itemDetails.Properties?.MaxDurability ?? repairable.MaxDurability; var durability = repairable.Durability / maxPossibleDurability; if (durability == 0) { logger.Error(serverLocalisationService.GetText("item-durability_value_invalid_use_default", item.Template)); return 1; } return Math.Sqrt(durability ?? 0); } /// /// Find children of the item in a given assort (weapons parts for example, need recursive loop function) /// /// Template id of item to check for /// List of items to check in /// List of children of requested item public List FindAndReturnChildrenByAssort(MongoId itemIdToFind, IEnumerable assort) { // Group items by ParentId var lookup = assort.CreateParentIdLookupCache(out _); var results = new List(); var visitedCache = new HashSet(); var explorationStack = new Stack(); explorationStack.Push(itemIdToFind.ToString()); while (explorationStack.Count > 0) { var currentId = explorationStack.Pop(); if (!lookup.TryGetValue(currentId, out var childItems)) { continue; } foreach (var childItem in childItems) { // Store item in visited cache so it's not added to results more than once if (visitedCache.Add(childItem.Id)) { // Item not in visited cache, take it results.Add(childItem); // Add item to stack so it gets processed explorationStack.Push(childItem.Id); } } } return results; } /// /// Checks if the passed template id is a dog tag. /// /// Template id to check. /// True if it is a dogtag. public bool IsDogtag(MongoId tpl) { return _dogTagTpls.Contains(tpl); } /// /// Checks if the passed item can be stacked. /// /// Item to check. /// True if it can be stacked. public bool? IsItemTplStackable(MongoId tpl) { if (!databaseService.GetItems().TryGetValue(tpl, out var item)) { return null; } return item.Properties?.StackMaxSize > 1; } /// /// Splits the item stack if it exceeds its items StackMaxSize property into child items of the passed parent. /// /// Item to split into smaller stacks. /// List of root item + children. public List SplitStack(Item itemToSplit) { if (itemToSplit.Upd?.StackObjectsCount is null) { return [itemToSplit]; } var maxStackSize = GetItem(itemToSplit.Template).Value?.Properties?.StackMaxSize; var remainingCount = itemToSplit.Upd.StackObjectsCount; List rootAndChildren = []; // If the current count is already equal or less than the max // return the item as is. if (remainingCount <= maxStackSize) { rootAndChildren.Add(cloner.Clone(itemToSplit)!); return rootAndChildren; } while (remainingCount > 0) { var amount = Math.Min(remainingCount.Value, maxStackSize ?? 0); var newStackClone = cloner.Clone(itemToSplit); newStackClone!.Id = new MongoId(); newStackClone.Upd!.StackObjectsCount = amount; remainingCount -= amount; rootAndChildren.Add(newStackClone); } return rootAndChildren; } /// /// Turns items like money into separate stacks that adhere to max stack size. /// /// Item to split into smaller stacks. /// List of separate item stacks. public List> SplitStackIntoSeparateItems(Item itemToSplit) { var itemTemplate = GetItem(itemToSplit.Template).Value; var itemMaxStackSize = itemTemplate?.Properties?.StackMaxSize ?? 1; // item already within bounds of stack size, return it if (itemToSplit.Upd?.StackObjectsCount <= itemMaxStackSize) { return [ [itemToSplit], ]; } // Split items stack into chunks List> result = []; var remainingCount = itemToSplit.Upd?.StackObjectsCount; while (remainingCount != 0) { var amount = Math.Min(remainingCount ?? 0, itemMaxStackSize); var newItemClone = cloner.Clone(itemToSplit)!; newItemClone.Id = new MongoId(); newItemClone.Upd!.StackObjectsCount = amount; remainingCount -= amount; result.Add([newItemClone]); } return result; } /// /// Finds Barter items from a list of items. /// /// Tpl or id. /// Array of items to iterate over. /// List of desired barter item ids. /// List of Item objects. public List FindBarterItems(string by, IEnumerable itemsToSearch, IEnumerable desiredBarterItemIds) { // Find required items to take after buying (handles multiple items) List matchingItems = []; foreach (var barterId in desiredBarterItemIds) { var filteredResult = itemsToSearch.Where(item => by == "tpl" ? item.Template.Equals(barterId) : item.Id.Equals(barterId)); if (!filteredResult.Any()) { logger.Warning(serverLocalisationService.GetText("item-helper_no_items_for_barter", barterId)); continue; } matchingItems.AddRange(filteredResult); } return matchingItems; } /// /// Regenerate all GUIDs with new IDs, except special item types (e.g. quest, sorting table, etc.) /// This function mutates the bot inventory list. /// /// Inventory to replace Ids in /// Insured items that should not have their IDs replaced public void ReplaceProfileInventoryIds(BotBaseInventory inventory, IEnumerable? insuredItems = null) { // Blacklist var itemIdBlacklist = new HashSet(); itemIdBlacklist.UnionWith( new List { inventory.Equipment!.Value, inventory.QuestRaidItems!.Value, inventory.QuestStashItems!.Value, inventory.SortingTable!.Value, inventory.Stash!.Value, inventory.HideoutCustomizationStashId!.Value, } ); if (inventory.HideoutAreaStashes != null) { itemIdBlacklist.UnionWith(inventory.HideoutAreaStashes.Values); } // Add insured items ids to blacklist if (insuredItems is not null) { itemIdBlacklist.UnionWith(insuredItems.Select(x => x.ItemId!.Value)); } if (inventory.Items is null) { return; } foreach (var item in inventory.Items) { if (itemIdBlacklist.Contains(item.Id)) { continue; } // Generate new id var newId = new MongoId(); // Keep copy of original id var originalId = item.Id; // Update items id to new one we generated item.Id = newId; // Find all children of item and update their parent ids to match var childItems = inventory.Items.Where(x => x.ParentId is not null && x.ParentId == originalId); foreach (var childItem in childItems) { childItem.ParentId = newId; } // Also replace in quick slot if the old ID exists. if (inventory.FastPanel is null) { continue; } // Update quick-slot id var fastPanel = inventory.FastPanel; if (fastPanel.ContainsValue(originalId) && !TryReplaceFastPanelId(fastPanel, originalId, newId)) { logger.Error( $"Original Id: {originalId.ToString()} is contained in the fast panel, but was unable to replace it with new Id: {newId.ToString()}" ); } } } /// /// Regenerate all GUIDs with new IDs, except special item types (e.g. quest, sorting table, etc.) This /// function will not mutate the original items list, but will return a new list with new GUIDs. /// /// Items to adjust the IDs of /// Player profile /// Insured items that should not have their IDs replaced /// Items public IEnumerable ReplaceIDs(IEnumerable originalItems, PmcData? pmcData, IEnumerable? insuredItems = null) { // Blacklist var itemIdBlacklist = new HashSet(); if (pmcData != null) { itemIdBlacklist.UnionWith( new List { pmcData.Inventory!.Equipment!.Value, pmcData.Inventory.QuestRaidItems!.Value, pmcData.Inventory.QuestStashItems!.Value, pmcData.Inventory.SortingTable!.Value, pmcData.Inventory.Stash!.Value, pmcData.Inventory.HideoutCustomizationStashId!.Value, } ); if (pmcData.Inventory?.HideoutAreaStashes != null) { itemIdBlacklist.UnionWith(pmcData.Inventory.HideoutAreaStashes.Values); } } // Add insured items ids to blacklist if (insuredItems is not null) { itemIdBlacklist.UnionWith(insuredItems.Select(x => x.ItemId!.Value)); } foreach (var item in originalItems) { if (itemIdBlacklist.Contains(item.Id)) { continue; } // Generate new id var newId = new MongoId(); // Keep copy of original id var originalId = item.Id; // Update items id to new one we generated item.Id = newId; // Find all children of item and update their parent ids to match var childItems = originalItems.Where(x => x.ParentId != null && x.ParentId == originalId); foreach (var childItem in childItems) { childItem.ParentId = newId; } // Also replace in quick slot if the old ID exists. if (pmcData?.Inventory?.FastPanel is null) { continue; } // Update quick-slot id var fastPanel = pmcData.Inventory.FastPanel; if (fastPanel.ContainsValue(originalId) && !TryReplaceFastPanelId(fastPanel, originalId, newId)) { logger.Error( $"Original Id: {originalId.ToString()} is contained in the fast panel, but was unable to replace it with new Id: {newId.ToString()}" ); } } return originalItems; } /// /// Trys to find the original id in FastPanel, if it exists set it to the new value /// /// Fast panel dictionary to check /// Original id of the item /// New Id of the item /// True if replaced, otherwise false public bool TryReplaceFastPanelId(Dictionary fastPanel, MongoId originalId, MongoId newId) { var key = fastPanel.FirstOrDefault(kvp => kvp.Value == originalId).Key; if (key is null) { return false; } fastPanel[key] = newId; return true; } /// /// Mark the passed in list of items as found in raid. /// Modifies passed in items /// Will not flag ammo or currency as FiR /// /// The list of items to mark as FiR public void SetFoundInRaid(IEnumerable items) { foreach (var item in items) { if (IsOfBaseclasses(item.Template, [BaseClasses.MONEY, BaseClasses.AMMO])) { if (item.Upd is not null) { item.Upd.SpawnedInSession = null; } continue; } item.Upd ??= new Upd(); item.Upd.SpawnedInSession = true; } } /// /// Mark the passed in list of items as found in raid. /// Modifies passed in items /// /// The list of items to mark as FiR /// Skip adding FiR status to currency items public void SetFoundInRaid(Item item, bool excludeCurrency = true) { if (excludeCurrency && IsOfBaseclass(item.Template, BaseClasses.MONEY)) { return; } item.Upd ??= new Upd(); item.Upd.SpawnedInSession = true; } /// /// Checks to see if the item is *actually* moddable in-raid. Checks include the items existence in the database, the /// parent items existence in the database, the existence (and value) of the items `RaidModdable` property, and that /// the parents slot-required property exists, matches that of the item, and its value. /// /// The item to be checked /// The parent of the item to be checked /// True if the item is actually moddable, false if it is not, and null if the check cannot be performed. public bool? IsRaidModdable(Item item, Item parent) { // This check requires the item to have the slotId property populated. if (item.SlotId == null) { return null; } var itemTemplate = GetItem(item.Template); var parentTemplate = GetItem(parent.Template); // Check for RaidModdable property on the item template. var isNotRaidModdable = false; if (itemTemplate.Key) { isNotRaidModdable = itemTemplate.Value?.Properties?.RaidModdable == false; } // Check to see if the slot that the item is attached to is marked as required in the parent item's template. var isRequiredSlot = false; if (parentTemplate.Key && parentTemplate.Value?.Properties?.Slots != null) { isRequiredSlot = parentTemplate.Value?.Properties?.Slots?.Any(slot => slot.Name == item.SlotId && (slot.Required ?? false)) ?? false; } return itemTemplate.Key && parentTemplate.Key && !(isNotRaidModdable || isRequiredSlot); } /// /// Retrieves the main parent item for a given attachment item. /// This method traverses up the hierarchy of items starting from a given `itemId`, until it finds the main parent /// item that is not an attached attachment itself. In other words, if you pass it an item id of a suppressor, it /// will traverse up the muzzle brake, barrel, upper receiver, and return the gun that the suppressor is ultimately /// attached to, even if that gun is located within multiple containers. /// It's important to note that traversal is expensive, so this method requires that you pass it a Map of the items /// to traverse, where the keys are the item IDs and the values are the corresponding Item objects. This alleviates /// some of the performance concerns, as it allows for quick lookups of items by ID. /// /// The unique identifier of the item for which to find the main parent. /// A Dictionary containing item IDs mapped to their corresponding Item objects for quick lookup. /// The Item object representing the top-most parent of the given item, or null if no such parent exists. public Item? GetAttachmentMainParent(MongoId itemId, Dictionary itemsMap) { var currentItem = itemsMap.FirstOrDefault(x => x.Key == itemId).Value; while (currentItem != null && IsAttachmentAttached(currentItem)) { currentItem = itemsMap.FirstOrDefault(kvp => kvp.Key == currentItem.ParentId!).Value; if (currentItem == null) { return null; } } return currentItem; } /// /// Determines if an item is an attachment that is currently attached to its parent item /// /// The item to check /// true if the item is attached attachment, otherwise false public bool IsAttachmentAttached(Item item) { HashSet check = ["hideout", "main"]; var slotId = item.SlotId ?? string.Empty; return !( check.Contains(slotId) // Is root item || _slotsAsStrings.Contains(slotId) // Is root item in equipment slot e.g. `Headwear` || int.TryParse(item.SlotId, out _) ); // Has int as slotId, is inside container. e.g. cartridges } /// /// Retrieves the equipment parent item for a given item. /// /// This method traverses up the hierarchy of items starting from a given `itemId`, until it finds the equipment /// parent item. In other words, if you pass it an item id of a suppressor, it will traverse up the muzzle brake, /// barrel, upper receiver, gun, nested backpack, and finally return the backpack Item that is equipped. /// /// It's important to note that traversal is expensive, so this method requires that you pass it a Dictionary of the items /// to traverse, where the keys are the item IDs and the values are the corresponding Item objects. This alleviates /// some of the performance concerns, as it allows for quick lookups of items by ID. /// /// The unique identifier of the item for which to find the equipment parent. /// A Dictionary containing item IDs mapped to their corresponding Item objects for quick lookup. /// The Item object representing the equipment parent of the given item, or `null` if no such parent exists public Item? GetEquipmentParent(MongoId itemId, Dictionary itemsMap) { var currentItem = itemsMap.GetValueOrDefault(itemId); while (currentItem is not null && !_slotsAsStrings.Contains(currentItem.SlotId ?? string.Empty)) { currentItem = itemsMap.GetValueOrDefault(currentItem.ParentId ?? string.Empty); if (currentItem is null) { return null; } } return currentItem; } /// /// Get the inventory size of an item /// /// Item with children /// The base items root id /// ItemSize object (width and height) public ItemSize? GetItemSize(ICollection items, MongoId rootItemId) { var itemTemplate = items.FirstOrDefault(x => x.Id == rootItemId)?.Template; if (itemTemplate is null) { return null; } var rootTemplate = GetItem(itemTemplate.Value).Value; if (rootTemplate is null) { return null; } var width = rootTemplate.Properties?.Width; var height = rootTemplate.Properties?.Height; var sizeUp = 0; var sizeDown = 0; var sizeLeft = 0; var sizeRight = 0; var forcedUp = 0; var forcedDown = 0; var forcedLeft = 0; var forcedRight = 0; var itemWithChildren = items.GetItemWithChildren(rootItemId); foreach (var item in itemWithChildren) { var itemDbTemplate = GetItem(item.Template).Value; // Calculating child ExtraSize if (itemDbTemplate?.Properties?.ExtraSizeForceAdd ?? false) { forcedUp += itemDbTemplate.Properties.ExtraSizeUp!.Value; forcedDown += itemDbTemplate.Properties.ExtraSizeDown!.Value; forcedLeft += itemDbTemplate.Properties.ExtraSizeLeft!.Value; forcedRight += itemDbTemplate.Properties.ExtraSizeRight!.Value; } else { sizeUp = sizeUp < itemDbTemplate?.Properties?.ExtraSizeUp ? itemDbTemplate.Properties.ExtraSizeUp.Value : sizeUp; sizeDown = sizeDown < itemDbTemplate?.Properties?.ExtraSizeDown ? itemDbTemplate.Properties.ExtraSizeDown.Value : sizeDown; sizeLeft = sizeLeft < itemDbTemplate?.Properties?.ExtraSizeLeft ? itemDbTemplate.Properties.ExtraSizeLeft.Value : sizeLeft; sizeRight = sizeRight < itemDbTemplate?.Properties?.ExtraSizeRight ? itemDbTemplate.Properties.ExtraSizeRight.Value : sizeRight; } } return new ItemSize { Width = (width ?? 0) + sizeLeft + sizeRight + forcedLeft + forcedRight, Height = (height ?? 0) + sizeUp + sizeDown + forcedUp + forcedDown, }; } /// /// Add cartridges to the ammo box with correct max stack sizes /// /// Box to add cartridges to /// Item template from items db public void AddCartridgesToAmmoBox(List ammoBox, TemplateItem ammoBoxDetails) { var ammoBoxMaxCartridgeCount = ammoBoxDetails.Properties?.StackSlots?.First().MaxCount; var cartridgeTpl = ammoBoxDetails.Properties?.StackSlots?.First().Properties?.Filters?.First().Filter?.FirstOrDefault(); var cartridgeDetails = GetItem(cartridgeTpl!.Value); var cartridgeMaxStackSize = cartridgeDetails.Value?.Properties?.StackMaxSize; // Exit early if ammo already exists in box if (ammoBox.Any(item => item.Template.Equals(cartridgeTpl))) { return; } // Add new stack-size-correct items to ammo box double? currentStoredCartridgeCount = 0; var maxPerStack = Math.Min(ammoBoxMaxCartridgeCount ?? 0, cartridgeMaxStackSize ?? 0); // Find location based on Max ammo box size var location = Math.Ceiling(ammoBoxMaxCartridgeCount / maxPerStack ?? 0) - 1; while (currentStoredCartridgeCount < ammoBoxMaxCartridgeCount) { var remainingSpace = ammoBoxMaxCartridgeCount - currentStoredCartridgeCount; var cartridgeCountToAdd = remainingSpace < maxPerStack ? remainingSpace : maxPerStack; // Add cartridge item into items array var cartridgeItemToAdd = CreateCartridges(ammoBox[0].Id, cartridgeTpl.Value, (int)cartridgeCountToAdd, location); // In live no ammo box has the first cartridge item with a location if (location == 0) { cartridgeItemToAdd.Location = null; } ammoBox.Add(cartridgeItemToAdd); currentStoredCartridgeCount += cartridgeCountToAdd; location--; } } /// /// Add child items (cartridges) to a magazine /// /// Magazine to add child items to /// Db template of magazine /// Cartridge distribution /// Caliber of cartridge to add to magazine /// OPTIONAL - % the magazine must be filled to /// OPTIONAL -Cartridge to use when none found /// OPTIONAL -Weapon the magazine will be used for (if passed in uses Chamber as whitelist) public void FillMagazineWithRandomCartridge( List magazine, TemplateItem magTemplate, Dictionary> staticAmmoDist, string? caliber = null, double minSizePercent = 0.25, MongoId? defaultCartridgeTpl = null, TemplateItem? weapon = null ) { var chosenCaliber = caliber ?? GetRandomValidCaliber(magTemplate); switch (chosenCaliber) { case null: throw new ItemHelperException("Chosen caliber is null when trying to fill magazine with random cartridge"); // Edge case - Klin pp-9 has a typo in its ammo caliber case "Caliber9x18PMM": chosenCaliber = "Caliber9x18PM"; break; } // Chose a randomly weighted cartridge that fits var cartridgeTpl = DrawAmmoTpl( chosenCaliber, staticAmmoDist, defaultCartridgeTpl, weapon?.Properties?.Chambers?.FirstOrDefault()?.Properties?.Filters?.FirstOrDefault()?.Filter ?? null ); if (cartridgeTpl is null) { if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug($"Unable to fill item: {magazine.FirstOrDefault()?.Id} {magTemplate.Name} with cartridges, none found."); } return; } FillMagazineWithCartridge(magazine, magTemplate, cartridgeTpl.Value, minSizePercent); } /// /// Add child items to a magazine of a specific cartridge /// /// Magazine to add child items to /// Db template of magazine /// Cartridge to add to magazine /// % the magazine must be filled to public void FillMagazineWithCartridge( List magazineWithChildCartridges, TemplateItem magTemplate, MongoId cartridgeTpl, double minSizeMultiplier = 0.25 ) { var isUbgl = IsOfBaseclass(magTemplate.Id, BaseClasses.LAUNCHER); if (isUbgl) // UBGL don't have mags { return; } // Get cartridge properties and max allowed stack size var cartridgeDetails = GetItem(cartridgeTpl); if (!cartridgeDetails.Key) { logger.Error(serverLocalisationService.GetText("item-invalid_tpl_item", cartridgeTpl)); } var cartridgeMaxStackSize = cartridgeDetails.Value?.Properties?.StackMaxSize; if (cartridgeMaxStackSize is null) { logger.Error($"Item with tpl: {cartridgeTpl} lacks a _props or StackMaxSize property"); } // Get max number of cartridges in magazine, choose random value between min/max var magProperties = magTemplate.Properties; var magazineCartridgeMaxCount = IsOfBaseclass(magTemplate.Id, BaseClasses.SPRING_DRIVEN_CYLINDER) ? magProperties?.Slots?.Count() // Edge case for rotating grenade launcher magazine : magProperties?.Cartridges?.FirstOrDefault()?.MaxCount; if (magazineCartridgeMaxCount is null) { logger.Warning($"Magazine: {magTemplate.Id} {magTemplate.Name} lacks a Cartridges array, unable to fill magazine with ammo"); return; } var desiredStackCount = randomUtil.GetInt( (int)Math.Round(minSizeMultiplier * magazineCartridgeMaxCount.Value), (int)magazineCartridgeMaxCount ); if (magazineWithChildCartridges.Count > 1) { logger.Warning($"Magazine {magTemplate.Name} already has cartridges defined, this may cause issues"); } // Loop over cartridge count and add stacks to magazine var currentStoredCartridgeCount = 0; var location = 0; while (currentStoredCartridgeCount < desiredStackCount) { // Get stack size of cartridges var cartridgeCountToAdd = desiredStackCount <= cartridgeMaxStackSize ? desiredStackCount : cartridgeMaxStackSize; // Ensure we don't go over the max stackCount size var remainingSpace = desiredStackCount - currentStoredCartridgeCount; if (cartridgeCountToAdd > remainingSpace) { cartridgeCountToAdd = remainingSpace; } // Add cartridge item object into items array magazineWithChildCartridges.Add( CreateCartridges(magazineWithChildCartridges[0].Id, cartridgeTpl, cartridgeCountToAdd ?? 0, location) ); currentStoredCartridgeCount += cartridgeCountToAdd!.Value; location++; } // Only one cartridge stack added, remove location property as it's only used for 2 or more stacks if (location == 1) { magazineWithChildCartridges[1].Location = null; } } /// /// Choose a random bullet type from the list of possible a magazine has /// /// Magazine template from Db /// Tpl of cartridge protected string? GetRandomValidCaliber(TemplateItem magTemplate) { var ammoTpls = magTemplate.Properties?.Cartridges?.First().Properties?.Filters?.First().Filter; var calibers = ammoTpls?.Where(x => GetItem(x).Key).Select(x => GetItem(x).Value?.Properties?.Caliber).ToList(); if (calibers is null) { throw new ItemHelperException("Calibers is null when trying to generate random valid caliber"); } return randomUtil.DrawRandomFromList(calibers).FirstOrDefault(); } /// /// Chose a randomly weighted cartridge that fits /// /// Desired caliber /// Cartridges and their weights /// If a cartridge cannot be found in the above staticAmmoDist param, use this instead /// OPTIONAL whitelist for cartridges /// Tpl of cartridge protected MongoId? DrawAmmoTpl( string caliber, Dictionary> staticAmmoDist, MongoId? fallbackCartridgeTpl = null, ISet? cartridgeWhitelist = null ) { var ammos = staticAmmoDist.GetValueOrDefault(caliber, []); if (!ammos.Any()) { if (fallbackCartridgeTpl is not null) { logger.Warning( $"Unable to pick a cartridge for caliber: {caliber}, staticAmmoDist has no data. using fallback value of {fallbackCartridgeTpl}" ); return fallbackCartridgeTpl; } logger.Warning($"Unable to pick a cartridge for caliber: {caliber}, staticAmmoDist has no data. No fallback value provided"); return null; } var ammoArray = new ProbabilityObjectArray(cloner); foreach (var ammoDetails in ammos) { if (ammoDetails.Tpl is null) { logger.Error("Ammo details tpl is null when trying to draw ammo from pool"); continue; } // Whitelist exists and tpl not inside it, skip // Fixes 9x18mm kedr issues if (cartridgeWhitelist is not null && !cartridgeWhitelist.Contains(ammoDetails.Tpl.Value)) { continue; } ammoArray.Add( new ProbabilityObject(ammoDetails.Tpl.Value, (double)ammoDetails.RelativeProbability!.Value, null) ); } return ammoArray.Draw().FirstOrDefault(); } /// /// Create a basic cartridge object /// /// container cartridges will be placed in /// Cartridge to insert /// Count of cartridges inside parent /// Location inside parent (e.g. 0, 1) /// Item public Item CreateCartridges(MongoId parentId, MongoId ammoTpl, int stackCount, double location) { return new Item { Id = new MongoId(), Template = ammoTpl, ParentId = parentId, SlotId = "cartridges", Location = location, Upd = new Upd { StackObjectsCount = stackCount }, }; } /// /// Get the name of an item from the locale file using the item tpl /// /// Tpl of item to get name of /// Full name, short name if not found public string GetItemName(MongoId itemTpl) { var localeDb = localeService.GetLocaleDb(); // Key exists and it's not empty if (localeDb.TryGetValue($"{itemTpl} Name", out var result) && result.Length > 0) { return result; } // Main item "name" property not found, try the backup if (localeDb.TryGetValue($"{itemTpl} ShortName", out result)) { return result; } return string.Empty; } /// /// Get all item tpls with a desired base type /// /// Item base type wanted /// Array of tpls public IEnumerable GetItemTplsOfBaseType(string desiredBaseType) { return databaseService.GetItems().Values.Where(item => item.Parent == desiredBaseType).Select(item => item.Id); } /// /// Add child slot items to an item, chooses random child item if multiple choices exist /// /// array with single object (root item) /// Db template for root item /// Optional dictionary of mod name + % chance mod will be included in item (e.g. front_plate: 100) /// Only add required mods /// Item with children public List AddChildSlotItems( List itemToAdd, TemplateItem itemToAddTemplate, Dictionary? modSpawnChanceDict = null, bool requiredOnly = false ) { var result = itemToAdd; HashSet incompatibleModTpls = []; foreach (var slot in itemToAddTemplate.Properties?.Slots ?? []) { // If only required mods is requested, skip non-essential if (requiredOnly && !(slot.Required ?? false)) { continue; } // Roll chance for non-required slot mods if (modSpawnChanceDict is not null && !(slot.Required ?? false)) { // only roll chance to not include mod if dict exists and has value for this mod type (e.g. front_plate) if (modSpawnChanceDict.TryGetValue(slot.Name?.ToLowerInvariant() ?? string.Empty, out var value)) { if (!randomUtil.GetChance100(value)) { continue; } } } var itemPool = slot.Properties?.Filters?.FirstOrDefault()?.Filter ?? []; if (itemPool.Count == 0) { if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug( $"Unable to choose a mod for slot: {slot.Name} on item: {itemToAddTemplate.Id} {itemToAddTemplate.Name}, parents' 'Filter' array is empty, skipping" ); } continue; } var chosenTpl = GetCompatibleTplFromArray(itemPool, incompatibleModTpls); if (chosenTpl.IsEmpty) { if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug( $"Unable to choose a mod for slot: {slot.Name} on item: {itemToAddTemplate.Id} {itemToAddTemplate.Name}, no compatible tpl found in pool of {itemPool.Count}, skipping" ); } continue; } // Create basic item structure ready to add to weapon array Item modItemToAdd = new() { Id = new MongoId(), Template = chosenTpl, ParentId = result[0].Id, SlotId = slot.Name, }; // Add chosen item to weapon array result.Add(modItemToAdd); var modItemDbDetails = GetItem(modItemToAdd.Template).Value; if (modItemDbDetails?.Properties?.ConflictingItems is null) { continue; } // Include conflicting items of newly added mod in pool to be used for next mod choice incompatibleModTpls.UnionWith(modItemDbDetails.Properties.ConflictingItems); } return result; } /// /// Get a compatible tpl from the array provided where it is not found in the provided incompatible mod tpls parameter /// /// Tpls to randomly choose from /// Incompatible tpls to disallow /// Chosen tpl or undefined public MongoId GetCompatibleTplFromArray(HashSet tplPool, HashSet tplBlacklist) { if (!tplPool.Any()) { return MongoId.Empty(); } var compatibleTpls = tplPool.Except(tplBlacklist).ToList(); return compatibleTpls.Any() ? randomUtil.GetArrayValue(compatibleTpls) : MongoId.Empty(); } /// /// Is the provided item._props.Slots._name property a plate slot /// /// Name of slot (_name) of Items Slot array /// True if it is a slot that holds a removable plate public bool IsRemovablePlateSlot(string slotName) { return GetRemovablePlateSlotIds().Contains(slotName.ToLowerInvariant()); } /// /// Get a list of slot names that hold removable plates /// /// Array of slot ids (e.g. front_plate) public FrozenSet GetRemovablePlateSlotIds() { return _removablePlateSlotIds; } /// /// Generate new unique ids for child items while preserving hierarchy /// /// Base/primary item /// Primary item + children of primary item /// Item array with updated IDs public List ReparentItemAndChildren(Item rootItem, List itemWithChildren) { var oldRootId = itemWithChildren[0].Id; Dictionary idMappings = []; idMappings[oldRootId] = rootItem.Id; foreach (var mod in itemWithChildren) { if (!idMappings.ContainsKey(mod.Id)) { idMappings[mod.Id.ToString()] = new MongoId(); } // Has parentId + no remapping exists for its parent if (mod.ParentId != null && (!idMappings.ContainsKey(mod.ParentId) || idMappings?[mod.ParentId] is null)) // Make remapping for items parentId { idMappings![mod.ParentId] = new MongoId(); } mod.Id = idMappings[mod.Id.ToString()]; if (mod.ParentId != null) { mod.ParentId = idMappings[mod.ParentId]; } } // Force item's details into first location of presetItems if (itemWithChildren[0].Template != rootItem.Template) { logger.Warning($"Reassigning root item from {itemWithChildren[0].Template} to {rootItem.Template}"); } itemWithChildren[0] = rootItem; return itemWithChildren; } /// /// Add a blank upd object to passed in item if it does not exist already /// /// item to add upd to /// text to write to log when upd object was not found /// True when upd object was added public bool AddUpdObjectToItem(Item item, string? warningMessageWhenMissing = null) { if (item.Upd is not null) { // Already exists, exit early return false; } item.Upd = new Upd(); if (warningMessageWhenMissing is not null) { if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug(warningMessageWhenMissing); } } return true; } // Return all tpls from Money enum // Returns string tpls public List GetMoneyTpls() { return [Money.ROUBLES, Money.DOLLARS, Money.EUROS, Money.GP]; } // Get a randomised stack size for the passed in ammo // Ammo to get stack size for // Default: Limit to 60 to prevent crazy values when players use stack increase mods // Returns number public int GetRandomisedAmmoStackSize(TemplateItem ammoItemTemplate, int maxLimit = 60) { return ammoItemTemplate.Properties?.StackMaxSize == 1 ? 1 // Max is one, nothing to randomise : randomUtil.GetInt( ammoItemTemplate.Properties?.StackMinRandom ?? 1, Math.Min(ammoItemTemplate.Properties?.StackMaxRandom ?? 1, maxLimit) ); } /// /// Get a 2D grid of a container's item slots /// /// Tpl id of the container public int[,] GetContainerMapping(MongoId containerTpl) { // Get template from db var containerTemplate = GetItem(containerTpl).Value; // Get height/width var height = containerTemplate?.Properties?.Grids?.First().Properties?.CellsV; var width = containerTemplate?.Properties?.Grids?.First().Properties?.CellsH; if (height is null || width is null) { throw new ItemHelperException("Height or width is null when trying to calculate container mapping"); } return GetBlankContainerMap(width.Value, height.Value); } /// /// Get a blank two-dimensional representation of a container /// /// Width of container (columns) /// Height of container (rows) /// Two-dimensional representation of container public int[,] GetBlankContainerMap(int horizontalSizeX, int verticalSizeY) { // Rows / Columns return new int[verticalSizeY, horizontalSizeX]; } }