diff --git a/Libraries/Core/Models/Eft/Hideout/HideoutArea.cs b/Libraries/Core/Models/Eft/Hideout/HideoutArea.cs index e90ac42d..4a1082bd 100644 --- a/Libraries/Core/Models/Eft/Hideout/HideoutArea.cs +++ b/Libraries/Core/Models/Eft/Hideout/HideoutArea.cs @@ -147,7 +147,7 @@ public record StageImprovementRequirement public string? Type { get; set; } } -public record StageRequirement : RequirementBase +public record StageRequirement { [JsonPropertyName("areaType")] public int? AreaType { get; set; } @@ -181,4 +181,7 @@ public record StageRequirement : RequirementBase [JsonPropertyName("skillLevel")] public int? SkillLevel { get; set; } + + [JsonPropertyName("type")] + public string? Type { get; set; } } diff --git a/Libraries/Core/Models/Eft/Hideout/HideoutProduction.cs b/Libraries/Core/Models/Eft/Hideout/HideoutProduction.cs index 2a8c4478..32bc2ad6 100644 --- a/Libraries/Core/Models/Eft/Hideout/HideoutProduction.cs +++ b/Libraries/Core/Models/Eft/Hideout/HideoutProduction.cs @@ -56,7 +56,7 @@ public record HideoutProduction public bool? IsCodeProduction { get; set; } } -public record Requirement : RequirementBase +public record Requirement { [JsonPropertyName("templateId")] public string? TemplateId { get; set; } @@ -87,10 +87,7 @@ public record Requirement : RequirementBase [JsonPropertyName("gameVersions")] public List? GameVersions { get; set; } -} - -public record RequirementBase -{ + [JsonPropertyName("type")] public string? Type { get; set; } } diff --git a/Libraries/Core/Services/CircleOfCultistService.cs b/Libraries/Core/Services/CircleOfCultistService.cs index 17d08f34..39bb6830 100644 --- a/Libraries/Core/Services/CircleOfCultistService.cs +++ b/Libraries/Core/Services/CircleOfCultistService.cs @@ -1,17 +1,46 @@ -using SptCommon.Annotations; +using Core.Helpers; +using SptCommon.Annotations; using Core.Models.Eft.Common; using Core.Models.Eft.Common.Tables; using Core.Models.Eft.Hideout; using Core.Models.Eft.ItemEvent; +using Core.Models.Eft.Profile; +using Core.Models.Enums; +using Core.Models.Enums.Hideout; using Core.Models.Spt.Config; using Core.Models.Spt.Hideout; -using Hideout = Core.Models.Eft.Common.Tables.Hideout; +using Core.Models.Utils; +using Core.Routers; +using Core.Servers; +using Core.Utils; +using Core.Utils.Cloners; +using SptCommon.Extensions; namespace Core.Services; [Injectable(InjectionType.Singleton)] -public class CircleOfCultistService +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, + ConfigServer _configServer +) { + protected HideoutConfig _hideoutConfig = _configServer.GetConfig(); + public const string CircleOfCultistSlotId = "CircleOfCultistsGrid1"; + /// /// Start a sacrifice event /// Generate rewards @@ -27,49 +56,89 @@ public class CircleOfCultistService HideoutCircleOfCultistProductionStartRequestData request ) { - throw new NotImplementedException(); + var cultistCircleStashId = pmcData.Inventory.HideoutAreaStashes.GetValueOrDefault(HideoutAreas.CIRCLE_OF_CULTISTS.ToString()); + + // `cultistRecipes` just has single recipeId + var cultistCraftData = _databaseService.GetHideout().Production.CultistRecipes.FirstOrDefault(); + List 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 sacrified items * above multipler + 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 + ); + + var output = _eventOutputHolder.GetOutput(sessionId); + + // Remove sacrificed items from circle inventory + foreach (var item in sacrificedItems) + { + if (item.SlotId == CircleOfCultistService.CircleOfCultistSlotId) + { + _inventoryHelper.RemoveItem(pmcData, item.Id, sessionId, output); + } + } + + var rewards = hasDirectReward + ? GetDirectRewards(sessionId, directRewardSettings, cultistCircleStashId) + : GetRewardsWithinBudget( + GetCultistCircleRewardPool(sessionId, pmcData, craftingInfo, _hideoutConfig.CultistCircle), + rewardAmountRoubles, + cultistCircleStashId, + _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, output); + + return output; } - /// - /// 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( - string sessionId, - PmcData pmcData, - List> rewards, - List> containerGrid, - string cultistCircleStashId, - ItemEventRouterResponse output - ) + private double GetRewardAmountMultiplier(PmcData pmcData, CultistCircleSettings cultistCircleSettings) { - throw new NotImplementedException(); - } + // Get a randomised value to multiply the sacrificed rouble cost by + var rewardAmountMultiplier = _randomUtil.GetFloat( + (float)cultistCircleSettings.RewardPriceMultiplerMinMax.Min, + (float)cultistCircleSettings.RewardPriceMultiplerMinMax.Max + ); - /// - /// 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) - { - throw new NotImplementedException(); - } + // Adjust value generated by the players hideout management skill + var hideoutManagementSkill = _profileHelper.GetSkillFromProfile(pmcData, 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) + } - /// - /// Get the reward amount multiple value based on players hideout management skill + configs rewardPriceMultiplerMinMax values - /// - /// Player profile - /// Circle config settings - /// Reward Amount Multiplier - protected double GetRewardAmountMultiplier(PmcData pmcData, CultistCircleSettings cultistCircleSettings) - { - throw new NotImplementedException(); + return rewardAmountMultiplier; } /// @@ -88,7 +157,17 @@ public class CircleOfCultistService double craftingTime ) { - throw new NotImplementedException(); + // 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; } /// @@ -105,7 +184,48 @@ public class CircleOfCultistService DirectRewardSettings directRewardSettings = null ) { - throw new NotImplementedException(); + 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.CraftTimeThreshholds, 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( @@ -113,7 +233,26 @@ public class CircleOfCultistService double rewardAmountRoubles ) { - throw new NotImplementedException(); + var matchingThreshold = thresholds.FirstOrDefault( + (craftThreshold) => craftThreshold.Min <= rewardAmountRoubles && craftThreshold.Max >= rewardAmountRoubles + ); + + // No matching threshold, make one + if (matchingThreshold is null) + { + // None found, use a defalt + _logger.Warning("Unable to find a matching cultist circle threshold, using fallback of 12 hours"); + + // Use first threshold value (cheapest) from parameter array, otherwise use 12 hours + var firstThreshold = thresholds.FirstOrDefault(); + var craftTime = firstThreshold?.CraftTimeSeconds is not null && firstThreshold.CraftTimeSeconds > 0 + ? firstThreshold.CraftTimeSeconds + : _timeUtil.GetHoursAsSeconds(12); + + return new CraftTimeThreshold { Min = firstThreshold?.Min ?? 1, Max = firstThreshold?.Max ?? 34999, CraftTimeSeconds = craftTime }; + } + + return matchingThreshold; } /// @@ -123,7 +262,23 @@ public class CircleOfCultistService /// Array of items from player inventory protected List GetSacrificedItems(PmcData pmcData) { - throw new NotImplementedException(); + // Get root items that are in the cultist sacrifice window + var inventoryRootItemsInCultistGrid = pmcData.Inventory.Items.Where( + (item) => item.SlotId == CircleOfCultistService.CircleOfCultistSlotId + ); + + // Get rootitem + its children + List sacrificedItems = []; + foreach (var rootItem in inventoryRootItemsInCultistGrid) + { + var rootItemWithChildren = _itemHelper.FindAndReturnChildrenAsItems( + pmcData.Inventory.Items, + rootItem.Id + ); + sacrificedItems.AddRange(rootItemWithChildren); + } + + return sacrificedItems; } /// @@ -140,7 +295,95 @@ public class CircleOfCultistService CultistCircleSettings circleConfig ) { - throw new NotImplementedException(); + // 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 = _itemHelper.ReplaceIDs(defaultPreset.Items); + _itemHelper.RemapRootItemId(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 Item + { + Id = _hashUtil.Generate(), + Template = randomItemTplFromPool, + ParentId = cultistCircleStashId, + SlotId = CircleOfCultistService.CircleOfCultistSlotId, + Upd = new Upd + { + StackObjectsCount = stackSize, + SpawnedInSession = true, + }, + }, + ]; + + // 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; } /// @@ -156,7 +399,76 @@ public class CircleOfCultistService string cultistCircleStashId ) { - throw new NotImplementedException(); + // 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)) + { + 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 = _itemHelper.ReplaceIDs(defaultPreset.Items); + _itemHelper.RemapRootItemId(presetAndMods); + + rewards.Add(presetAndMods); + + continue; + } + + // 'Normal' item, non-preset + var stackSize = GetDirectRewardBaseTypeStackSize(rewardTpl); + List rewardItem = + [ + new Item + { + Id = _hashUtil.Generate(), + Template = rewardTpl, + ParentId = cultistCircleStashId, + SlotId = CircleOfCultistService.CircleOfCultistSlotId, + Upd = new Upd + { + StackObjectsCount = stackSize, + SpawnedInSession = true, + }, + }, + ]; + + // 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; } /// @@ -171,7 +483,28 @@ public class CircleOfCultistService Dictionary directRewardsCache ) { - throw new NotImplementedException(); + // Get sacrificed tpls + var sacrificedItemTpls = sacrificedItems.Select((item) => item.Template).ToList(); + sacrificedItemTpls.Sort(); + // Create md5 key of the items player sacrificed so we can compare against the direct reward cache + var sacrificedItemsKey = _hashUtil.GenerateMd5ForData(string.Concat(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; } /// @@ -181,7 +514,15 @@ public class CircleOfCultistService /// Key protected string GetDirectRewardHashKey(DirectRewardSettings directReward) { - throw new NotImplementedException(); + 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.GenerateMd5ForData(key); } /// @@ -191,7 +532,20 @@ public class CircleOfCultistService /// stack size of item protected int GetDirectRewardBaseTypeStackSize(string rewardTpl) { - throw new NotImplementedException(); + 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[itemDetails.Value.Parent]; + if (settings is null) { + return 1; + } + + return _randomUtil.GetInt((int)settings.Min, (int)settings.Max); } /// @@ -201,7 +555,14 @@ public class CircleOfCultistService /// Reward sent to player protected void FlagDirectRewardAsAcceptedInProfile(string sessionId, DirectRewardSettings directReward) { - throw new NotImplementedException(); + var fullProfile = _profileHelper.GetFullProfile(sessionId); + AcceptedCultistReward dataToStoreInProfile = new AcceptedCultistReward { + Timestamp = _timeUtil.GetTimeStamp(), + SacrificeItems = directReward.RequiredItems, + RewardItems = directReward.Reward, + }; + + fullProfile.SptData.CultistRewards[GetDirectRewardHashKey(directReward)] = dataToStoreInProfile; } /// @@ -213,7 +574,31 @@ public class CircleOfCultistService /// Size of stack protected int GetRewardStackSize(string itemTpl, int rewardPoolRemaining) { - throw new NotImplementedException(); + 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.GetInt((int)settings.Min, (int)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; } /// @@ -224,13 +609,64 @@ public class CircleOfCultistService /// Do we return bonus items (hideout/task items) /// Circle config /// Array of tpls - protected string[] GetCultistCircleRewardPool( + protected List GetCultistCircleRewardPool( string sessionId, PmcData pmcData, CircleCraftDetails craftingInfo, CultistCircleSettings cultistCircleConfig) { - throw new NotImplementedException(); + 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(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(); } /// @@ -255,12 +691,35 @@ public class CircleOfCultistService /// Items not to add to pool /// Pool to add items to protected void AddHideoutUpgradeRequirementsToRewardPool( - Hideout hideoutDbData, + Core.Models.Spt.Hideout.Hideout hideoutDbData, PmcData pmcData, HashSet itemRewardBlacklist, HashSet rewardPool) { - throw new NotImplementedException(); + 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 nextStageDbData = dbArea?.Stages[(currentStageLevel + 1).ToString()]; + if (nextStageDbData is not null) { + // Next stage exists, gather up 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; + } + _logger.Debug($"Added Hideout Loot: {_itemHelper.GetItemName(rewardToAdd.TemplateId)}"); + rewardPool.Add(rewardToAdd.TemplateId); + } + } + } } /// @@ -268,9 +727,16 @@ public class CircleOfCultistService /// /// Hideout areas to iterate over /// Active area array - protected BotHideoutArea[] GetPlayerAccessibleHideoutAreas(BotHideoutArea[] areas) + protected List GetPlayerAccessibleHideoutAreas(List areas) { - throw new NotImplementedException(); + return areas.Where((area) => { + if (area.Type == HideoutAreas.CHRISTMAS_TREE && !_seasonalEventService.ChristmasEventEnabled()) { + // Christmas tree area and not Christmas, skip + return false; + } + + return true; + }).ToList(); } /// @@ -285,7 +751,33 @@ public class CircleOfCultistService HashSet itemRewardBlacklist, bool itemsShouldBeHighValue) { - throw new NotImplementedException(); + var allItems = _itemHelper.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.Id) || !_itemHelper.IsValidItem(randomItem.Id)) { + continue; + } + + // Valuable check + if (itemsShouldBeHighValue) { + var itemValue = _itemHelper.GetItemMaxPrice(randomItem.Id); + if (itemValue < _hideoutConfig.CultistCircle.HighValueThresholdRub) { + continue; + } + } + _logger.Debug($"Added: {_itemHelper.GetItemName(randomItem.Id)}"); + rewardPool.Add(randomItem.Id); + currentItemCount++; + } + + return rewardPool; } /// @@ -293,8 +785,81 @@ public class CircleOfCultistService /// /// Requirements to iterate over /// Array of item requirements - protected (StageRequirement[] StageRequirement, Requirement[] Requirement) GetItemRequirements(RequirementBase[] requirements) + protected List GetItemRequirements(List requirements) { - throw new NotImplementedException(); + return requirements.Where((requirement) => requirement.Type == "Item").ToList(); + } + + /// + /// Iterate over passed in hideout requirements and return the Item + /// + /// Requirements to iterate over + /// Array of item requirements + protected List GetItemRequirements(List requirements) + { + return requirements.Where((requirement) => requirement.Type == "Item").ToList(); + } + + /// + /// 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) { + rewardSettings.RequiredItems.Sort(); + var concat = string.Concat(rewardSettings.RequiredItems, ","); + + var key = _hashUtil.GenerateMd5ForData(concat); + 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( + string sessionId, + PmcData pmcData, + List> rewards, + int[][] containerGrid, + string 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.PopFirst(); + } + } + + foreach (var itemToAdd in rewards) { + _inventoryHelper.PlaceItemInContainer( + containerGrid, + itemToAdd, + cultistCircleStashId, + CircleOfCultistService.CircleOfCultistSlotId + ); + // Add item + mods to output and profile inventory + output.ProfileChanges[sessionId].Items.NewItems.AddRange(itemToAdd); + pmcData.Inventory.Items.AddRange(itemToAdd); + } } }