using System.Collections.Frozen; using SPTarkov.DI.Annotations; using SPTarkov.Server.Core.Constants; using SPTarkov.Server.Core.Extensions; using SPTarkov.Server.Core.Models.Common; using SPTarkov.Server.Core.Models.Eft.Common.Tables; using SPTarkov.Server.Core.Models.Enums; using SPTarkov.Server.Core.Models.Spt.Bots; using SPTarkov.Server.Core.Models.Spt.Config; using SPTarkov.Server.Core.Models.Utils; using SPTarkov.Server.Core.Servers; using SPTarkov.Server.Core.Services; using SPTarkov.Server.Core.Utils; using LogLevel = SPTarkov.Server.Core.Models.Spt.Logging.LogLevel; namespace SPTarkov.Server.Core.Helpers; [Injectable] public class BotGeneratorHelper( ISptLogger _logger, RandomUtil _randomUtil, DurabilityLimitsHelper _durabilityLimitsHelper, ItemHelper _itemHelper, InventoryHelper _inventoryHelper, ProfileActivityService _profileActivityService, ServerLocalisationService _serverLocalisationService, ConfigServer _configServer ) { // Equipment slot ids that do not conflict with other slots private static readonly FrozenSet _slotsWithNoCompatIssues = [ EquipmentSlots.Scabbard.ToString(), EquipmentSlots.Backpack.ToString(), EquipmentSlots.SecuredContainer.ToString(), EquipmentSlots.Holster.ToString(), EquipmentSlots.ArmBand.ToString(), ]; private static readonly string[] _pmcTypes = [Sides.PmcBear.ToLowerInvariant(), Sides.PmcUsec.ToLowerInvariant()]; private readonly BotConfig _botConfig = _configServer.GetConfig(); /// /// Adds properties to an item /// e.g. Repairable / HasHinge / Foldable / MaxDurability /// /// Item extra properties are being generated for /// Used by weapons to randomize the durability values. Null for non-equipped items /// Item Upd object with extra properties public Upd GenerateExtraPropertiesForItem(TemplateItem? itemTemplate, string? botRole = null) { // Get raid settings, if no raid, default to day var raidSettings = _profileActivityService .GetFirstProfileActivityRaidData() ?.RaidConfiguration; RandomisedResourceDetails randomisationSettings = null; if (botRole is not null) { _botConfig.LootItemResourceRandomization.TryGetValue( botRole, out randomisationSettings ); } Upd itemProperties = new(); var hasProperties = false; if ( itemTemplate?.Properties?.MaxDurability is not null && itemTemplate.Properties.MaxDurability > 0 ) { if (itemTemplate.Properties.WeapClass is not null) { // Is weapon itemProperties.Repairable = GenerateWeaponRepairableProperties( itemTemplate, botRole ); hasProperties = true; } else if (itemTemplate.Properties.ArmorClass is not null) { // Is armor itemProperties.Repairable = GenerateArmorRepairableProperties( itemTemplate, botRole ); hasProperties = true; } } if (itemTemplate?.Properties?.HasHinge ?? false) { itemProperties.Togglable = new UpdTogglable { On = true }; hasProperties = true; } if (itemTemplate?.Properties?.Foldable ?? false) { itemProperties.Foldable = new UpdFoldable { Folded = false }; hasProperties = true; } 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), }; hasProperties = true; } if (itemTemplate?.Properties?.MaxHpResource is not null) { itemProperties.MedKit = new UpdMedKit { HpResource = GetRandomizedResourceValue( itemTemplate.Properties.MaxHpResource ?? 0, randomisationSettings?.Meds ), }; hasProperties = true; } if ( itemTemplate?.Properties?.MaxResource is not null && itemTemplate.Properties?.FoodUseTime is not null ) { itemProperties.FoodDrink = new UpdFoodDrink { HpPercent = GetRandomizedResourceValue( itemTemplate.Properties.MaxResource ?? 0, randomisationSettings?.Food ), }; hasProperties = true; } if (itemTemplate?.Parent == BaseClasses.FLASHLIGHT) { // Get chance from botconfig for bot type var lightLaserActiveChance = raidSettings?.IsNightRaid ?? false ? GetBotEquipmentSettingFromConfig( botRole, "lightIsActiveNightChancePercent", 50 ) : GetBotEquipmentSettingFromConfig( botRole, "lightIsActiveDayChancePercent", 25 ); itemProperties.Light = new UpdLight { IsActive = _randomUtil.GetChance100(lightLaserActiveChance), SelectedMode = 0, }; hasProperties = true; } else if (itemTemplate?.Parent == BaseClasses.TACTICAL_COMBO) { // Get chance from botconfig for bot type, use 50% if no value found var lightLaserActiveChance = GetBotEquipmentSettingFromConfig( botRole, "laserIsActiveChancePercent", 50 ); itemProperties.Light = new UpdLight { IsActive = _randomUtil.GetChance100(lightLaserActiveChance), SelectedMode = 0, }; hasProperties = true; } if (itemTemplate?.Parent == BaseClasses.NIGHTVISION) { // Get chance from botconfig for bot type var nvgActiveChance = raidSettings?.IsNightRaid ?? false ? GetBotEquipmentSettingFromConfig(botRole, "nvgIsActiveChanceNightPercent", 90) : GetBotEquipmentSettingFromConfig(botRole, "nvgIsActiveChanceDayPercent", 15); itemProperties.Togglable = new UpdTogglable { On = _randomUtil.GetChance100(nvgActiveChance), }; hasProperties = true; } // Togglable face shield if ( (itemTemplate?.Properties?.HasHinge ?? false) && (itemTemplate.Properties.FaceShieldComponent ?? false) ) { var faceShieldActiveChance = GetBotEquipmentSettingFromConfig( botRole, "faceShieldIsActiveChancePercent", 75 ); itemProperties.Togglable = new UpdTogglable { On = _randomUtil.GetChance100(faceShieldActiveChance), }; hasProperties = true; } // Get chance from botconfig for bot type, use 75% if no value found return hasProperties ? itemProperties : null; } /// /// Randomize the HpResource for bots e.g (245/400 resources) /// /// Max resource value of medical items /// Value provided from config /// Randomized value from maxHpResource protected double GetRandomizedResourceValue( double maxResource, RandomisedResourceValues? randomizationValues ) { if (randomizationValues is null) { return maxResource; } if (_randomUtil.GetChance100(randomizationValues.ChanceMaxResourcePercent)) { return maxResource; } return _randomUtil.GetDouble( _randomUtil.GetPercentOfValue(randomizationValues.ResourcePercent, maxResource, 0), maxResource ); } /// /// Get the chance for the weapon attachment or helmet equipment to be set as activated /// /// role of bot with weapon/helmet /// the setting of the weapon attachment/helmet equipment to be activated /// default value for the chance of activation if the botrole or bot equipment role is undefined /// Percent chance to be active protected double? GetBotEquipmentSettingFromConfig( string? botRole, string setting, double defaultValue ) { if (botRole is null) { return defaultValue; } var botEquipmentSettings = _botConfig.Equipment[GetBotEquipmentRole(botRole)]; if (botEquipmentSettings is null) { _logger.Warning( _serverLocalisationService.GetText( "bot-missing_equipment_settings", new { botRole, setting, defaultValue, } ) ); return defaultValue; } var props = botEquipmentSettings.GetType().GetProperties(); var propValue = (double?) props .FirstOrDefault(x => string.Equals(x.Name, setting, StringComparison.CurrentCultureIgnoreCase) ) ?.GetValue(botEquipmentSettings); if (propValue is not null) { return propValue; } _logger.Warning( _serverLocalisationService.GetText( "bot-missing_equipment_settings_property", new { botRole, setting, defaultValue, } ) ); return defaultValue; } /// /// Create a repairable object for a weapon that containers durability + max durability properties /// /// weapon object being generated for /// type of bot being generated for /// Repairable object protected UpdRepairable GenerateWeaponRepairableProperties( TemplateItem itemTemplate, string? botRole = null ) { var maxDurability = _durabilityLimitsHelper.GetRandomizedMaxWeaponDurability( itemTemplate, botRole ); var currentDurability = _durabilityLimitsHelper.GetRandomizedWeaponDurability( itemTemplate, botRole, maxDurability ); return new UpdRepairable { Durability = Math.Round(currentDurability, 5), MaxDurability = Math.Round(maxDurability, 5), }; } /// /// Create a repairable object for an armor that containers durability + max durability properties /// /// weapon object being generated for /// type of bot being generated for /// Repairable object protected UpdRepairable GenerateArmorRepairableProperties( TemplateItem itemTemplate, string? botRole = null ) { double maxDurability; double currentDurability; if (itemTemplate.Properties?.ArmorClass == 0) { maxDurability = itemTemplate.Properties.MaxDurability.Value; currentDurability = itemTemplate.Properties.MaxDurability.Value; } else { maxDurability = _durabilityLimitsHelper.GetRandomizedMaxArmorDurability( itemTemplate, botRole ); currentDurability = _durabilityLimitsHelper.GetRandomizedArmorDurability( itemTemplate, botRole, maxDurability ); } return new UpdRepairable { Durability = Math.Round(currentDurability, 5), MaxDurability = Math.Round(maxDurability, 5), }; } /// /// Can item be added to another item without conflict /// /// Items to check compatibilities with /// Tpl of the item to check for incompatibilities /// Slot the item will be placed into /// false if no incompatibilities, also has incompatibility reason public ChooseRandomCompatibleModResult IsItemIncompatibleWithCurrentItems( List itemsEquipped, string tplToCheck, string equipmentSlot ) { // Skip slots that have no incompatibilities if (_slotsWithNoCompatIssues.Contains(equipmentSlot)) { return new ChooseRandomCompatibleModResult { Incompatible = false, Found = false, Reason = "", }; } // TODO: Can probably be optimized to cache itemTemplates as items are added to inventory var equippedItemsDb = itemsEquipped .Select(equippedItem => _itemHelper.GetItem(equippedItem.Template).Value) .ToList(); var (itemIsValid, itemToEquip) = _itemHelper.GetItem(tplToCheck); if (!itemIsValid) { _logger.Warning( _serverLocalisationService.GetText( "bot-invalid_item_compatibility_check", new { itemTpl = tplToCheck, slot = equipmentSlot } ) ); return new ChooseRandomCompatibleModResult { Incompatible = true, Found = false, Reason = $"item: {tplToCheck} does not exist in the database", }; } if (itemToEquip?.Properties is null) { _logger.Warning( _serverLocalisationService.GetText( "bot-compatibility_check_missing_props", new { id = itemToEquip?.Id, name = itemToEquip?.Name, slot = equipmentSlot, } ) ); return new ChooseRandomCompatibleModResult { Incompatible = true, Found = false, Reason = $"item: {tplToCheck} does not have a _props field", }; } // Does an equipped item have a property that blocks the desired item - check for prop "BlocksX" .e.g BlocksEarpiece / BlocksFaceCover var templateItems = equippedItemsDb.ToList(); var blockingItem = templateItems.FirstOrDefault(item => HasBlockingProperty(item, equipmentSlot) ); if (blockingItem is not null) // this.logger.warning(`1 incompatibility found between - {itemToEquip[1]._name} and {blockingItem._name} - {equipmentSlot}`); { return new ChooseRandomCompatibleModResult { Incompatible = true, Found = false, Reason = $"{tplToCheck} {itemToEquip.Name} in slot: {equipmentSlot} blocked by: {blockingItem.Id} {blockingItem.Name}", SlotBlocked = true, }; } // Check if any of the current inventory templates have the incoming item defined as incompatible blockingItem = templateItems.FirstOrDefault(x => x?.Properties?.ConflictingItems?.Contains(tplToCheck) ?? false ); if (blockingItem is not null) // this.logger.warning(`2 incompatibility found between - {itemToEquip[1]._name} and {blockingItem._props.Name} - {equipmentSlot}`); { return new ChooseRandomCompatibleModResult { Incompatible = true, Found = false, Reason = $"{tplToCheck} {itemToEquip.Name} in slot: {equipmentSlot} blocked by: {blockingItem.Id} {blockingItem.Name}", SlotBlocked = true, }; } // Does item being checked get blocked/block existing item if (itemToEquip.Properties.BlocksHeadwear ?? false) { var existingHeadwear = itemsEquipped.FirstOrDefault(x => x.SlotId == Containers.Headwear ); if (existingHeadwear is not null) { return new ChooseRandomCompatibleModResult { Incompatible = true, Found = false, Reason = $"{tplToCheck} {itemToEquip.Name} is blocked by: {existingHeadwear.Template} in slot: {existingHeadwear.SlotId}", SlotBlocked = true, }; } } // Does item being checked get blocked/block existing item if (itemToEquip.Properties.BlocksFaceCover.GetValueOrDefault(false)) { var existingFaceCover = itemsEquipped.FirstOrDefault(item => item.SlotId == Containers.FaceCover ); if (existingFaceCover is not null) { return new ChooseRandomCompatibleModResult { Incompatible = true, Found = false, Reason = $"{tplToCheck} {itemToEquip.Name} is blocked by: {existingFaceCover.Template} in slot: {existingFaceCover.SlotId}", SlotBlocked = true, }; } } // Does item being checked get blocked/block existing item if (itemToEquip.Properties.BlocksEarpiece.GetValueOrDefault(false)) { var existingEarpiece = itemsEquipped.FirstOrDefault(item => item.SlotId == Containers.Earpiece ); if (existingEarpiece is not null) { return new ChooseRandomCompatibleModResult { Incompatible = true, Found = false, Reason = $"{tplToCheck} {itemToEquip.Name} is blocked by: {existingEarpiece.Template} in slot: {existingEarpiece.SlotId}", SlotBlocked = true, }; } } // Does item being checked get blocked/block existing item if (itemToEquip.Properties.BlocksArmorVest.GetValueOrDefault(false)) { var existingArmorVest = itemsEquipped.FirstOrDefault(item => item.SlotId == Containers.ArmorVest ); if (existingArmorVest is not null) { return new ChooseRandomCompatibleModResult { Incompatible = true, Found = false, Reason = $"{tplToCheck} {itemToEquip.Name} is blocked by: {existingArmorVest.Template} in slot: {existingArmorVest.SlotId}", SlotBlocked = true, }; } } // Check if the incoming item has any inventory items defined as incompatible var blockingInventoryItem = itemsEquipped.FirstOrDefault(x => itemToEquip.Properties.ConflictingItems?.Contains(x.Template) ?? false ); if (blockingInventoryItem is not null) // this.logger.warning(`3 incompatibility found between - {itemToEquip[1]._name} and {blockingInventoryItem._tpl} - {equipmentSlot}`) { return new ChooseRandomCompatibleModResult { Incompatible = true, Found = false, Reason = $"{tplToCheck} blocks existing item {blockingInventoryItem.Template} in slot {blockingInventoryItem.SlotId}", }; } return new ChooseRandomCompatibleModResult { Incompatible = false, Reason = "" }; } protected bool HasBlockingProperty(TemplateItem? item, string blockingPropertyName) { return item != null && item.Blocks.TryGetValue(blockingPropertyName, out var blocks) && blocks; } /// /// Convert a bots role to the equipment role used in config/bot.json /// /// Role to convert /// Equipment role (e.g. pmc / assault / bossTagilla) public string GetBotEquipmentRole(string botRole) { return _pmcTypes.Contains(botRole, StringComparer.OrdinalIgnoreCase) ? Sides.PmcEquipmentRole : botRole; } /// /// Adds an item with all its children into specified equipmentSlots, wherever it fits. /// /// Slot to add item+children into /// Root item id to use as mod items parentId /// Root items tpl id /// Item to add /// Inventory to add item+children into /// /// ItemAddedResult result object public ItemAddedResult AddItemWithChildrenToEquipmentSlot( HashSet equipmentSlots, MongoId rootItemId, MongoId rootItemTplId, List itemWithChildren, BotBaseInventory inventory, HashSet? containersIdFull = null ) { // Track how many containers are unable to be found var missingContainerCount = 0; foreach (var equipmentSlotId in equipmentSlots) { if (containersIdFull?.Contains(equipmentSlotId.ToString()) ?? false) { continue; } // Get container to put item into var container = inventory.Items.FirstOrDefault(item => item.SlotId == equipmentSlotId.ToString() ); if (container is null) { missingContainerCount++; if (missingContainerCount == equipmentSlots.Count) { // Bot doesn't have any containers we want to add item to if (_logger.IsLogEnabled(LogLevel.Debug)) { _logger.Debug( $"Unable to add item: {itemWithChildren.FirstOrDefault()?.Template} to bot as it lacks the following containers: {string.Join(",", equipmentSlots)}" ); } return ItemAddedResult.NO_CONTAINERS; } // No container of desired type found, skip to next container type continue; } // Get container details from db var (key, value) = _itemHelper.GetItem(container.Template); if (!key) { _logger.Warning( _serverLocalisationService.GetText( "bot-missing_container_with_tpl", container.Template ) ); // Bad item, skip continue; } if (value?.Properties?.Grids?.Count == 0) // Container has no slots to hold items { continue; } // Get x/y grid size of item var (width, height) = _inventoryHelper.GetItemSize( rootItemTplId, rootItemId, itemWithChildren ); // Iterate over each grid in the container and look for a big enough space for the item to be placed in var currentGridCount = 1; var totalSlotGridCount = value?.Properties?.Grids?.Count; foreach (var slotGrid in value?.Properties?.Grids ?? []) { // Grid is empty, skip or item size is bigger than grid if ( slotGrid.Props?.CellsH == 0 || slotGrid.Props?.CellsV == 0 || width * height > slotGrid.Props?.CellsV * slotGrid.Props?.CellsH ) { continue; } // Can't put item type in grid, skip all grids as we're assuming they have the same rules if (!ItemAllowedInContainer(slotGrid, rootItemTplId)) // Multiple containers, maybe next one allows item, only break out of loop for the containers grids { break; } // Get all root items in found container var existingContainerItems = (inventory.Items ?? []).Where(item => item.ParentId == container.Id && item.SlotId == slotGrid.Name ); // Get root items in container we can iterate over to find out what space is free var containerItemsToCheck = existingContainerItems.Where(x => x.SlotId == slotGrid.Name ); var containerItemsWithChildren = GetContainerItemsWithChildren( containerItemsToCheck, inventory.Items ); if (slotGrid.Props is not null) { // Get rid of an items free/used spots in current grid var slotGridMap = _inventoryHelper.GetContainerMap( slotGrid.Props.CellsH.GetValueOrDefault(), slotGrid.Props.CellsV.GetValueOrDefault(), containerItemsWithChildren, container.Id ); // Try to fit item into grid var findSlotResult = slotGridMap.FindSlotForItem(width, height); // Free slot found, add item if (findSlotResult.Success ?? false) { var parentItem = itemWithChildren.FirstOrDefault(i => i.Id == rootItemId); // Set items parent to container id if (parentItem is not null) { parentItem.ParentId = container.Id; parentItem.SlotId = slotGrid.Name; parentItem.Location = new ItemLocation { X = findSlotResult.X, Y = findSlotResult.Y, R = findSlotResult.Rotation ?? false ? ItemRotation.Vertical : ItemRotation.Horizontal, }; } (inventory.Items ?? []).AddRange(itemWithChildren); return ItemAddedResult.SUCCESS; } } // If we've checked all grids in container and reached this point, there's no space for item if (currentGridCount >= totalSlotGridCount) { break; } currentGridCount++; // No space in this grid, move to next container grid and try again } // if we got to this point, the item couldn't be placed on the container if (containersIdFull is null) { continue; } // if the item was a one by one, we know it must be full. Or if the maps cant find a slot for a one by one if (width == 1 && height == 1) { containersIdFull.Add(equipmentSlotId.ToString()); } } return ItemAddedResult.NO_SPACE; } /// /// Take a list of items and check if they need children + add them /// /// /// /// protected List GetContainerItemsWithChildren( IEnumerable containerRootItems, List inventoryItems ) { var result = new List(); if (!containerRootItems.Any()) { // Container has no root items return result; } // Filter out all items without location prop, (child items) var itemsWithoutLocation = inventoryItems.Where(item => item.Location is null).ToList(); foreach (var rootItem in containerRootItems) { // Check item in container for children, store for later insertion into `containerItemsToCheck` // (used later when figuring out how much space weapon takes up) var itemWithChildItems = itemsWithoutLocation.FindAndReturnChildrenAsItems(rootItem.Id); // Item had children, replace existing data with item + its children result.Add(rootItem); result.AddRange(itemWithChildItems); } return result; } /// /// Is the provided item allowed inside a container /// /// Items sub-grid we want to place item inside /// Item tpl being placed /// True if allowed protected bool ItemAllowedInContainer(Grid? slotGrid, string? itemTpl) { var propFilters = slotGrid?.Props?.Filters; var excludedFilter = propFilters?.FirstOrDefault()?.ExcludedFilter ?? []; var filter = propFilters?.FirstOrDefault()?.Filter ?? []; if (propFilters?.Count == 0) // no filters, item is fine to add { return true; } // Check if item base type is excluded var itemDetails = _itemHelper.GetItem(itemTpl).Value; // if item to add is found in exclude filter, not allowed if (excludedFilter.Contains(itemDetails?.Parent ?? string.Empty)) { return false; } // If Filter array only contains 1 filter and its for basetype 'item', allow it if (filter.Count == 1 && filter.Contains(BaseClasses.ITEM)) { return true; } // If allowed filter has something in it + filter doesnt have basetype 'item', not allowed if (filter.Count > 0 && !filter.Contains(itemDetails?.Parent ?? string.Empty)) { return false; } return true; } }