using System.Text.Json.Serialization; using SPTarkov.DI.Annotations; using SPTarkov.Server.Core.Helpers; using SPTarkov.Server.Core.Models.Common; using SPTarkov.Server.Core.Models.Eft.Common; using SPTarkov.Server.Core.Models.Eft.Common.Tables; using SPTarkov.Server.Core.Models.Enums; using SPTarkov.Server.Core.Models.Spt.Config; using SPTarkov.Server.Core.Models.Spt.Services; using SPTarkov.Server.Core.Models.Utils; using SPTarkov.Server.Core.Services; using SPTarkov.Server.Core.Utils; using SPTarkov.Server.Core.Utils.Cloners; using LogLevel = SPTarkov.Server.Core.Models.Spt.Logging.LogLevel; namespace SPTarkov.Server.Core.Generators; [Injectable] public class LootGenerator( ISptLogger _logger, RandomUtil _randomUtil, HashUtil _hashUtil, ItemHelper _itemHelper, PresetHelper _presetHelper, DatabaseService _databaseService, ItemFilterService _itemFilterService, LocalisationService _localisationService, WeightedRandomHelper _weightedRandomHelper, RagfairLinkedItemService _ragfairLinkedItemService, SeasonalEventService _seasonalEventService, ICloner _cloner ) { /// /// Generate a list of items based on configuration options parameter /// /// parameters to adjust how loot is generated /// An array of loot items public List> CreateRandomLoot(LootRequest options) { var result = new List>(); var itemTypeCounts = InitItemLimitCounter(options.ItemLimits); // Handle sealed weapon containers var sealedWeaponCrateCount = _randomUtil.GetInt( options.WeaponCrateCount.Min, options.WeaponCrateCount.Max ); if (sealedWeaponCrateCount > 0) { // Get list of all sealed containers from db - they're all the same, just for flavor var itemsDb = _itemHelper.GetItems(); var sealedWeaponContainerPool = itemsDb.Where(item => item.Name.Contains("event_container_airdrop") ); for (var index = 0; index < sealedWeaponCrateCount; index++) { // Choose one at random + add to results array var chosenSealedContainer = _randomUtil.GetArrayValue(sealedWeaponContainerPool); result.Add( [ new Item { Id = _hashUtil.Generate(), Template = chosenSealedContainer.Id, Upd = new Upd { StackObjectsCount = 1, SpawnedInSession = true }, }, ] ); } } // Get items from items.json that have a type of item + not in global blacklist + base type is in whitelist var rewardPoolResults = GetItemRewardPool( options.ItemBlacklist, options.ItemTypeWhitelist, options.UseRewardItemBlacklist.GetValueOrDefault(false), options.AllowBossItems.GetValueOrDefault(false), options.BlockSeasonalItemsOutOfSeason.GetValueOrDefault(false) ); // Pool has items we could add as loot, proceed if (rewardPoolResults.ItemPool.Count > 0) { var randomisedItemCount = _randomUtil.GetInt( options.ItemCount.Min, options.ItemCount.Max ); for (var index = 0; index < randomisedItemCount; index++) { if ( !FindAndAddRandomItemToLoot( rewardPoolResults.ItemPool, itemTypeCounts, options, result ) ) // Failed to add, reduce index so we get another attempt { index--; } } } var globalDefaultPresets = _presetHelper.GetDefaultPresets().Values; // Filter default presets to just weapons var randomisedWeaponPresetCount = _randomUtil.GetInt( options.WeaponPresetCount.Min, options.WeaponPresetCount.Max ); if (randomisedWeaponPresetCount > 0) { var weaponDefaultPresets = globalDefaultPresets .Where(preset => _itemHelper.IsOfBaseclass(preset.Encyclopedia, BaseClasses.WEAPON)) .ToList(); if (weaponDefaultPresets.Any()) { for (var index = 0; index < randomisedWeaponPresetCount; index++) { if ( !FindAndAddRandomPresetToLoot( weaponDefaultPresets, itemTypeCounts, rewardPoolResults.Blacklist, result ) ) // Failed to add, reduce index so we get another attempt { index--; } } } } // Filter default presets to just armors and then filter again by protection level var randomisedArmorPresetCount = _randomUtil.GetInt( options.ArmorPresetCount.Min, options.ArmorPresetCount.Max ); if (randomisedArmorPresetCount > 0) { var armorDefaultPresets = globalDefaultPresets.Where(preset => _itemHelper.ArmorItemCanHoldMods(preset.Encyclopedia) ); var levelFilteredArmorPresets = armorDefaultPresets .Where(armor => IsArmorOfDesiredProtectionLevel(armor, options)) .ToList(); // Add some armors to rewards if (levelFilteredArmorPresets.Any()) { for (var index = 0; index < randomisedArmorPresetCount; index++) { if ( !FindAndAddRandomPresetToLoot( levelFilteredArmorPresets, itemTypeCounts, rewardPoolResults.Blacklist, result ) ) // Failed to add, reduce index so we get another attempt { index--; } } } } return result; } /// /// Generate An array of items /// TODO - handle ammo packs /// /// Dictionary of item tpls with minmax values /// Array of Item public List> CreateForcedLoot(Dictionary> forcedLootToAdd) { var result = new List>(); var defaultPresets = _presetHelper.GetDefaultPresetsByTplKey(); foreach (var (itemTpl, details) in forcedLootToAdd) { // How many of this item we want var randomisedItemCount = _randomUtil.GetInt(details.Min, details.Max); // Check if item being added has a preset and use that instead if (defaultPresets.ContainsKey(itemTpl)) { // Use default preset data if (defaultPresets.TryGetValue(itemTpl, out var preset)) { // Add the chosen preset as many times as randomisedItemCount states for (var i = 0; i < randomisedItemCount; i++) { // Clone preset and alter Ids to be unique var presetWithUniqueIds = _itemHelper.ReplaceIDs( _cloner.Clone(preset.Items) ); // Add to results result.Add(presetWithUniqueIds); } } continue; } // Non-preset item to be added var newLootItem = new Item { Id = _hashUtil.Generate(), Template = itemTpl, Upd = new Upd { StackObjectsCount = randomisedItemCount, SpawnedInSession = true }, }; var splitResults = _itemHelper.SplitStack(newLootItem); foreach (var splitItem in splitResults) { // Add as separate lists result.Add([splitItem]); } } return result; } /// /// Get pool of items from item db that fit passed in param criteria /// /// Prevent these items /// Only allow these items /// Should item.json reward item config be used /// Should boss items be allowed in result /// Prevent seasonal items appearing outside their defined season /// results of filtering + blacklist used protected ItemRewardPoolResults GetItemRewardPool( HashSet itemTplBlacklist, List itemTypeWhitelist, bool useRewardItemBlacklist, bool allowBossItems, bool blockSeasonalItemsOutOfSeason ) { var itemsDb = _databaseService.GetItems().Values; var itemBlacklist = new HashSet(); itemBlacklist.UnionWith([.. _itemFilterService.GetBlacklistedItems(), .. itemTplBlacklist]); if (useRewardItemBlacklist) { var rewardItemBlacklist = _itemFilterService.GetItemRewardBlacklist(); // Get all items that match the blacklisted types and fold into item blacklist var itemTypeBlacklist = _itemFilterService.GetItemRewardBaseTypeBlacklist(); var itemsMatchingTypeBlacklist = itemsDb .Where(templateItem => !string.IsNullOrEmpty(templateItem.Parent)) // Ignore items without parents .Where(templateItem => _itemHelper.IsOfBaseclasses(templateItem.Parent, itemTypeBlacklist) ) .Select(templateItem => templateItem.Id); itemBlacklist.UnionWith([.. rewardItemBlacklist, .. itemsMatchingTypeBlacklist]); } if (!allowBossItems) { itemBlacklist.UnionWith(_itemFilterService.GetBossItems()); } if (blockSeasonalItemsOutOfSeason) { itemBlacklist.UnionWith(_seasonalEventService.GetInactiveSeasonalEventItems()); } var items = itemsDb .Where(item => !itemBlacklist.Contains(item.Id) && string.Equals(item.Type, "item", StringComparison.OrdinalIgnoreCase) && !item.Properties.QuestItem.GetValueOrDefault(false) && itemTypeWhitelist.Contains(item.Parent) ) .ToList(); return new ItemRewardPoolResults { ItemPool = items, Blacklist = itemBlacklist }; } /// /// Filter armor items by their front plates protection level - top if it's a helmet /// /// Armor preset to check /// Loot request options - armor level etc /// True if item has desired armor level protected bool IsArmorOfDesiredProtectionLevel(Preset armor, LootRequest options) { string[] relevantSlots = ["front_plate", "helmet_top", "soft_armor_front"]; foreach (var slotId in relevantSlots) { var armorItem = armor.Items.FirstOrDefault(item => string.Equals(item?.SlotId, slotId)); if (armorItem is null) { continue; } var armorDetails = _itemHelper.GetItem(armorItem.Template).Value; var armorClass = armorDetails.Properties.ArmorClass; return options.ArmorLevelWhitelist.Contains(armorClass.Value); } return false; } /// /// Construct item limit record to hold max and current item count for each item type /// /// limits as defined in config /// record, key: item tplId, value: current/max item count allowed protected Dictionary InitItemLimitCounter(Dictionary limits) { var itemTypeCounts = new Dictionary(); foreach (var itemTypeId in limits) { itemTypeCounts[itemTypeId.Key] = new ItemLimit { Current = 0, Max = limits[itemTypeId.Key], }; } return itemTypeCounts; } /// /// Find a random item in items.json and add to result array /// /// items to choose from /// item limit counts /// item filters /// array to add found item to /// true if item was valid and added to pool protected bool FindAndAddRandomItemToLoot( List items, Dictionary itemTypeCounts, LootRequest options, List> result ) { var randomItem = _randomUtil.GetArrayValue(items); var itemLimitCount = itemTypeCounts.TryGetValue( randomItem.Parent, out var randomItemLimitCount ); if (!itemLimitCount && randomItemLimitCount?.Current > randomItemLimitCount?.Max) { return false; } // Skip armors as they need to come from presets if (_itemHelper.ArmorItemCanHoldMods(randomItem.Id)) { return false; } var newLootItem = new Item { Id = _hashUtil.Generate(), Template = randomItem.Id, Upd = new Upd { StackObjectsCount = 1, SpawnedInSession = true }, }; // Special case - handle items that need a stackcount > 1 if (randomItem.Properties.StackMaxSize > 1) { newLootItem.Upd.StackObjectsCount = GetRandomisedStackCount(randomItem, options); } newLootItem.Template = randomItem.Id; result.Add([newLootItem]); if (randomItemLimitCount is not null) // Increment item count as it's in limit array { randomItemLimitCount.Current++; } // Item added okay return true; } /// /// Get a randomised stack count for an item between its StackMinRandom and StackMaxSize values /// /// item to get stack count of /// loot options /// stack count protected int GetRandomisedStackCount(TemplateItem item, LootRequest options) { var min = item.Properties.StackMinRandom; var max = item.Properties.StackMaxSize; if (options.ItemStackLimits.TryGetValue(item.Id, out var itemLimits)) { min = itemLimits.Min; max = itemLimits.Max; } return _randomUtil.GetInt(min ?? 1, max ?? 1); } /// /// Find a random item in items.json and add to result list /// /// Presets to choose from /// Item limit counts /// Items to skip /// List to add chosen preset to /// true if preset was valid and added to pool protected bool FindAndAddRandomPresetToLoot( List presetPool, Dictionary itemTypeCounts, HashSet itemBlacklist, List> result ) { if (presetPool.Count == 0) { _logger.Warning(_localisationService.GetText("loot-preset_pool_is_empty")); return false; } // Choose random preset and get details from item db using encyclopedia value (encyclopedia === tplId) var chosenPreset = _randomUtil.GetArrayValue(presetPool); // No `_encyclopedia` property, not possible to reliably get root item tpl if (chosenPreset?.Encyclopedia is null) { if (_logger.IsLogEnabled(LogLevel.Debug)) { _logger.Warning( _localisationService.GetText( "loot-chosen_preset_missing_encyclopedia_value", chosenPreset?.Id ) ); } return false; } // Get preset root item db details via its `_encyclopedia` property var itemDbDetails = _itemHelper.GetItem(chosenPreset.Encyclopedia); if (!itemDbDetails.Key) { if (_logger.IsLogEnabled(LogLevel.Debug)) { _logger.Debug( $"$Unable to find preset with tpl: {chosenPreset.Encyclopedia}, skipping" ); } return false; } // Skip preset if root item is blacklisted if (itemBlacklist.Contains(chosenPreset.Items.FirstOrDefault().Template)) { return false; } // Some custom mod items lack a parent property if (itemDbDetails.Value?.Parent is null) { _logger.Error( _localisationService.GetText( "loot-item_missing_parentid", itemDbDetails.Value?.Name ) ); return false; } // Check chosen preset hasn't exceeded spawn limit var hasItemLimitCount = itemTypeCounts.TryGetValue( itemDbDetails.Value.Parent, out var itemLimitCount ); if (!hasItemLimitCount && itemLimitCount?.Current > itemLimitCount?.Max) { return false; } var presetAndMods = _itemHelper.ReplaceIDs(_cloner.Clone(chosenPreset.Items)); _itemHelper.RemapRootItemId(presetAndMods); _itemHelper.SetFoundInRaid(presetAndMods); // Add chosen preset tpl to result array result.Add(presetAndMods); if (itemLimitCount is not null) // Increment item count as item has been chosen and its inside itemLimitCount dictionary { itemLimitCount.Current++; } // Item added okay return true; } /// /// Sealed weapon containers have a weapon + associated mods inside them + assortment of other things (food/meds) /// /// sealed weapon container settings /// List of items with children lists public List> GetSealedWeaponCaseLoot( SealedAirdropContainerSettings containerSettings ) { List> itemsToReturn = []; // Choose a weapon to give to the player (weighted) var chosenWeaponTpl = _weightedRandomHelper.GetWeightedValue( containerSettings.WeaponRewardWeight ); // Get itemDb details of weapon var weaponDetailsDb = _itemHelper.GetItem(chosenWeaponTpl); if (!weaponDetailsDb.Key) { _logger.Error( _localisationService.GetText( "loot-non_item_picked_as_sealed_weapon_crate_reward", chosenWeaponTpl ) ); return itemsToReturn; } // Get weapon preset - default or choose a random one from globals.json preset pool var chosenWeaponPreset = containerSettings.DefaultPresetsOnly ? _presetHelper.GetDefaultPreset(chosenWeaponTpl) : _randomUtil.GetArrayValue(_presetHelper.GetPresets(chosenWeaponTpl)); // No default preset found for weapon, choose a random one if (chosenWeaponPreset is null) { _logger.Warning( _localisationService.GetText( "loot-default_preset_not_found_using_random", chosenWeaponTpl ) ); chosenWeaponPreset = _randomUtil.GetArrayValue( _presetHelper.GetPresets(chosenWeaponTpl) ); } // Clean up Ids to ensure they're all unique and prevent collisions var presetAndMods = _itemHelper.ReplaceIDs(_cloner.Clone(chosenWeaponPreset.Items)); _itemHelper.RemapRootItemId(presetAndMods); // Add preset to return object itemsToReturn.Add(presetAndMods); // Get a random collection of weapon mods related to chosen weawpon and add them to result array var linkedItemsToWeapon = _ragfairLinkedItemService.GetLinkedDbItems(chosenWeaponTpl); itemsToReturn.AddRange( GetSealedContainerWeaponModRewards( containerSettings, linkedItemsToWeapon, chosenWeaponPreset ) ); // Handle non-weapon mod reward types itemsToReturn.AddRange( GetSealedContainerNonWeaponModRewards(containerSettings, weaponDetailsDb.Value) ); return itemsToReturn; } /// /// Get non-weapon mod rewards for a sealed container /// /// Sealed weapon container settings /// Details for the weapon to reward player /// List of item with children lists protected List> GetSealedContainerNonWeaponModRewards( SealedAirdropContainerSettings containerSettings, TemplateItem weaponDetailsDb ) { List> rewards = []; foreach (var (rewardKey, settings) in containerSettings.RewardTypeLimits) { var rewardCount = _randomUtil.GetInt(settings.Min, settings.Max); if (rewardCount == 0) { continue; } // Edge case - ammo boxes if (rewardKey == BaseClasses.AMMO_BOX) { // Get ammo boxes from db var ammoBoxesDetails = containerSettings.AmmoBoxWhitelist.Select(tpl => { var itemDetails = _itemHelper.GetItem(tpl); return itemDetails.Value; }); // Need to find boxes that matches weapons caliber var weaponCaliber = weaponDetailsDb.Properties.AmmoCaliber; var ammoBoxesMatchingCaliber = ammoBoxesDetails.Where(x => x.Properties.AmmoCaliber == weaponCaliber ); if (!ammoBoxesMatchingCaliber.Any()) { if (_logger.IsLogEnabled(LogLevel.Debug)) { _logger.Debug($"No ammo box with caliber {weaponCaliber} found, skipping"); } continue; } for (var index = 0; index < rewardCount; index++) { var chosenAmmoBox = _randomUtil.GetArrayValue(ammoBoxesMatchingCaliber); var ammoBoxReward = new List { new() { Id = _hashUtil.Generate(), Template = chosenAmmoBox.Id }, }; _itemHelper.AddCartridgesToAmmoBox(ammoBoxReward, chosenAmmoBox); rewards.Add(ammoBoxReward); } continue; } // Get all items of the desired type + not quest items + not globally blacklisted var rewardItemPool = _databaseService .GetItems() .Values.Where(item => item.Parent == rewardKey && string.Equals(item.Type, "item", StringComparison.OrdinalIgnoreCase) && _itemFilterService.IsItemBlacklisted(item.Id) && !(containerSettings.AllowBossItems || _itemFilterService.IsBossItem(item.Id)) && item.Properties.QuestItem is null ); if (!rewardItemPool.Any()) { if (_logger.IsLogEnabled(LogLevel.Debug)) { _logger.Debug($"No items with base type of {rewardKey} found, skipping"); } continue; } for (var index = 0; index < rewardCount; index++) { // Choose a random item from pool var chosenRewardItem = _randomUtil.GetArrayValue(rewardItemPool); var rewardItem = new List { new() { Id = _hashUtil.Generate(), Template = chosenRewardItem.Id }, }; rewards.Add(rewardItem); } } return rewards; } /// /// Iterate over the container weaponModRewardLimits settings and create a list of weapon mods to reward player /// /// Sealed weapon container settings /// All items that can be attached/inserted into weapon /// The weapon preset given to player as reward /// List of item with children lists protected List> GetSealedContainerWeaponModRewards( SealedAirdropContainerSettings containerSettings, List linkedItemsToWeapon, Preset chosenWeaponPreset ) { List> modRewards = []; foreach (var (rewardKey, settings) in containerSettings.WeaponModRewardLimits) { var rewardCount = _randomUtil.GetInt(settings.Min, settings.Max); // Nothing to add, skip reward type if (rewardCount == 0) { continue; } // Get items that fulfil reward type criteria from items that fit on gun var relatedItems = linkedItemsToWeapon?.Where(item => item?.Parent == rewardKey && !_itemFilterService.IsItemBlacklisted(item.Id) ); if (relatedItems is null || !relatedItems.Any()) { if (_logger.IsLogEnabled(LogLevel.Debug)) { _logger.Debug( $"No items found to fulfil reward type: {rewardKey} for weapon: {chosenWeaponPreset.Name}, skipping type" ); } continue; } // Find a random item of the desired type and add as reward for (var index = 0; index < rewardCount; index++) { var chosenItem = _randomUtil.DrawRandomFromList(relatedItems.ToList()); var reward = new List { new() { Id = _hashUtil.Generate(), Template = chosenItem[0].Id }, }; modRewards.Add(reward); } } return modRewards; } /// /// Handle event-related loot containers - currently just the halloween jack-o-lanterns that give food rewards /// /// /// List of item with children lists public List> GetRandomLootContainerLoot(RewardDetails rewardContainerDetails) { List> itemsToReturn = []; // Get random items and add to newItemRequest for (var index = 0; index < rewardContainerDetails.RewardCount; index++) { // Pick random reward from pool, add to request object var chosenRewardItemTpl = PickRewardItem(rewardContainerDetails); if (_presetHelper.HasPreset(chosenRewardItemTpl)) { var preset = _presetHelper.GetDefaultPreset(chosenRewardItemTpl); // Ensure preset has unique ids and is cloned so we don't alter the preset data stored in memory var presetAndMods = _itemHelper.ReplaceIDs(preset.Items); _itemHelper.RemapRootItemId(presetAndMods); itemsToReturn.Add(presetAndMods); continue; } List rewardItem = [ new() { Id = _hashUtil.Generate(), Template = chosenRewardItemTpl }, ]; itemsToReturn.Add(rewardItem); } return itemsToReturn; } /// /// Pick a reward item based on the reward details data /// /// /// Single tpl protected string PickRewardItem(RewardDetails rewardContainerDetails) { if ( rewardContainerDetails.RewardTplPool is not null && rewardContainerDetails.RewardTplPool.Count > 0 ) { return _weightedRandomHelper.GetWeightedValue(rewardContainerDetails.RewardTplPool); } return _randomUtil.GetArrayValue( GetItemRewardPool([], rewardContainerDetails.RewardTypePool, true, true, false) .ItemPool.Select(item => item.Id) ); } public record ItemRewardPoolResults { public List ItemPool { get; set; } public HashSet Blacklist { get; set; } } } public class ItemLimit { [JsonPropertyName("current")] public int Current { get; set; } [JsonPropertyName("max")] public int Max { get; set; } }