From 329445795d62ad444039bf12ccefb9038a8ec3dd Mon Sep 17 00:00:00 2001 From: acidphantasm Date: Fri, 17 Jul 2026 15:59:07 -0500 Subject: [PATCH 1/2] Add support for "and/or" cultist circle recipes. Consume non-repeatable rewards first, then if there are still repeatables after that only grant those. The cache key moves to only being the sacrificed items instead of sacrifice+reward. --- .../Hideout/CircleOfCultistService.cs | 75 ++++++++----------- 1 file changed, 30 insertions(+), 45 deletions(-) diff --git a/Libraries/SPTarkov.Server.Core/Services/Hideout/CircleOfCultistService.cs b/Libraries/SPTarkov.Server.Core/Services/Hideout/CircleOfCultistService.cs index a8b6aef1c..535ca4a17 100644 --- a/Libraries/SPTarkov.Server.Core/Services/Hideout/CircleOfCultistService.cs +++ b/Libraries/SPTarkov.Server.Core/Services/Hideout/CircleOfCultistService.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Logging; using SPTarkov.Common.Extensions; using SPTarkov.Common.Models.Logging; using SPTarkov.DI.Annotations; @@ -23,7 +24,6 @@ using SPTarkov.Server.Core.Services.Server; using SPTarkov.Server.Core.Utils; using SPTarkov.Server.Core.Utils.Cloners; -using Microsoft.Extensions.Logging; namespace SPTarkov.Server.Core.Services.Hideout; @@ -91,10 +91,11 @@ HideoutCircleOfCultistProductionStartRequestData request // 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; + var hasDirectReward = directRewardSettings.Count > 0; + var selectedDirectReward = hasDirectReward ? randomUtil.GetArrayValue(directRewardSettings) : null; // Get craft time and bonus status - var craftingInfo = GetCircleCraftingInfo(rewardAmountRoubles, hideoutConfig.CultistCircle, directRewardSettings); + var craftingInfo = GetCircleCraftingInfo(rewardAmountRoubles, hideoutConfig.CultistCircle, selectedDirectReward); // Create production in pmc profile RegisterCircleOfCultistProduction(sessionId, pmcData, cultistCraftData.Id, sacrificedItems, craftingInfo.Time); @@ -109,7 +110,7 @@ HideoutCircleOfCultistProductionStartRequestData request } var rewards = hasDirectReward - ? GetDirectRewards(sessionId, directRewardSettings, cultistCircleStashId.Value) + ? GetDirectRewards(sessionId, selectedDirectReward, cultistCircleStashId.Value) : GetRewardsWithinBudget( GetCultistCircleRewardPool(sessionId, pmcData, craftingInfo, hideoutConfig.CultistCircle), rewardAmountRoubles, @@ -399,13 +400,6 @@ protected List> GetDirectRewards(MongoId sessionId, DirectRewardSetti // 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) { @@ -474,10 +468,10 @@ protected List> GetDirectRewards(MongoId sessionId, DirectRewardSetti /// Items sacrificed /// /// Direct reward items to send to player - protected DirectRewardSettings? CheckForDirectReward( + protected List CheckForDirectReward( MongoId sessionId, List sacrificedItems, - Dictionary directRewardsCache + Dictionary> directRewardsCache ) { // Get sacrificed tpls @@ -485,40 +479,22 @@ Dictionary directRewardsCache // 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 empty list if no match + if (!directRewardsCache.TryGetValue(sacrificedItemsKey, out var matchingDirectRewards)) { - return null; + return []; } + // check if player already completed a direct reward with these sacrificed items var fullProfile = profileHelper.GetFullProfile(sessionId); - var directRewardHash = GetDirectRewardHashKey(matchingDirectReward); - if (fullProfile.SptData.CultistRewards?.ContainsKey(directRewardHash) ?? false) - // Player has already received this direct reward + var hasCompletedNonRepeatable = fullProfile.SptData.CultistRewards?.ContainsKey(sacrificedItemsKey) ?? false; + if (!hasCompletedNonRepeatable) { - return null; + var nonRepeatableRewards = matchingDirectRewards.Where(reward => !reward.Repeatable).ToList(); + return nonRepeatableRewards.Count > 0 ? nonRepeatableRewards : matchingDirectRewards; } - 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.Join(",", directReward.RequiredItems); - var reward = string.Join(",", 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); + return matchingDirectRewards.Where(reward => reward.Repeatable).ToList(); } /// @@ -554,6 +530,7 @@ protected int GetDirectRewardBaseTypeStackSize(MongoId rewardTpl) protected void FlagDirectRewardAsAcceptedInProfile(MongoId sessionId, DirectRewardSettings directReward) { var fullProfile = profileHelper.GetFullProfile(sessionId); + var sacrificeKey = CreateSacrificeCacheKey(directReward.RequiredItems); var dataToStoreInProfile = new AcceptedCultistReward { Timestamp = timeUtil.GetTimeStamp(), @@ -561,7 +538,7 @@ protected void FlagDirectRewardAsAcceptedInProfile(MongoId sessionId, DirectRewa RewardItems = directReward.Reward, }; - fullProfile.SptData.CultistRewards[GetDirectRewardHashKey(directReward)] = dataToStoreInProfile; + fullProfile.SptData.CultistRewards[sacrificeKey] = dataToStoreInProfile; } /// @@ -853,13 +830,21 @@ protected string CreateSacrificeCacheKey(IEnumerable requiredItems) /// /// Direct rewards array from hideout config /// Dictionary - protected Dictionary GenerateSacrificedItemsCache(List directRewards) + protected Dictionary> GenerateSacrificedItemsCache(List directRewards) { - var result = new Dictionary(); + var result = new Dictionary>(); + foreach (var rewardSettings in directRewards) { - string key = CreateSacrificeCacheKey(rewardSettings.RequiredItems); - result[key] = rewardSettings; + var key = CreateSacrificeCacheKey(rewardSettings.RequiredItems); + + if (!result.TryGetValue(key, out var rewards)) + { + rewards = []; + result[key] = rewards; + } + + rewards.Add(rewardSettings); } return result; From cd6bf9335741270fbbb62462feea56e64ff4f9ed Mon Sep 17 00:00:00 2001 From: acidphantasm Date: Fri, 17 Jul 2026 15:59:32 -0500 Subject: [PATCH 2/2] Update hideout.json Cultist Circle and/or recipes to proper consumable format --- .../SPT_Data/configs/hideout.json | 55 ++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/Libraries/SPTarkov.Server.Assets/SPT_Data/configs/hideout.json b/Libraries/SPTarkov.Server.Assets/SPT_Data/configs/hideout.json index 9b42fcda0..3d9e11b16 100644 --- a/Libraries/SPTarkov.Server.Assets/SPT_Data/configs/hideout.json +++ b/Libraries/SPTarkov.Server.Assets/SPT_Data/configs/hideout.json @@ -101,6 +101,28 @@ "craftTimeSeconds": 3960, "repeatable": false }, + { + "reward": [ + "5bc9b9ecd4351e3bac122519", + "5bc9b9ecd4351e3bac122519" + ], + "requiredItems": [ + "66572b8d80b1cd4b6a67847f" + ], + "craftTimeSeconds": 3960, + "repeatable": false + }, + { + "reward": [ + "62a09dd4621468534a797ac7", + "62a09dd4621468534a797ac7" + ], + "requiredItems": [ + "66572b8d80b1cd4b6a67847f" + ], + "craftTimeSeconds": 3960, + "repeatable": false + }, { "reward": [ "5c0e874186f7745dc7616606" @@ -157,7 +179,38 @@ }, { "reward": [ - "60a7ad3a0c5cb24b0134664a", + "56e335e4d2720b6c058b456d", + "56e335e4d2720b6c058b456d" + ], + "requiredItems": [ + "655c673673a43e23e857aebd" + ], + "craftTimeSeconds": 3960, + "repeatable": false + }, + { + "reward": [ + "572b7adb24597762ae139821", + "572b7adb24597762ae139821" + ], + "requiredItems": [ + "655c673673a43e23e857aebd" + ], + "craftTimeSeconds": 3960, + "repeatable": false + }, + { + "reward": [ + "60a7ad3a0c5cb24b0134664a" + ], + "requiredItems": [ + "66572cbdad599021091c611a" + ], + "craftTimeSeconds": 3960, + "repeatable": false + }, + { + "reward": [ "60a7ad2a2198820d95707a2e" ], "requiredItems": [