using System.Collections.Concurrent; using SPTarkov.DI.Annotations; using SPTarkov.Server.Core.Extensions; 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.Spt.Bots; using SPTarkov.Server.Core.Models.Utils; using SPTarkov.Server.Core.Utils.Cloners; namespace SPTarkov.Server.Core.Services; [Injectable(InjectionType.Singleton)] public class BotLootCacheService( ISptLogger logger, ItemHelper itemHelper, PMCLootGenerator pmcLootGenerator, ServerLocalisationService serverLocalisationService, ICloner cloner ) { protected readonly 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(); private readonly Lock _lootCacheGeneratedLock = new(); /// /// Remove cached bot loot data /// public void ClearCache() { _lootCache.Clear(); } /// /// Get a dictionary of lootable item Tpls with their corresponding weight /// /// 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, key = item tpl, value = weight public Dictionary GetLootFromCache( string botRole, bool isPmc, string lootType, BotType botJsonTemplate, MinMax? itemPriceMinMax = null ) { // Ensure cache is hydrated before use lock (_lootCacheGeneratedLock) { 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; 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( serverLocalisationService.GetText( "bot-loot_type_not_found", new { lootType, botRole, isPmc, } ) ); return []; } if (!result.Any()) { // No loot, exit return result; } if (itemPriceMinMax is null) { // No filtering requested, return all results return cloner.Clone(result); } // Filter the loot pool by item value prior to returning var filteredResult = result.Where(i => { var itemPrice = itemHelper.GetItemPrice(i.Key); var priceLimitMin = itemPriceMinMax.Min; var priceLimitMax = itemPriceMinMax.Max; // Treat -1 as no limit if (priceLimitMax.Approx(-1)) { // only check min limit value return itemPrice >= priceLimitMin; } return itemPrice >= priceLimitMin && itemPrice <= priceLimitMax; }); return cloner.Clone(filteredResult.ToDictionary(pair => pair.Key, pair => pair.Value)); } /// /// 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; } // If pool has items and items were going into a non-secure container pool, add to combined if (itemPool.Count > 0 && !containerType.Equals("securedcontainer", StringComparison.OrdinalIgnoreCase)) { // fill up 'combined' pool of all loot AddItemsToPool(combinedLootPool, itemPool); } } // Assign whitelisted special items to bot if any exist var (specialLootItems, addSpecialLootItems) = GetGenerationWeights(botJsonTemplate.BotGeneration?.Items?.SpecialItems?.Whitelist); if (addSpecialLootItems) // key = tpl, value = weight { // No whitelist, find and assign from combined item pool 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, addHealingItems) = GetGenerationWeights(botJsonTemplate.BotGeneration?.Items?.Healing?.Whitelist); var (drugItemsInWhitelist, addDrugItems) = GetGenerationWeights(botJsonTemplate.BotGeneration?.Items?.Drugs?.Whitelist); var (foodItemsInWhitelist, addFoodItems) = GetGenerationWeights(botJsonTemplate.BotGeneration?.Items?.Food?.Whitelist); var (drinkItemsInWhitelist, addDrinkItems) = GetGenerationWeights(botJsonTemplate.BotGeneration?.Items?.Food?.Whitelist); var (currencyItemsInWhitelist, addCurrencyItems) = GetGenerationWeights(botJsonTemplate.BotGeneration?.Items?.Currency?.Whitelist); var (stimItemsInWhitelist, addStimItems) = GetGenerationWeights(botJsonTemplate.BotGeneration?.Items?.Stims?.Whitelist); var (grenadeItemsInWhitelist, addGrenadeItems) = GetGenerationWeights(botJsonTemplate.BotGeneration?.Items?.Grenades?.Whitelist); 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 (addFoodItems) { 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 = FilterItemPool( backpackLootPool, (itemTemplate) => IsBulletOrGrenade(itemTemplate.Properties) || IsMagazine(itemTemplate.Properties) || IsMedicalItem(itemTemplate.Properties) || IsGrenade(itemTemplate.Properties) || IsFood(itemTemplate.Id) || IsDrink(itemTemplate.Id) || IsCurrency(itemTemplate.Id) ); // Get pocket loot (excluding magazines, bullets, grenades, drink, food medical and healing/stim items) var filteredPocketItems = FilterItemPool( pocketLootPool, (itemTemplate) => 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 // 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 = FilterItemPool( secureLootPool, (itemTemplate) => IsBulletOrGrenade(itemTemplate.Properties) || IsMagazine(itemTemplate.Properties) ); if (!_lootCache.TryGetValue(botRole, out var cacheForRole)) { logger.Error($"Unable to get loot cache value using key: {botRole}"); return; } 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; } /// /// Helper function - Filter out items from passed in pool based on a passed in delegate /// /// Pool to filter /// Delegate to filter pool by /// protected Dictionary FilterItemPool(Dictionary lootPool, Func shouldBeSkipped) { var filteredItems = new Dictionary(); foreach (var (itemTpl, itemWeight) in lootPool) { var (isValidItem, itemTemplate) = itemHelper.GetItem(itemTpl); if (!isValidItem) { continue; } if (shouldBeSkipped(itemTemplate)) { continue; } filteredItems.TryAdd(itemTpl, itemWeight); } return filteredItems; } /// /// Return provided weights or an empty dictionary /// /// Weights to return /// Dictionary and should pool be hydrated by items in combined loot pool protected static (Dictionary, bool populateFromCombinedPool) GetGenerationWeights(Dictionary? weights) { var result = weights ?? []; return (result, !result.Any()); // empty dict = should be populated from combined pool } /// /// merge item tpls + weightings to passed in dictionary /// If exits already, skip /// /// Dictionary to add item to /// Dictionary of items to add protected void AddItemsToPool(Dictionary poolToAddTo, Dictionary poolOfItemsToAdd) { foreach (var (tpl, weight) in poolOfItemsToAdd) { // Skip adding items that already exist poolToAddTo.TryAdd(tpl, weight); } } /// /// Ammo/grenades have this property /// /// /// protected bool IsBulletOrGrenade(TemplateItemProperties properties) { return properties.AmmoType is not null; } /// /// Internal and external magazine have this property /// /// /// protected bool IsMagazine(TemplateItemProperties properties) { return properties.ReloadMagType is not null; } /// /// Medical use items (e.g. morphine/lip balm/grizzly) /// /// /// protected bool IsMedicalItem(TemplateItemProperties properties) { return properties.MedUseTime is not null; } /// /// Grenades have this property (e.g. smoke/frag/flash grenades) /// /// /// protected bool IsGrenade(TemplateItemProperties properties) { return properties.ThrowType is not null; } protected bool IsFood(MongoId tpl) { return itemHelper.IsOfBaseclass(tpl, BaseClasses.FOOD); } protected bool IsDrink(MongoId tpl) { return itemHelper.IsOfBaseclass(tpl, BaseClasses.DRINK); } protected bool IsCurrency(MongoId 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) { _lootCache.TryAdd(botRole, new BotLootCache()); } /// /// 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; } }