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; using SPTarkov.Server.Core.Models.Eft.Common.Tables; using SPTarkov.Server.Core.Models.Eft.Hideout; using SPTarkov.Server.Core.Models.Eft.ItemEvent; using SPTarkov.Server.Core.Models.Eft.Profile; using SPTarkov.Server.Core.Models.Enums; using SPTarkov.Server.Core.Models.Enums.Hideout; using SPTarkov.Server.Core.Models.Spt.Config; using SPTarkov.Server.Core.Models.Spt.Hideout; using SPTarkov.Server.Core.Models.Utils; using SPTarkov.Server.Core.Routers; using SPTarkov.Server.Core.Servers; using SPTarkov.Server.Core.Utils; using SPTarkov.Server.Core.Utils.Cloners; using Hideout = SPTarkov.Server.Core.Models.Spt.Hideout.Hideout; using LogLevel = SPTarkov.Server.Core.Models.Spt.Logging.LogLevel; namespace SPTarkov.Server.Core.Services; [Injectable(InjectionType.Singleton)] public class CircleOfCultistService( ISptLogger logger, TimeUtil timeUtil, ICloner cloner, EventOutputHolder eventOutputHolder, RandomUtil randomUtil, HashUtil hashUtil, ItemHelper itemHelper, PresetHelper presetHelper, ProfileHelper profileHelper, InventoryHelper inventoryHelper, HideoutHelper hideoutHelper, QuestHelper questHelper, DatabaseService databaseService, ItemFilterService itemFilterService, SeasonalEventService seasonalEventService, ServerLocalisationService localisationService, ConfigServer configServer ) { protected const string CircleOfCultistSlotId = "CircleOfCultistsGrid1"; protected readonly HideoutConfig _hideoutConfig = configServer.GetConfig(); /// /// Start a sacrifice event /// Generate rewards /// Delete sacrificed items /// /// Session id /// Player profile doing sacrifice /// Client request /// ItemEventRouterResponse public ItemEventRouterResponse StartSacrifice( MongoId sessionId, PmcData pmcData, HideoutCircleOfCultistProductionStartRequestData request ) { var output = eventOutputHolder.GetOutput(sessionId); var cultistCircleStashId = pmcData.Inventory?.HideoutAreaStashes?.GetValueOrDefault( ((int)HideoutAreas.CircleOfCultists).ToString() ); if (cultistCircleStashId is null) { logger.Error(localisationService.GetText("cultistcircle-unable_to_find_stash_id")); return output; } // `cultistRecipes` just has single recipeId var cultistCraftData = databaseService.GetHideout().Production.CultistRecipes.FirstOrDefault(); var sacrificedItems = GetSacrificedItems(pmcData); var sacrificedItemCostRoubles = sacrificedItems.Aggregate(0D, (sum, curr) => sum + (itemHelper.GetItemPrice(curr.Template) ?? 0)); var rewardAmountMultiplier = GetRewardAmountMultiplier(pmcData, _hideoutConfig.CultistCircle); // Get the rouble amount we generate rewards with from cost of sacrificed items * above multiplier var rewardAmountRoubles = Math.Round(sacrificedItemCostRoubles * rewardAmountMultiplier); // Check if it matches any direct swap recipes var directRewardsCache = GenerateSacrificedItemsCache(_hideoutConfig.CultistCircle.DirectRewards); var directRewardSettings = CheckForDirectReward(sessionId, sacrificedItems, directRewardsCache); var hasDirectReward = directRewardSettings?.Reward.Count > 0; // Get craft time and bonus status var craftingInfo = GetCircleCraftingInfo(rewardAmountRoubles, _hideoutConfig.CultistCircle, directRewardSettings); // Create production in pmc profile RegisterCircleOfCultistProduction(sessionId, pmcData, cultistCraftData.Id, sacrificedItems, craftingInfo.Time); // Remove sacrificed items from circle inventory foreach (var item in sacrificedItems) { if (item.SlotId == CircleOfCultistSlotId) { inventoryHelper.RemoveItem(pmcData, item.Id, sessionId, output); } } var rewards = hasDirectReward ? GetDirectRewards(sessionId, directRewardSettings, cultistCircleStashId.Value) : GetRewardsWithinBudget( GetCultistCircleRewardPool(sessionId, pmcData, craftingInfo, _hideoutConfig.CultistCircle), rewardAmountRoubles, cultistCircleStashId.Value, _hideoutConfig.CultistCircle ); // Get the container grid for cultist stash area var cultistStashDbItem = itemHelper.GetItem(ItemTpl.HIDEOUTAREACONTAINER_CIRCLEOFCULTISTS_STASH_1); // Ensure rewards fit into container var containerGrid = inventoryHelper.GetContainerSlotMap(cultistStashDbItem.Value.Id); AddRewardsToCircleContainer(sessionId, pmcData, rewards, containerGrid, cultistCircleStashId.Value, output); return output; } /// /// Get the reward amount multiple value based on players hideout management skill + configs rewardPriceMultiplierMinMax values /// /// Player profile /// Circle config settings /// Reward Amount Multiplier private double GetRewardAmountMultiplier(PmcData pmcData, CultistCircleSettings cultistCircleSettings) { // Get a randomised value to multiply the sacrificed rouble cost by var rewardAmountMultiplier = randomUtil.GetDouble( cultistCircleSettings.RewardPriceMultiplierMinMax.Min, cultistCircleSettings.RewardPriceMultiplierMinMax.Max ); // Adjust value generated by the players hideout management skill var hideoutManagementSkill = pmcData.GetSkillFromProfile(SkillTypes.HideoutManagement); if (hideoutManagementSkill is not null) { rewardAmountMultiplier *= (float)(1 + hideoutManagementSkill.Progress / 10000); // 5100 becomes 0.51, add 1 to it, 1.51, multiply the bonus by it (e.g. 1.2 x 1.51) } return rewardAmountMultiplier; } /// /// Register production inside player profile /// /// Session id /// Player profile /// Recipe id /// Items player sacrificed /// How long the ritual should take protected void RegisterCircleOfCultistProduction( MongoId sessionId, PmcData pmcData, MongoId recipeId, List sacrificedItems, double craftingTime ) { // Create circle production/craft object to add to player profile var cultistProduction = hideoutHelper.InitProduction(recipeId, craftingTime, false); // Flag as cultist circle for code to pick up later cultistProduction.SptIsCultistCircle = true; // Add items player sacrificed cultistProduction.GivenItemsInStart = sacrificedItems; // Add circle production to profile keyed to recipe id pmcData.Hideout.Production[recipeId] = cultistProduction; } /// /// Get the circle craft time as seconds, value is based on reward item value /// And get the bonus status to determine what tier of reward is given /// /// Value of rewards in roubles /// Circle config values /// OPTIONAL - Values related to direct reward being given /// craft time + type of reward + reward details protected CircleCraftDetails GetCircleCraftingInfo( double rewardAmountRoubles, CultistCircleSettings circleConfig, DirectRewardSettings? directRewardSettings = null ) { var result = new CircleCraftDetails { Time = -1, RewardType = CircleRewardType.RANDOM, RewardAmountRoubles = (int)rewardAmountRoubles, RewardDetails = null, }; // Direct reward edge case if (directRewardSettings is not null) { result.Time = directRewardSettings.CraftTimeSeconds; return result; } var random = new Random(); // Get a threshold where sacrificed amount is between thresholds min and max var matchingThreshold = GetMatchingThreshold(circleConfig.CraftTimeThresholds, rewardAmountRoubles); if ( rewardAmountRoubles >= circleConfig.HideoutCraftSacrificeThresholdRub && random.Next(0, 1) <= circleConfig.BonusChanceMultiplier ) { // Sacrifice amount is enough + passed 25% check to get hideout/task rewards result.Time = circleConfig.CraftTimeOverride != -1 ? circleConfig.CraftTimeOverride : circleConfig.HideoutTaskRewardTimeSeconds; result.RewardType = CircleRewardType.HIDEOUT_TASK; return result; } // Edge case, check if override exists, Otherwise use matching threshold craft time result.Time = circleConfig.CraftTimeOverride != -1 ? circleConfig.CraftTimeOverride : matchingThreshold.CraftTimeSeconds; result.RewardDetails = matchingThreshold; return result; } protected CraftTimeThreshold GetMatchingThreshold(List thresholds, double rewardAmountRoubles) { var matchingThreshold = thresholds.FirstOrDefault(craftThreshold => craftThreshold.Min <= rewardAmountRoubles && craftThreshold.Max >= rewardAmountRoubles ); // No matching threshold, make one if (matchingThreshold is null) { // None found, use a default logger.Warning( localisationService.GetText("cultistcircle-no_matching_threshhold_found", new { rewardAmountRoubles = rewardAmountRoubles }) ); // Use first threshold value (cheapest) from parameter array, otherwise use 12 hours var firstThreshold = thresholds.FirstOrDefault(); var craftTime = firstThreshold?.CraftTimeSeconds > 0 ? firstThreshold.CraftTimeSeconds : timeUtil.GetHoursAsSeconds(12); return new CraftTimeThreshold { Min = firstThreshold?.Min ?? 1, Max = firstThreshold?.Max ?? 34999, CraftTimeSeconds = craftTime, }; } return matchingThreshold; } /// /// Get the items player sacrificed in circle /// /// Player profile /// Array of items from player inventory protected List GetSacrificedItems(PmcData pmcData) { // Get root items that are in the cultist sacrifice window var inventoryRootItemsInCultistGrid = pmcData.Inventory.Items.Where(item => item.SlotId == CircleOfCultistSlotId); // Get rootitem + its children List sacrificedItems = []; foreach (var rootItem in inventoryRootItemsInCultistGrid) { var rootItemWithChildren = pmcData.Inventory.Items.GetItemWithChildren(rootItem.Id); sacrificedItems.AddRange(rootItemWithChildren); } return sacrificedItems; } /// /// Given a pool of items + rouble budget, pick items until the budget is reached /// /// Items that can be picked /// Rouble budget to reach /// Id of stash item /// /// Array of item arrays protected List> GetRewardsWithinBudget( List rewardItemTplPool, double rewardBudget, MongoId cultistCircleStashId, CultistCircleSettings circleConfig ) { // Prep rewards array (reward can be item with children, hence array of arrays) List> rewards = []; // Pick random rewards until we have exhausted the sacrificed items budget var totalRewardCost = 0; var rewardItemCount = 0; var failedAttempts = 0; while (totalRewardCost < rewardBudget && rewardItemTplPool.Count > 0 && rewardItemCount < circleConfig.MaxRewardItemCount) { if (failedAttempts > circleConfig.MaxAttemptsToPickRewardsWithinBudget) { logger.Warning($"Exiting reward generation after {failedAttempts} failed attempts"); break; } // Choose a random tpl from pool var randomItemTplFromPool = randomUtil.GetArrayValue(rewardItemTplPool); // Is weapon/armor, handle differently if ( itemHelper.ArmorItemHasRemovableOrSoftInsertSlots(randomItemTplFromPool) || itemHelper.IsOfBaseclass(randomItemTplFromPool, BaseClasses.WEAPON) ) { var defaultPreset = presetHelper.GetDefaultPreset(randomItemTplFromPool); if (defaultPreset is null) { logger.Warning($"Reward tpl: {randomItemTplFromPool} lacks a default preset, skipping reward"); failedAttempts++; continue; } // Ensure preset has unique ids and is cloned so we don't alter the preset data stored in memory var presetAndMods = defaultPreset.Items.ReplaceIDs().ToList(); presetAndMods.RemapRootItemId(); // Set item as FiR itemHelper.SetFoundInRaid(presetAndMods); rewardItemCount++; totalRewardCost += (int)itemHelper.GetItemPrice(randomItemTplFromPool); rewards.Add(presetAndMods); continue; } // Some items can have variable stack size, e.g. ammo / currency var stackSize = GetRewardStackSize( randomItemTplFromPool, (int)(rewardBudget / (rewardItemCount == 0 ? 1 : rewardItemCount)) // Remaining rouble budget ); // Not a weapon/armor, standard single item List rewardItem = [ new() { Id = new MongoId(), Template = randomItemTplFromPool, ParentId = cultistCircleStashId, SlotId = CircleOfCultistSlotId, Upd = new Upd { StackObjectsCount = stackSize }, }, ]; itemHelper.SetFoundInRaid(rewardItem); // Edge case - item is ammo container and needs cartridges added if (itemHelper.IsOfBaseclass(randomItemTplFromPool, BaseClasses.AMMO_BOX)) { var itemDetails = itemHelper.GetItem(randomItemTplFromPool).Value; itemHelper.AddCartridgesToAmmoBox(rewardItem, itemDetails); } // Increment price of rewards to give to player + add to reward array rewardItemCount++; var singleItemPrice = itemHelper.GetItemPrice(randomItemTplFromPool); var itemPrice = singleItemPrice * stackSize; totalRewardCost += (int)itemPrice; rewards.Add(rewardItem); } return rewards; } /// /// Get direct rewards /// /// sessionId /// Items sacrificed /// Id of stash item /// The reward object protected List> GetDirectRewards(MongoId sessionId, DirectRewardSettings directReward, MongoId cultistCircleStashId) { // Prep rewards array (reward can be item with children, hence array of arrays) List> rewards = []; // Handle special case of tagilla helmets - only one reward is allowed if (directReward.Reward.Contains(ItemTpl.FACECOVER_TAGILLAS_WELDING_MASK_GORILLA)) { // TODO: this is likely redundant with direct reward system in config? directReward.Reward = [randomUtil.GetArrayValue(directReward.Reward)]; } // Loop because these can include multiple rewards foreach (var rewardTpl in directReward.Reward) { // Is weapon/armor, handle differently if (itemHelper.ArmorItemHasRemovableOrSoftInsertSlots(rewardTpl) || itemHelper.IsOfBaseclass(rewardTpl, BaseClasses.WEAPON)) { var defaultPreset = presetHelper.GetDefaultPreset(rewardTpl); if (defaultPreset is null) { logger.Warning($"Reward tpl: {rewardTpl} lacks a default preset, skipping reward"); continue; } // Ensure preset has unique ids and is cloned so we don't alter the preset data stored in memory var presetAndMods = defaultPreset.Items.ReplaceIDs().ToList(); presetAndMods.RemapRootItemId(); // Set item as FiR itemHelper.SetFoundInRaid(presetAndMods); rewards.Add(presetAndMods); continue; } // 'Normal' item, non-preset var stackSize = GetDirectRewardBaseTypeStackSize(rewardTpl); List rewardItem = [ new() { Id = new MongoId(), Template = rewardTpl, ParentId = cultistCircleStashId, SlotId = CircleOfCultistSlotId, Upd = new Upd { StackObjectsCount = stackSize }, }, ]; itemHelper.SetFoundInRaid(rewardItem); // Edge case - item is ammo container and needs cartridges added if (itemHelper.IsOfBaseclass(rewardTpl, BaseClasses.AMMO_BOX)) { var itemDetails = itemHelper.GetItem(rewardTpl).Value; itemHelper.AddCartridgesToAmmoBox(rewardItem, itemDetails); } rewards.Add(rewardItem); } // Direct reward is not repeatable, flag collected in profile if (!directReward.Repeatable) { FlagDirectRewardAsAcceptedInProfile(sessionId, directReward); } return rewards; } /// /// Check for direct rewards from what player sacrificed /// /// sessionId /// Items sacrificed /// /// Direct reward items to send to player protected DirectRewardSettings? CheckForDirectReward( MongoId sessionId, List sacrificedItems, Dictionary directRewardsCache ) { // Get sacrificed tpls var sacrificedItemTpls = sacrificedItems.Select(item => item.Template).Where(item => item != null); // Create md5 key of the items player sacrificed so we can compare against the direct reward cache var sacrificedItemsKey = CreateSacrificeCacheKey(sacrificedItemTpls); var matchingDirectReward = directRewardsCache.GetValueOrDefault(sacrificedItemsKey); if (matchingDirectReward is null) // No direct reward { return null; } var fullProfile = profileHelper.GetFullProfile(sessionId); var directRewardHash = GetDirectRewardHashKey(matchingDirectReward); if (fullProfile.SptData.CultistRewards?.ContainsKey(directRewardHash) ?? false) // Player has already received this direct reward { return null; } return matchingDirectReward; } /// /// Create an md5 key of the sacrificed + reward items /// /// Direct reward to create key for /// Key protected string GetDirectRewardHashKey(DirectRewardSettings directReward) { directReward.RequiredItems.Sort(); directReward.Reward.Sort(); var required = string.Concat(directReward.RequiredItems, ","); var reward = string.Concat(directReward.Reward, ","); // Key is sacrificed items separated by commas, a dash, then the rewards separated by commas var key = $"{{{required}-{reward}}}"; return hashUtil.GenerateHashForData(HashingAlgorithm.MD5, key); } /// /// Explicit rewards have their own stack sizes as they don't use a reward rouble pool /// /// Item being rewarded to get stack size of /// stack size of item protected int GetDirectRewardBaseTypeStackSize(MongoId rewardTpl) { var itemDetails = itemHelper.GetItem(rewardTpl); if (!itemDetails.Key) { logger.Warning($"{rewardTpl} is not an item, setting stack size to 1"); return 1; } // Look for parent in dict var settings = _hideoutConfig.CultistCircle.DirectRewardStackSize.GetValueOrDefault(itemDetails.Value.Parent); if (settings is null) { return 1; } return randomUtil.GetInt(settings.Min, settings.Max); } /// /// Add a record to the player's profile to signal they have accepted a non-repeatable direct reward /// /// Session id /// Reward sent to player protected void FlagDirectRewardAsAcceptedInProfile(MongoId sessionId, DirectRewardSettings directReward) { var fullProfile = profileHelper.GetFullProfile(sessionId); var dataToStoreInProfile = new AcceptedCultistReward { Timestamp = timeUtil.GetTimeStamp(), SacrificeItems = directReward.RequiredItems, RewardItems = directReward.Reward, }; fullProfile.SptData.CultistRewards[GetDirectRewardHashKey(directReward)] = dataToStoreInProfile; } /// /// Get the size of a reward item's stack /// 1 for everything except ammo, ammo can be between min stack and max stack /// /// Item chosen /// Rouble amount of pool remaining to fill /// Size of stack protected int GetRewardStackSize(MongoId itemTpl, int rewardPoolRemaining) { if (itemHelper.IsOfBaseclass(itemTpl, BaseClasses.AMMO)) { var ammoTemplate = itemHelper.GetItem(itemTpl).Value; return itemHelper.GetRandomisedAmmoStackSize(ammoTemplate); } if (itemHelper.IsOfBaseclass(itemTpl, BaseClasses.MONEY)) { // Get currency-specific values from config var settings = _hideoutConfig.CultistCircle.CurrencyRewards[itemTpl]; // What % of the pool remaining should be rewarded as chosen currency var percentOfPoolToUse = randomUtil.GetDouble(settings.Min, settings.Max); // Rouble amount of pool we want to reward as currency var roubleAmountToFill = randomUtil.GetPercentOfValue(percentOfPoolToUse, rewardPoolRemaining); // Convert currency to roubles var currencyPriceAsRouble = itemHelper.GetItemPrice(itemTpl); // How many items can we fit into chosen pool var itemCountToReward = Math.Round(roubleAmountToFill / currencyPriceAsRouble ?? 0); return (int)itemCountToReward; } return 1; } /// /// Get a pool of tpl IDs of items the player needs to complete hideout crafts/upgrade areas /// /// Session id /// Profile of player who will be getting the rewards /// Do we return bonus items (hideout/task items) /// Circle config /// Array of tpls protected List GetCultistCircleRewardPool( MongoId sessionId, PmcData pmcData, CircleCraftDetails craftingInfo, CultistCircleSettings cultistCircleConfig ) { var rewardPool = new HashSet(); var hideoutDbData = databaseService.GetHideout(); var itemsDb = databaseService.GetItems(); // Get all items that match the blacklisted types and fold into item blacklist below var itemTypeBlacklist = itemFilterService.GetItemRewardBaseTypeBlacklist(); var itemsMatchingTypeBlacklist = itemsDb .Where(templateItem => itemHelper.IsOfBaseclasses(templateItem.Key, itemTypeBlacklist)) .Select(templateItem => templateItem.Key); // Create set of unique values to ignore var itemRewardBlacklist = new HashSet(); itemRewardBlacklist.UnionWith(seasonalEventService.GetInactiveSeasonalEventItems()); itemRewardBlacklist.UnionWith(itemFilterService.GetItemRewardBlacklist()); itemRewardBlacklist.UnionWith(itemFilterService.GetBlacklistedItems()); itemRewardBlacklist.UnionWith(cultistCircleConfig.RewardItemBlacklist); itemRewardBlacklist.UnionWith(itemsMatchingTypeBlacklist); // Hideout and task rewards are ONLY if the bonus is active switch (craftingInfo.RewardType) { case CircleRewardType.RANDOM: { // Does reward pass the high value threshold var isHighValueReward = craftingInfo.RewardAmountRoubles >= cultistCircleConfig.HighValueThresholdRub; GenerateRandomisedItemsAndAddToRewardPool(rewardPool, itemRewardBlacklist, isHighValueReward); break; } case CircleRewardType.HIDEOUT_TASK: { // Hideout/Task loot AddHideoutUpgradeRequirementsToRewardPool(hideoutDbData, pmcData, itemRewardBlacklist, rewardPool); AddTaskItemRequirementsToRewardPool(pmcData, itemRewardBlacklist, rewardPool); // If we have no tasks or hideout stuff left or need more loot to fill it out, default to high value if (rewardPool.Count < cultistCircleConfig.MaxRewardItemCount + 2) { GenerateRandomisedItemsAndAddToRewardPool(rewardPool, itemRewardBlacklist, true); } break; } } // Add custom rewards from config if (cultistCircleConfig.AdditionalRewardItemPool.Count > 0) { foreach (var additionalReward in cultistCircleConfig.AdditionalRewardItemPool) { if (itemRewardBlacklist.Contains(additionalReward)) { continue; } // Add tpl to reward pool rewardPool.Add(additionalReward); } } return rewardPool.ToList(); } /// /// Check player's profile for quests with hand-in requirements and add those required items to the pool /// /// Player profile /// Items not to add to pool /// Pool to add items to protected void AddTaskItemRequirementsToRewardPool(PmcData pmcData, HashSet itemRewardBlacklist, HashSet rewardPool) { var activeTasks = pmcData.Quests.Where(quest => quest.Status == QuestStatusEnum.Started); foreach (var task in activeTasks) { var questData = questHelper.GetQuestFromDb(task.QId, pmcData); var handoverConditions = questData.Conditions.AvailableForFinish.Where(condition => condition.ConditionType == "HandoverItem"); foreach (var condition in handoverConditions) foreach (var neededItem in condition.Target.List) { if (itemRewardBlacklist.Contains(neededItem) || !itemHelper.IsValidItem(neededItem)) { continue; } if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug($"Added Task Loot: {itemHelper.GetItemName(neededItem)}"); } rewardPool.Add(neededItem); } } } /// /// Adds items the player needs to complete hideout crafts/upgrades to the reward pool /// /// Hideout area data /// Player profile /// Items not to add to pool /// Pool to add items to protected void AddHideoutUpgradeRequirementsToRewardPool( Hideout hideoutDbData, PmcData pmcData, HashSet itemRewardBlacklist, HashSet rewardPool ) { var dbAreas = hideoutDbData.Areas; foreach (var profileArea in GetPlayerAccessibleHideoutAreas(pmcData.Hideout.Areas)) { var currentStageLevel = profileArea.Level; var areaType = profileArea.Type; // Get next stage of area var dbArea = dbAreas?.FirstOrDefault(area => area.Type == areaType); var nextTargetStageLevel = (currentStageLevel + 1).ToString() ?? ""; if (dbArea?.Stages?.TryGetValue(nextTargetStageLevel, out var nextStageDbData) ?? false) { // Next stage exists, gather requirements and add to pool var itemRequirements = GetItemRequirements(nextStageDbData.Requirements); foreach (var rewardToAdd in itemRequirements) { if (itemRewardBlacklist.Contains(rewardToAdd.TemplateId) || !itemHelper.IsValidItem(rewardToAdd.TemplateId)) // Dont reward items sacrificed { continue; } if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug($"Added Hideout Loot: {itemHelper.GetItemName(rewardToAdd.TemplateId)}"); } rewardPool.Add(rewardToAdd.TemplateId); } } } } /// /// Get all active hideout areas /// /// Hideout areas to iterate over /// Active area array protected IEnumerable GetPlayerAccessibleHideoutAreas(IEnumerable areas) { return areas.Where(area => { if (area.Type == HideoutAreas.ChristmasIllumination && !seasonalEventService.ChristmasEventEnabled()) // Christmas tree area and not Christmas, skip { return false; } return true; }); } /// /// Get array of random reward items /// /// Reward pool to add to /// Item tpls to ignore /// Should these items meet the valuable threshold protected void GenerateRandomisedItemsAndAddToRewardPool( HashSet rewardPool, HashSet itemRewardBlacklist, bool itemsShouldBeHighValue ) { var allItems = databaseService.GetItems(); var currentItemCount = 0; var attempts = 0; // `currentItemCount` var will look for the correct number of items, `attempts` var will keep this from never stopping if the highValueThreshold is too high while (currentItemCount < _hideoutConfig.CultistCircle.MaxRewardItemCount + 2 && attempts < allItems.Count) { attempts++; var randomItem = randomUtil.GetArrayValue(allItems); if (itemRewardBlacklist.Contains(randomItem.Key) || !itemHelper.IsValidItem(randomItem.Key)) { continue; } // Valuable check if (itemsShouldBeHighValue) { var itemValue = itemHelper.GetItemMaxPrice(randomItem.Key); if (itemValue < _hideoutConfig.CultistCircle.HighValueThresholdRub) { continue; } } if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug($"Added: {itemHelper.GetItemName(randomItem.Key)}"); } rewardPool.Add(randomItem.Key); currentItemCount++; } } /// /// Iterate over passed in hideout requirements and return the Item /// /// Requirements to iterate over /// Array of item requirements protected IEnumerable GetItemRequirements(IEnumerable requirements) { return requirements.Where(requirement => requirement.Type == "Item").ToList(); } /// /// Create an MD5 hash of the passed in items /// /// Items to create key for /// Key protected string CreateSacrificeCacheKey(IEnumerable requiredItems) { var concat = string.Join(",", requiredItems.OrderBy(item => item.ToString())); return hashUtil.GenerateHashForData(HashingAlgorithm.MD5, concat); } /// /// Create a map of the possible direct rewards, keyed by the items needed to be sacrificed /// /// Direct rewards array from hideout config /// Dictionary protected Dictionary GenerateSacrificedItemsCache(List directRewards) { var result = new Dictionary(); foreach (var rewardSettings in directRewards) { string key = CreateSacrificeCacheKey(rewardSettings.RequiredItems); result[key] = rewardSettings; } return result; } /// /// Attempt to add all rewards to cultist circle, if they don't fit remove one and try again until they fit /// /// Session id /// Player profile /// Rewards to send to player /// Cultist grid to add rewards to /// Stash id /// Client output protected void AddRewardsToCircleContainer( MongoId sessionId, PmcData pmcData, List> rewards, int[,] containerGrid, MongoId cultistCircleStashId, ItemEventRouterResponse output ) { var canAddToContainer = false; while (!canAddToContainer && rewards.Count > 0) { canAddToContainer = inventoryHelper.CanPlaceItemsInContainer( cloner.Clone(containerGrid), // MUST clone grid before passing in as function modifies grid rewards ); // Doesn't fit, remove one item if (!canAddToContainer) { rewards.PopLast(); } } foreach (var itemToAdd in rewards) { var result = inventoryHelper.PlaceItemInContainer(containerGrid, itemToAdd, cultistCircleStashId, CircleOfCultistSlotId); if (!result.Success.GetValueOrDefault()) { logger.Warning($"Failed to place sacrifice reward: {itemToAdd.FirstOrDefault()?.Template}"); continue; } // Add item + mods to output and profile inventory pmcData.Inventory.Items.AddRange(itemToAdd); } } }