using System.Collections.Concurrent; using SPTarkov.DI.Annotations; using SPTarkov.Server.Core.Generators; using SPTarkov.Server.Core.Helpers; 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.Utils; using SPTarkov.Server.Core.Utils.Cloners; using LogLevel = SPTarkov.Server.Core.Models.Spt.Logging.LogLevel; namespace SPTarkov.Server.Core.Services; [Injectable(InjectionType.Singleton)] public class BotLootCacheService( ISptLogger _logger, ItemHelper _itemHelper, PMCLootGenerator _pmcLootGenerator, LocalisationService _localisationService, ICloner _cloner ) { protected ConcurrentDictionary _lootCache = new(); private readonly Lock _drugLock = new(); private readonly Lock _foodLock = new(); private readonly Lock _drinkLock = new(); private readonly Lock _currencyLock = new(); private readonly Lock _stimLock = new(); private readonly Lock _grenadeLock = new(); private readonly Lock _specialLock = new(); private readonly Lock _healingLock = new(); /// /// Remove cached bot loot data /// public void ClearCache() { _lootCache.Clear(); } /// /// Get the fully created loot array, ordered by price low to high /// /// bot to get loot for /// is the bot a pmc /// what type of loot is needed (backpack/pocket/stim/vest etc) /// Base json db file for the bot having its loot generated /// OPTIONAL - item price min and max value filter /// THIS IS NOT A THREAD SAFE METHOD /// dictionary public Dictionary GetLootFromCache( string botRole, bool isPmc, string lootType, BotType botJsonTemplate, MinMax? itemPriceMinMax = null) { if (!BotRoleExistsInCache(botRole)) { InitCacheForBotRole(botRole); AddLootToCache(botRole, isPmc, botJsonTemplate); } if(!_lootCache.TryGetValue(botRole, out var botRoleCache)) { _logger.Error($"Unable to find: {botRole} in loot cache"); return []; } Dictionary result = null; switch (lootType) { case LootCacheType.Special: result = botRoleCache.SpecialItems; break; case LootCacheType.Backpack: result = botRoleCache.BackpackLoot; break; case LootCacheType.Pocket: result = botRoleCache.PocketLoot; break; case LootCacheType.Vest: result = botRoleCache.VestLoot; break; case LootCacheType.Secure: result = botRoleCache.SecureLoot; break; case LootCacheType.Combined: result = botRoleCache.CombinedPoolLoot; break; case LootCacheType.HealingItems: result = botRoleCache.HealingItems; break; case LootCacheType.GrenadeItems: result = botRoleCache.GrenadeItems; break; case LootCacheType.DrugItems: result = botRoleCache.DrugItems; break; case LootCacheType.FoodItems: result = botRoleCache.FoodItems; break; case LootCacheType.DrinkItems: result = botRoleCache.DrinkItems; break; case LootCacheType.CurrencyItems: result = botRoleCache.CurrencyItems; break; case LootCacheType.StimItems: result = botRoleCache.StimItems; break; default: _logger.Error( _localisationService.GetText( "bot-loot_type_not_found", new { lootType, botRole, isPmc } ) ); break; } if (itemPriceMinMax is not null) { var filteredResult = result.Where(i => { var itemPrice = _itemHelper.GetItemPrice(i.Key); if (itemPriceMinMax?.Min is not null && itemPriceMinMax?.Max is not null) { return itemPrice >= itemPriceMinMax?.Min && itemPrice <= itemPriceMinMax?.Max; } if (itemPriceMinMax?.Min is not null && itemPriceMinMax?.Max is null) { return itemPrice >= itemPriceMinMax?.Min; } if (itemPriceMinMax?.Min is null && itemPriceMinMax?.Max is not null) { return itemPrice <= itemPriceMinMax?.Max; } return false; } ); return _cloner.Clone(filteredResult.ToDictionary(pair => pair.Key, pair => pair.Value)); } return _cloner.Clone(result); } /// /// Generate loot for a bot and store inside a private class property /// /// bots role (assault / pmcBot etc) /// Is the bot a PMC (alters what loot is cached) /// db template for bot having its loot generated protected void AddLootToCache(string botRole, bool isPmc, BotType botJsonTemplate) { // Full pool of loot we use to create the various sub-categories with var lootPool = botJsonTemplate.BotInventory.Items; // Flatten all individual slot loot pools into one big pool, while filtering out potentially missing templates Dictionary specialLootPool = new(); Dictionary backpackLootPool = new(); Dictionary pocketLootPool = new(); Dictionary vestLootPool = new(); Dictionary secureLootPool = new(); Dictionary combinedLootPool = new(); if (isPmc) { // Replace lootPool from bot json with our own generated list for PMCs lootPool.Backpack = _cloner.Clone(_pmcLootGenerator.GeneratePMCBackpackLootPool(botRole)); lootPool.Pockets = _cloner.Clone(_pmcLootGenerator.GeneratePMCPocketLootPool(botRole)); lootPool.TacticalVest = _cloner.Clone(_pmcLootGenerator.GeneratePMCVestLootPool(botRole)); } // Backpack/Pockets etc var poolsToProcess = new Dictionary> { { "Backpack", lootPool.Backpack }, { "Pockets", lootPool.Pockets }, { "SecuredContainer", lootPool.SecuredContainer }, { "SpecialLoot", lootPool.SpecialLoot }, { "TacticalVest", lootPool.TacticalVest } }; foreach (var (containerType, itemPool) in poolsToProcess) { // No items to add, skip if (itemPool.Count == 0) { continue; } // Sort loot pool into separate buckets switch (containerType) { case "SpecialLoot": AddItemsToPool(specialLootPool, itemPool); break; case "Pockets": AddItemsToPool(pocketLootPool, itemPool); break; case "TacticalVest": AddItemsToPool(vestLootPool, itemPool); break; case "SecuredContainer": AddItemsToPool(secureLootPool, itemPool); break; case "Backpack": AddItemsToPool(backpackLootPool, itemPool); break; default: _logger.Warning($"How did you get here {containerType}"); break; } // Add all items (if any) to combined pool (excluding secure) if (itemPool.Count > 0 && containerType.Equals("securedcontainer", StringComparison.OrdinalIgnoreCase)) { AddItemsToPool(combinedLootPool, itemPool); } } // Assign whitelisted special items to bot if any exist var specialLootItems = GetGenerationWeights(botJsonTemplate.BotGeneration?.Items?.SpecialItems?.Whitelist); // No whitelist, find and assign from combined item pool if (!specialLootItems.Any()) // key = tpl, value = weight { foreach (var itemKvP in specialLootPool) { var itemTemplate = _itemHelper.GetItem(itemKvP.Key).Value; if (!(IsBulletOrGrenade(itemTemplate.Properties) || IsMagazine(itemTemplate.Properties))) { lock (_specialLock) { specialLootItems.TryAdd(itemKvP.Key, itemKvP.Value); } } } } var healingItemsInWhitelist = GetGenerationWeights(botJsonTemplate.BotGeneration?.Items?.Healing?.Whitelist); var addHealingItems = !healingItemsInWhitelist.Any(); // Nothing found in whitelist, we need to add items from combinedLootPool var drugItemsInWhitelist = GetGenerationWeights(botJsonTemplate.BotGeneration?.Items?.Drugs?.Whitelist); var addDrugItems = !drugItemsInWhitelist.Any(); var foodItemsInWhitelist = GetGenerationWeights(botJsonTemplate.BotGeneration?.Items?.Food?.Whitelist); var foodItems = !foodItemsInWhitelist.Any(); var drinkItemsInWhitelist = GetGenerationWeights(botJsonTemplate.BotGeneration?.Items?.Food?.Whitelist); var addDrinkItems = !drinkItemsInWhitelist.Any(); var currencyItemsInWhitelist = GetGenerationWeights(botJsonTemplate.BotGeneration?.Items?.Currency?.Whitelist); var addCurrencyItems = !currencyItemsInWhitelist.Any(); var stimItemsInWhitelist = GetGenerationWeights(botJsonTemplate.BotGeneration?.Items?.Stims?.Whitelist); var addStimItems = !stimItemsInWhitelist.Any(); var grenadeItemsInWhitelist = GetGenerationWeights(botJsonTemplate.BotGeneration?.Items?.Grenades?.Whitelist); var addGrenadeItems = !grenadeItemsInWhitelist.Any(); foreach (var itemKvP in combinedLootPool) { var itemTemplate = _itemHelper.GetItem(itemKvP.Key).Value; if (itemTemplate is null) { continue; } if (addHealingItems) { // Whitelist has no healing items, hydrate it using items from combinedLootPool that meet criteria if ( IsMedicalItem(itemTemplate.Properties) && itemTemplate.Parent != BaseClasses.STIMULATOR && itemTemplate.Parent != BaseClasses.DRUGS ) { lock (_healingLock) { healingItemsInWhitelist.TryAdd(itemKvP.Key, itemKvP.Value); } } } if (addDrugItems) { if (itemTemplate.Parent == BaseClasses.DRUGS && IsMedicalItem(itemTemplate.Properties)) { lock (_drugLock) { drugItemsInWhitelist.TryAdd(itemKvP.Key, itemKvP.Value); } } } if (foodItems) { if (_itemHelper.IsOfBaseclass(itemTemplate.Id, BaseClasses.FOOD)) { lock (_foodLock) { foodItemsInWhitelist.TryAdd(itemKvP.Key, itemKvP.Value); } } } if (addDrinkItems) { if (_itemHelper.IsOfBaseclass(itemTemplate.Id, BaseClasses.DRINK)) { lock (_drinkLock) { drinkItemsInWhitelist.TryAdd(itemKvP.Key, itemKvP.Value); } } } if (addCurrencyItems) { if (_itemHelper.IsOfBaseclass(itemTemplate.Id, BaseClasses.MONEY)) { lock (_currencyLock) { currencyItemsInWhitelist.TryAdd(itemKvP.Key, itemKvP.Value); } } } if (addStimItems) { if (itemTemplate.Parent == BaseClasses.STIMULATOR && IsMedicalItem(itemTemplate.Properties)) { lock (_stimLock) { stimItemsInWhitelist.TryAdd(itemKvP.Key, itemKvP.Value); } } } if (addGrenadeItems) { if (IsGrenade(itemTemplate.Properties)) { lock (_grenadeLock) { grenadeItemsInWhitelist.TryAdd(itemKvP.Key, itemKvP.Value); } } } } // Get backpack loot (excluding magazines, bullets, grenades, drink, food and healing/stim items) var filteredBackpackItems = new Dictionary(); foreach (var itemKvP in backpackLootPool) { var itemResult = _itemHelper.GetItem(itemKvP.Key); if (itemResult.Value is null) { continue; } var itemTemplate = itemResult.Value; if ( IsBulletOrGrenade(itemTemplate.Properties) || IsMagazine(itemTemplate.Properties) || IsMedicalItem(itemTemplate.Properties) || IsGrenade(itemTemplate.Properties) || IsFood(itemTemplate.Id) || IsDrink(itemTemplate.Id) || IsCurrency(itemTemplate.Id) ) // Is type we don't want as backpack loot, skip { continue; } filteredBackpackItems.TryAdd(itemKvP.Key, itemKvP.Value); } // Get pocket loot (excluding magazines, bullets, grenades, drink, food medical and healing/stim items) var filteredPocketItems = new Dictionary(); foreach (var itemKvP in pocketLootPool) { var itemResult = _itemHelper.GetItem(itemKvP.Key); if (itemResult.Value is null) { continue; } var itemTemplate = itemResult.Value; if ( IsBulletOrGrenade(itemTemplate.Properties) || IsMagazine(itemTemplate.Properties) || IsMedicalItem(itemTemplate.Properties) || IsGrenade(itemTemplate.Properties) || IsFood(itemTemplate.Id) || IsDrink(itemTemplate.Id) || IsCurrency(itemTemplate.Id) || itemTemplate.Properties.Height is null || // lacks height itemTemplate.Properties.Width is null // lacks width ) { continue; } filteredPocketItems.TryAdd(itemKvP.Key, itemKvP.Value); } // Get vest loot (excluding magazines, bullets, grenades, medical and healing/stim items) var filteredVestItems = new Dictionary(); foreach (var itemKvP in vestLootPool) { var itemResult = _itemHelper.GetItem(itemKvP.Key); if (itemResult.Value is null) { continue; } var itemTemplate = itemResult.Value; if ( IsBulletOrGrenade(itemTemplate.Properties) || IsMagazine(itemTemplate.Properties) || IsMedicalItem(itemTemplate.Properties) || IsGrenade(itemTemplate.Properties) || IsFood(itemTemplate.Id) || IsDrink(itemTemplate.Id) || IsCurrency(itemTemplate.Id) ) { continue; } filteredVestItems.TryAdd(itemKvP.Key, itemKvP.Value); } // Get secure loot (excluding magazines, bullets) var filteredSecureLoot = new Dictionary(); foreach (var itemKvP in secureLootPool) { var itemResult = _itemHelper.GetItem(itemKvP.Key); if (itemResult.Value is null) { continue; } var itemTemplate = itemResult.Value; if (IsBulletOrGrenade(itemTemplate.Properties) || IsMagazine(itemTemplate.Properties)) { continue; } filteredSecureLoot.TryAdd(itemKvP.Key, itemKvP.Value); } var cacheForRole = _lootCache[botRole]; cacheForRole.HealingItems = healingItemsInWhitelist; cacheForRole.DrugItems = drugItemsInWhitelist; cacheForRole.FoodItems = foodItemsInWhitelist; cacheForRole.DrinkItems = drinkItemsInWhitelist; cacheForRole.CurrencyItems = currencyItemsInWhitelist; cacheForRole.StimItems = stimItemsInWhitelist; cacheForRole.GrenadeItems = grenadeItemsInWhitelist; cacheForRole.SpecialItems = specialLootItems; cacheForRole.BackpackLoot = filteredBackpackItems; cacheForRole.PocketLoot = filteredPocketItems; cacheForRole.VestLoot = filteredVestItems; cacheForRole.SecureLoot = filteredSecureLoot; } /// /// Return provided weights or an empty dictionary /// /// Weights to return /// Dictionary protected static Dictionary GetGenerationWeights(Dictionary? weights) { return weights ?? []; } protected void AddItemsToPool(Dictionary poolToAddTo, Dictionary poolOfItemsToAdd) { foreach (var tpl in poolOfItemsToAdd) { // Skip adding items that already exist if (poolToAddTo.ContainsKey(tpl.Key)) { continue; } poolToAddTo.TryAdd(tpl.Key, poolOfItemsToAdd[tpl.Key]); } } /// /// Ammo/grenades have this property /// /// /// protected bool IsBulletOrGrenade(Props props) { return props.AmmoType is not null; } /// /// Internal and external magazine have this property /// /// /// protected bool IsMagazine(Props props) { return props.ReloadMagType is not null; } /// /// Medical use items (e.g. morphine/lip balm/grizzly) /// /// /// protected bool IsMedicalItem(Props props) { return props.MedUseTime is not null; } /// /// Grenades have this property (e.g. smoke/frag/flash grenades) /// /// /// protected bool IsGrenade(Props props) { return props.ThrowType is not null; } protected bool IsFood(string tpl) { return _itemHelper.IsOfBaseclass(tpl, BaseClasses.FOOD); } protected bool IsDrink(string tpl) { return _itemHelper.IsOfBaseclass(tpl, BaseClasses.DRINK); } protected bool IsCurrency(string tpl) { return _itemHelper.IsOfBaseclass(tpl, BaseClasses.MONEY); } /// /// Check if a bot type exists inside the loot cache /// /// role to check for /// true if they exist protected bool BotRoleExistsInCache(string botRole) { return _lootCache.ContainsKey(botRole); } /// /// If lootcache is undefined, init with empty property arrays /// /// Bot role to hydrate protected void InitCacheForBotRole(string botRole) { if ( !_lootCache.TryAdd( botRole, new BotLootCache { BackpackLoot = new Dictionary(), PocketLoot = new Dictionary(), VestLoot = new Dictionary(), SecureLoot = new Dictionary(), CombinedPoolLoot = new Dictionary(), SpecialItems = new Dictionary(), GrenadeItems = new Dictionary(), DrugItems = new Dictionary(), FoodItems = new Dictionary(), DrinkItems = new Dictionary(), CurrencyItems = new Dictionary(), HealingItems = new Dictionary(), StimItems = new Dictionary() } ) ) { if (_logger.IsLogEnabled(LogLevel.Debug)) { _logger.Debug($"Unable to add loot cache for bot role: {botRole} - already exists"); } } } /// /// Compares two item prices by their flea (or handbook if that doesn't exist) price /// /// /// /// protected int CompareByValue(int itemAPrice, int itemBPrice) { // If item A has no price, it should be moved to the back when sorting if (itemAPrice is 0) { return 1; } if (itemBPrice is 0) { return -1; } if (itemAPrice < itemBPrice) { return -1; } if (itemAPrice > itemBPrice) { return 1; } return 0; } }