using System.Collections.Concurrent; using SPTarkov.DI.Annotations; using SPTarkov.Server.Core.Helpers; using SPTarkov.Server.Core.Models.Common; using SPTarkov.Server.Core.Models.Eft.Common.Tables; using SPTarkov.Server.Core.Models.Utils; namespace SPTarkov.Server.Core.Services; [Injectable(InjectionType.Singleton)] public class BotEquipmentModPoolService( ISptLogger logger, ItemHelper itemHelper, DatabaseService databaseService, ServerLocalisationService localisationService ) { private readonly Lock _lockObject = new(); private ConcurrentDictionary>>? _gearModPool; protected ConcurrentDictionary>> GearModPool { get { lock (_lockObject) { return _gearModPool ??= GenerateGearPool(); } } } private ConcurrentDictionary>>? _weaponModPool; protected ConcurrentDictionary>> WeaponModPool { get { lock (_lockObject) { return _weaponModPool ??= GenerateWeaponPool(); } } } /// /// Create a dictionary of mods for each item passed in /// /// Items to find related mods and store in modPool /// Mod pool to choose from e.g. "weapon" for weaponModPool protected ConcurrentDictionary>> GeneratePool( IEnumerable? inputItems, string poolKey ) { if (inputItems is null || !inputItems.Any()) { logger.Error(localisationService.GetText("bot-unable_to_generate_item_pool_no_items", poolKey)); return []; } // Create pool we want to return var pool = new ConcurrentDictionary>>(); // Create queue to hold items we need to process/check for mods to add into the pool // Add items passed in to method initially, add sub-mods later var itemsToProcess = new Queue(inputItems); // Keep track of processed items to reduce unnecessary work var processedItems = new HashSet(); while (itemsToProcess.TryDequeue(out var currentItem)) { // Null guard / we've already processed this item if (currentItem is null || !processedItems.Add(currentItem.Id)) { continue; } // No slots = skip if (currentItem.Properties?.Slots is null || !currentItem.Properties.Slots.Any()) { continue; } // Get top-level pool, create if it doesn't exist var itemPool = pool.GetOrAdd(currentItem.Id, new ConcurrentDictionary>()); foreach (var slot in currentItem.Properties.Slots) { var compatibleMods = slot?.Properties?.Filters?.FirstOrDefault()?.Filter; if (compatibleMods is null || !compatibleMods.Any()) { // No mod items in whitelist, skip continue; } // Get or add set for this specific mod slot (e.g., "mod_scope"). var modItemPool = itemPool.GetOrAdd(slot.Name, []); foreach (var modTpl in compatibleMods) { modItemPool.Add(modTpl); // Also heck if mod ALSO has its own sub slots to process var modItemDetails = itemHelper.GetItem(modTpl).Value; if (modItemDetails?.Properties?.Slots?.Any() == true) { // Has slots we need to check, add to processing queue itemsToProcess.Enqueue(modItemDetails); } } } } return pool; } /// /// Empty the mod pool /// public void ResetWeaponPool() { WeaponModPool.Clear(); } /// /// Get array of compatible mods for an items mod slot (generate pool if it doesn't exist already) /// /// Item to look up /// Slot to get compatible mods for /// Hashset of tpls that fit the slot public HashSet GetCompatibleModsForWeaponSlot(MongoId itemTpl, string slotName) { if (WeaponModPool.TryGetValue(itemTpl, out var value)) { if (value.TryGetValue(slotName, out var tplsForSlotHashSet)) { return tplsForSlotHashSet; } } logger.Warning($"Slot: {slotName} not found for item: {itemTpl} in cache"); return []; } /// /// Get mods for a piece of gear by its tpl /// /// Items tpl to look up mods for /// Dictionary of mods (keys are mod slot names) with array of compatible mod tpls as value public ConcurrentDictionary> GetModsForGearSlot(MongoId itemTpl) { return GearModPool.TryGetValue(itemTpl, out var value) ? value : []; } /// /// Get mods for a weapon by its tpl /// /// Weapons tpl to look up mods for /// Dictionary of mods (keys are mod slot names) with array of compatible mod tpls as value public ConcurrentDictionary> GetModsForWeaponSlot(MongoId itemTpl) { return WeaponModPool.TryGetValue(itemTpl, out var value) ? value : []; } /// /// Get required mods for a weapon by its tpl /// /// Weapons tpl to look up mods for /// Dictionary of mods (keys are mod slot names) with array of compatible mod tpls as value public Dictionary> GetRequiredModsForWeaponSlot(MongoId itemTpl) { var result = new Dictionary>(); // Get item from db var itemDb = itemHelper.GetItem(itemTpl).Value; if (itemDb.Properties?.Slots is not null) // Loop over slots flagged as 'required' { foreach (var slot in itemDb.Properties.Slots.Where(slot => slot.Required.GetValueOrDefault(false))) { // Create dict entry for mod slot result.TryAdd(slot.Name, []); // Add compatible tpls to dicts hashset foreach (var compatibleItemTpl in slot.Properties.Filters.FirstOrDefault().Filter) { result[slot.Name].Add(compatibleItemTpl); } } } return result; } /// /// Create weapon mod pool and set generated flag to true /// protected ConcurrentDictionary>> GenerateWeaponPool() { var weaponsAndMods = databaseService .GetItems() .Values.Where(item => string.Equals(item.Type, "Item", StringComparison.OrdinalIgnoreCase) && itemHelper.IsOfBaseclasses(item.Id, [BaseClasses.WEAPON, BaseClasses.MOD]) ); return GeneratePool(weaponsAndMods, "weapon"); } /// /// Create gear mod pool and set generated flag to true /// protected ConcurrentDictionary>> GenerateGearPool() { var gearAndMods = databaseService .GetItems() .Values.Where(item => string.Equals(item.Type, "Item", StringComparison.OrdinalIgnoreCase) && itemHelper.IsOfBaseclasses( item.Id, [BaseClasses.ARMORED_EQUIPMENT, BaseClasses.VEST, BaseClasses.ARMOR, BaseClasses.HEADWEAR, BaseClasses.MOD] ) ); return GeneratePool(gearAndMods, "gear"); } }