using SPTarkov.Common.Extensions; using SPTarkov.DI.Annotations; using SPTarkov.Server.Core.Extensions; using SPTarkov.Server.Core.Helpers; using SPTarkov.Server.Core.Models.Common; using SPTarkov.Server.Core.Models.Eft.Common.Tables; using SPTarkov.Server.Core.Models.Eft.Hideout; using SPTarkov.Server.Core.Models.Enums; using SPTarkov.Server.Core.Models.Spt.Config; using SPTarkov.Server.Core.Models.Spt.Hideout; using SPTarkov.Server.Core.Models.Utils; using SPTarkov.Server.Core.Servers; using SPTarkov.Server.Core.Services; using SPTarkov.Server.Core.Utils; using SPTarkov.Server.Core.Utils.Cloners; namespace SPTarkov.Server.Core.Generators; [Injectable] public class ScavCaseRewardGenerator( ISptLogger logger, RandomUtil randomUtil, ItemHelper itemHelper, PresetHelper presetHelper, DatabaseService databaseService, RagfairPriceService ragfairPriceService, SeasonalEventService seasonalEventService, ItemFilterService itemFilterService, ServerLocalisationService localisationService, ConfigServer configServer, ICloner cloner ) { protected List DbAmmoItemsCache = []; protected List DbItemsCache = []; protected readonly ScavCaseConfig ScavCaseConfig = configServer.GetConfig(); /// /// Create an array of rewards that will be given to the player upon completing their scav case build /// /// recipe of the scav case craft /// Product array public IEnumerable> Generate(MongoId recipeId) { CacheDbItems(); // Get scavcase details from hideout/scavcase.json var scavCaseDetails = databaseService.GetHideout().Production.ScavRecipes.FirstOrDefault(r => r.Id == recipeId); var rewardItemCounts = GetScavCaseRewardCountsAndPrices(scavCaseDetails); // Get items that fit the price criteria as set by the scavCase config var commonPricedItems = GetFilteredItemsByPrice(DbItemsCache, rewardItemCounts.Common); var rarePricedItems = GetFilteredItemsByPrice(DbItemsCache, rewardItemCounts.Rare); var superRarePricedItems = GetFilteredItemsByPrice(DbItemsCache, rewardItemCounts.Superrare); // Get randomly picked items from each item collection, the count range of which is defined in hideout/scavcase.json var randomlyPickedCommonRewards = PickRandomRewards(commonPricedItems, rewardItemCounts.Common, RewardRarity.Common); var randomlyPickedRareRewards = PickRandomRewards(rarePricedItems, rewardItemCounts.Rare, RewardRarity.Rare); var randomlyPickedSuperRareRewards = PickRandomRewards(superRarePricedItems, rewardItemCounts.Superrare, RewardRarity.SuperRare); // Add randomised stack sizes to ammo and money rewards var commonRewards = RandomiseContainerItemRewards(randomlyPickedCommonRewards, RewardRarity.Common); var rareRewards = RandomiseContainerItemRewards(randomlyPickedRareRewards, RewardRarity.Rare); var superRareRewards = RandomiseContainerItemRewards(randomlyPickedSuperRareRewards, RewardRarity.SuperRare); var result = commonRewards.Concat(rareRewards).Concat(superRareRewards); return result; } /// /// Get all db items that are not blacklisted in scavcase config or global blacklist /// Store in class field /// protected void CacheDbItems() { // Get an array of seasonal items that should not be shown right now as seasonal event is not active var inactiveSeasonalItems = seasonalEventService.GetInactiveSeasonalEventItems(); if (!DbItemsCache.Any()) { DbItemsCache = databaseService .GetItems() .Values.Where(item => { // Base "Item" item has no parent, ignore it if (item.Parent == MongoId.Empty()) { return false; } if (item.Type == "Node") { return false; } if (item.Properties.QuestItem ?? false) { return false; } // Skip item if item id is on blacklist if ( item.Type != "Item" || ScavCaseConfig.RewardItemBlacklist.Contains(item.Id) || itemFilterService.IsItemBlacklisted(item.Id) ) { return false; } // Globally reward-blacklisted if (itemFilterService.IsItemRewardBlacklisted(item.Id)) { return false; } if (!ScavCaseConfig.AllowBossItemsAsRewards && itemFilterService.IsBossItem(item.Id)) { return false; } // Skip item if parent id is blacklisted if (itemHelper.IsOfBaseclasses(item.Id, ScavCaseConfig.RewardItemParentBlacklist)) { return false; } if (inactiveSeasonalItems.Contains(item.Id)) { return false; } return true; }) .ToList(); } if (!DbAmmoItemsCache.Any()) { DbAmmoItemsCache = databaseService .GetItems() .Values.Where(item => { // Base "Item" item has no parent, ignore it if (item.Parent == MongoId.Empty()) { return false; } if (item.Type != "Item") { return false; } // Not ammo, skip if (!itemHelper.IsOfBaseclass(item.Id, BaseClasses.AMMO)) { return false; } // Skip item if item id is on blacklist if (ScavCaseConfig.RewardItemBlacklist.Contains(item.Id) || itemFilterService.IsItemBlacklisted(item.Id)) { return false; } // Globally reward-blacklisted if (itemFilterService.IsItemRewardBlacklisted(item.Id)) { return false; } if (!ScavCaseConfig.AllowBossItemsAsRewards && itemFilterService.IsBossItem(item.Id)) { return false; } // Skip seasonal items if (inactiveSeasonalItems.Contains(item.Id)) { return false; } // Skip ammo that doesn't stack as high as value in config if (item.Properties.StackMaxSize < ScavCaseConfig.AmmoRewards.MinStackSize) { return false; } return true; }) .ToList(); } } /// /// Pick a number of items to be rewards, the count is defined by the values in `itemFilters` param /// /// item pool to pick rewards from /// how the rewards should be filtered down (by item count) /// Rarity of reward /// protected List PickRandomRewards(List items, RewardCountAndPriceDetails itemFilters, string rarity) { List result = []; var rewardWasMoney = false; var rewardWasAmmo = false; var randomCount = randomUtil.GetInt((int)itemFilters.MinCount, (int)itemFilters.MaxCount); for (var i = 0; i < randomCount; i++) { if (RewardShouldBeMoney() && !rewardWasMoney) { // Only allow one reward to be money result.Add(GetRandomMoney()); if (!ScavCaseConfig.AllowMultipleMoneyRewardsPerRarity) { rewardWasMoney = true; } } else if (RewardShouldBeAmmo() && !rewardWasAmmo) { // Only allow one reward to be ammo result.Add(GetRandomAmmo(rarity)); if (!ScavCaseConfig.AllowMultipleAmmoRewardsPerRarity) { rewardWasAmmo = true; } } else { result.Add(randomUtil.GetArrayValue(items)); } } return result; } /// /// Choose if money should be a reward based on the moneyRewardChancePercent config chance in scavCaseConfig /// /// true if reward should be money protected bool RewardShouldBeMoney() { return randomUtil.GetChance100(ScavCaseConfig.MoneyRewards.MoneyRewardChancePercent); } /// /// Choose if ammo should be a reward based on the ammoRewardChancePercent config chance in scavCaseConfig /// /// true if reward should be ammo protected bool RewardShouldBeAmmo() { return randomUtil.GetChance100(ScavCaseConfig.AmmoRewards.AmmoRewardChancePercent); } /// /// Choose from rouble/dollar/euro at random /// protected TemplateItem GetRandomMoney() { List money = []; var items = databaseService.GetItems(); money.Add(items[Money.ROUBLES]); money.Add(items[Money.EUROS]); money.Add(items[Money.DOLLARS]); money.Add(items[Money.GP]); return randomUtil.GetArrayValue(money); } /// /// Get a random ammo from items.json that is not in the ammo blacklist AND inside the price range defined in scavcase.json config /// /// The rarity desired ammo reward is for /// random ammo item from items.json protected TemplateItem GetRandomAmmo(string rarity) { var possibleAmmoPool = DbAmmoItemsCache.Where(ammo => { // Is ammo handbook price between desired range var handbookPrice = ragfairPriceService.GetStaticPriceForItem(ammo.Id); if ( ScavCaseConfig.AmmoRewards.AmmoRewardValueRangeRub.TryGetValue(rarity, out var matchingAmmoRewardForRarity) && handbookPrice >= matchingAmmoRewardForRarity.Min && handbookPrice <= matchingAmmoRewardForRarity.Max ) { return true; } return false; }); if (!possibleAmmoPool.Any()) { // Filtered pool is empty logger.Warning(localisationService.GetText("scavcase-no_cartridges_found_matching_price")); } // Get a random ammo and return it return randomUtil.GetArrayValue(possibleAmmoPool); } /// /// Take all the rewards picked create the Product object array ready to return to calling code. /// Also add a stack count to ammo and money /// /// items to convert /// The rarity desired ammo reward is for /// Product array protected List> RandomiseContainerItemRewards(IEnumerable rewardItems, string rarity) { // Each array is an item + children List> result = []; foreach (var rewardItemDb in rewardItems) { List resultItem = [ new() { Id = new MongoId(), Template = rewardItemDb.Id, Upd = null, }, ]; var rootItem = resultItem.FirstOrDefault(); if (itemHelper.IsOfBaseclass(rewardItemDb.Id, BaseClasses.AMMO_BOX)) { itemHelper.AddCartridgesToAmmoBox(resultItem, rewardItemDb); } // Armor or weapon = use default preset from globals.json else if ( itemHelper.ArmorItemHasRemovableOrSoftInsertSlots(rewardItemDb.Id) || itemHelper.IsOfBaseclass(rewardItemDb.Id, BaseClasses.WEAPON) ) { var preset = presetHelper.GetDefaultPreset(rewardItemDb.Id); if (preset is null) { logger.Warning($"No preset for item: {rewardItemDb.Id} {rewardItemDb.Name}, skipping"); continue; } // Ensure preset has unique ids and is cloned so we don't alter the preset data stored in memory var presetAndMods = cloner.Clone(preset.Items).ReplaceIDs().ToList(); presetAndMods.RemapRootItemId(); resultItem = presetAndMods; } else if (itemHelper.IsOfBaseclasses(rewardItemDb.Id, [BaseClasses.AMMO, BaseClasses.MONEY])) { rootItem.Upd = new Upd { StackObjectsCount = GetRandomAmountRewardForScavCase(rewardItemDb, rarity) }; } result.Add(resultItem); } return result; } /// /// /// all items from the items.json /// controls how the dbItems will be filtered and returned (handbook price) /// filtered dbItems array protected List GetFilteredItemsByPrice(List dbItems, RewardCountAndPriceDetails itemFilters) { return dbItems .Where(item => { var handbookPrice = ragfairPriceService.GetStaticPriceForItem(item.Id); if (handbookPrice >= itemFilters.MinPriceRub && handbookPrice <= itemFilters.MaxPriceRub) { return true; } return false; }) .ToList(); } /// /// Gathers the reward min and max count params for each reward quality level from config and scavcase.json into a single object /// /// production.json/scavRecipes object /// ScavCaseRewardCountsAndPrices object protected ScavCaseRewardCountsAndPrices GetScavCaseRewardCountsAndPrices(ScavRecipe scavCaseDetails) { return new ScavCaseRewardCountsAndPrices { // Create reward min/max counts for each type Common = new RewardCountAndPriceDetails { MinCount = scavCaseDetails.EndProducts.Common.Min, MaxCount = scavCaseDetails.EndProducts.Common.Max, MinPriceRub = ScavCaseConfig.RewardItemValueRangeRub[RewardRarity.Common].Min, MaxPriceRub = ScavCaseConfig.RewardItemValueRangeRub[RewardRarity.Common].Max, }, Rare = new RewardCountAndPriceDetails { MinCount = scavCaseDetails.EndProducts.Rare.Min, MaxCount = scavCaseDetails.EndProducts.Rare.Max, MinPriceRub = ScavCaseConfig.RewardItemValueRangeRub[RewardRarity.Rare].Min, MaxPriceRub = ScavCaseConfig.RewardItemValueRangeRub[RewardRarity.Rare].Max, }, Superrare = new RewardCountAndPriceDetails { MinCount = scavCaseDetails.EndProducts.Superrare.Min, MaxCount = scavCaseDetails.EndProducts.Superrare.Max, MinPriceRub = ScavCaseConfig.RewardItemValueRangeRub[RewardRarity.SuperRare].Min, MaxPriceRub = ScavCaseConfig.RewardItemValueRangeRub[RewardRarity.SuperRare].Max, }, }; } /// /// Randomises the size of ammo and money stacks /// /// ammo or money item /// rarity (common/rare/superrare) /// value to set stack count to protected int GetRandomAmountRewardForScavCase(TemplateItem itemToCalculate, string rarity) { var parentId = itemToCalculate.Parent; if (parentId == BaseClasses.AMMO) { return GetRandomisedAmmoRewardStackSize(itemToCalculate); } else if (parentId == BaseClasses.MONEY) { return GetRandomisedMoneyRewardStackSize(itemToCalculate, rarity); } else { return 1; } } /// /// Randomises the size of ammo stacks /// /// ammo or money item /// value to set stack count to protected int GetRandomisedAmmoRewardStackSize(TemplateItem itemToCalculate) { return randomUtil.GetInt(ScavCaseConfig.AmmoRewards.MinStackSize, itemToCalculate.Properties.StackMaxSize ?? 0); } /// /// Randomises the size of money stacks /// /// ammo or money item /// rarity (common/rare/superrare) /// value to set stack count to protected int GetRandomisedMoneyRewardStackSize(TemplateItem itemToCalculate, string rarity) { var id = itemToCalculate.Id; if (id == Money.ROUBLES) { return randomUtil.GetInt( ScavCaseConfig.MoneyRewards.RubCount.GetByJsonProperty>(rarity).Min, ScavCaseConfig.MoneyRewards.RubCount.GetByJsonProperty>(rarity).Max ); } else if (id == Money.EUROS) { return randomUtil.GetInt( ScavCaseConfig.MoneyRewards.EurCount.GetByJsonProperty>(rarity).Min, ScavCaseConfig.MoneyRewards.EurCount.GetByJsonProperty>(rarity).Max ); } else if (id == Money.DOLLARS) { return randomUtil.GetInt( ScavCaseConfig.MoneyRewards.UsdCount.GetByJsonProperty>(rarity).Min, ScavCaseConfig.MoneyRewards.UsdCount.GetByJsonProperty>(rarity).Max ); } else if (id == Money.GP) { return randomUtil.GetInt( ScavCaseConfig.MoneyRewards.GpCount.GetByJsonProperty>(rarity).Min, ScavCaseConfig.MoneyRewards.GpCount.GetByJsonProperty>(rarity).Max ); } else { return 1; } } } public record RewardRarity { public const string Common = "common"; public const string Rare = "rare"; public const string SuperRare = "superrare"; }