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, BotInventoryContainerService botInventoryContainerService, ConfigServer configServer ) { // Equipment slot ids that do not conflict with other slots private static readonly FrozenSet _slotsWithNoCompatIssues = [ nameof(EquipmentSlots.Scabbard), nameof(EquipmentSlots.Backpack), nameof(EquipmentSlots.SecuredContainer), nameof(EquipmentSlots.Holster), nameof(EquipmentSlots.ArmBand), ]; private static readonly FrozenSet _pmcTypes = [Sides.PmcBear.ToLowerInvariant(), Sides.PmcUsec.ToLowerInvariant()]; protected 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 /// Force property on item /// Item Upd object with extra properties public Upd? GenerateExtraPropertiesForItem(TemplateItem? itemTemplate, string? botRole = null, bool forceStackObjectsCount = false) { // Get raid settings, if no raid, default to day var raidSettings = profileActivityService.GetFirstProfileActivityRaidData()?.RaidConfiguration; // BotRole property exists, we have specific bot randomisation values to make use of RandomisedResourceDetails? randomisationSettings = null; if (botRole is not null) { BotConfig.LootItemResourceRandomization.TryGetValue(botRole, out randomisationSettings); } Upd itemUpd = new(); var hasProperties = false; if (itemTemplate?.Properties?.MaxDurability is not null && itemTemplate.Properties.MaxDurability > 0) { if (itemTemplate.Properties.WeapClass is not null) { // Is weapon itemUpd.Repairable = GenerateWeaponRepairableProperties(itemTemplate, botRole); hasProperties = true; } else if (itemTemplate.Properties.ArmorClass is not null) { // Is armor itemUpd.Repairable = GenerateArmorRepairableProperties(itemTemplate, botRole); hasProperties = true; } } if (itemTemplate?.Properties?.HasHinge ?? false) { itemUpd.Togglable = new UpdTogglable { On = true }; hasProperties = true; } if (itemTemplate?.Properties?.Foldable ?? false) { itemUpd.Foldable = new UpdFoldable { Folded = false }; hasProperties = true; } if (itemTemplate?.Properties?.WeapFireType?.Count == 0) { itemUpd.FireMode = itemTemplate.Properties.WeapFireType.Contains("fullauto") ? new UpdFireMode { FireMode = "fullauto" } : new UpdFireMode { FireMode = randomUtil.GetArrayValue(itemTemplate.Properties.WeapFireType) }; hasProperties = true; } // Must have value + not be 0 (e.g. Esmarch tourniquet) as they're single use if (itemTemplate?.Properties?.MaxHpResource is not null && itemTemplate.Properties.MaxHpResource != 0) { itemUpd.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) { itemUpd.FoodDrink = new UpdFoodDrink { HpPercent = GetRandomizedResourceValue(itemTemplate.Properties.MaxResource ?? 0, randomisationSettings?.Food), }; hasProperties = true; } var equipmentSettings = GetBotEquipmentSettingFromConfig(botRole); if (itemTemplate?.Parent == BaseClasses.FLASHLIGHT) { var lightLaserActiveChance = raidSettings?.IsNightRaid ?? false // Higher chance of laser/light at night ? equipmentSettings?.LightIsActiveNightChancePercent ?? 50 : equipmentSettings?.LightIsActiveDayChancePercent ?? 25; itemUpd.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 = equipmentSettings?.LaserIsActiveChancePercent ?? 50; itemUpd.Light = new UpdLight { IsActive = randomUtil.GetChance100(lightLaserActiveChance), SelectedMode = 0 }; hasProperties = true; } if (itemTemplate?.Parent == BaseClasses.NIGHT_VISION) { // Get chance from botconfig for bot type var nvgActiveChance = raidSettings?.IsNightRaid ?? false ? equipmentSettings?.NvgIsActiveChanceNightPercent ?? 90 : equipmentSettings?.NvgIsActiveChanceDayPercent ?? 15; itemUpd.Togglable = new UpdTogglable { On = randomUtil.GetChance100(nvgActiveChance) }; hasProperties = true; } // Togglable face shield if ((itemTemplate?.Properties?.HasHinge ?? false) && (itemTemplate.Properties.FaceShieldComponent ?? false)) { var faceShieldActiveChance = equipmentSettings?.FaceShieldIsActiveChancePercent ?? 75; itemUpd.Togglable = new UpdTogglable { On = randomUtil.GetChance100(faceShieldActiveChance) }; hasProperties = true; } if (forceStackObjectsCount) { // Ensure property is set itemUpd.StackObjectsCount ??= 1; } // Some items (weapon mods) may not have any props, and we don't want an empty Upd object return hasProperties || forceStackObjectsCount ? itemUpd : null; } /// /// Choose a random value between a min and max for a resource to be /// /// 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 || randomUtil.GetChance100(randomizationValues.ChanceMaxResourcePercent)) { return maxResource; } if (maxResource.Approx(1)) { return 1; } // Generate a randomised min value the resource could have var min = Math.Max(1, randomUtil.GetPercentOfValue(randomizationValues.ResourcePercent, maxResource, 0)); // Choose value from randomised min and resource max possible return randomUtil.GetDouble(min, maxResource); } /// /// Get equipment specific flags (e.g. nvg settings) for a particular bot type /// /// bot to get settings for /// Equipment filter settings protected EquipmentFilters? GetBotEquipmentSettingFromConfig(string botRole) { return BotConfig.Equipment.GetValueOrDefault(GetBotEquipmentRole(botRole)); } /// /// 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(botRole); var currentDurability = durabilityLimitsHelper.GetRandomizedWeaponDurability(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( IEnumerable itemsEquipped, MongoId tplToCheck, string equipmentSlot ) { // Skip slots that have no incompatibilities if (_slotsWithNoCompatIssues.Contains(equipmentSlot)) { return new ChooseRandomCompatibleModResult { Incompatible = false, Found = false, Reason = string.Empty, }; } // TODO: Can probably be optimized to cache itemTemplates as items are added to inventory var equippedItemsDb = itemsEquipped.Select(equippedItem => itemHelper.GetItem(equippedItem.Template).Value); 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; 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 = string.Empty }; } 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.ToLower()) ? Sides.PmcEquipmentRole : botRole; } /// /// Adds an item with all its children into specified equipmentSlots, wherever it fits /// /// Bots unique identifier /// Slot to try and 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( MongoId botId, HashSet equipmentSlots, MongoId rootItemId, MongoId rootItemTplId, IEnumerable itemWithChildren, BotBaseInventory inventory ) { var itemWithChildrenList = itemWithChildren.ToList(); // Track how many containers are unable to be found var missingContainerCount = 0; foreach (var equipmentSlotId in equipmentSlots) { // Get container from inventory 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: {itemWithChildrenList.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 (isValidItem, containerDbDetails) = itemHelper.GetItem(container.Template); if (!isValidItem) { logger.Warning(serverLocalisationService.GetText("bot-missing_container_with_tpl", container.Template)); // Bad item, skip continue; } if (containerDbDetails?.Properties?.Grids is null || !containerDbDetails.Properties.Grids.Any()) { // Container has no slots to hold items, skip to next container continue; } // Get x/y grid size of item var (itemWidth, itemHeight) = inventoryHelper.GetItemSize(rootItemTplId, rootItemId, itemWithChildrenList); var result = botInventoryContainerService.TryAddItemToBotContainer( botId, equipmentSlotId, itemWithChildrenList, inventory, itemWidth, itemHeight ); if (result != ItemAddedResult.SUCCESS) { // Failed to add to container, try next continue; } return result; } return ItemAddedResult.NO_SPACE; } }