From 811791f7d3b05253cbb0c5de43e23fe785fcf775 Mon Sep 17 00:00:00 2001 From: Cj <161484149+CJ-SPT@users.noreply.github.com> Date: Sun, 22 Jun 2025 15:51:18 -0400 Subject: [PATCH] Repeatable quest generation (Part 1) (#417) * Refactor and breakout CompletionQuestGenerator.cs * make `GenerateAvailableForFinish` protected --- .../CompletionQuestGenerator.cs | 421 +++++++++++++++++ .../Generators/RepeatableQuestGenerator.cs | 434 ++---------------- .../Helpers/RepeatableQuestHelper.cs | 156 ++++++- .../Models/Enums/RepeatableQuestType.cs | 12 + 4 files changed, 613 insertions(+), 410 deletions(-) create mode 100644 Libraries/SPTarkov.Server.Core/Generators/RepeatableQuestGeneration/CompletionQuestGenerator.cs create mode 100644 Libraries/SPTarkov.Server.Core/Models/Enums/RepeatableQuestType.cs diff --git a/Libraries/SPTarkov.Server.Core/Generators/RepeatableQuestGeneration/CompletionQuestGenerator.cs b/Libraries/SPTarkov.Server.Core/Generators/RepeatableQuestGeneration/CompletionQuestGenerator.cs new file mode 100644 index 00000000..c3b76f91 --- /dev/null +++ b/Libraries/SPTarkov.Server.Core/Generators/RepeatableQuestGeneration/CompletionQuestGenerator.cs @@ -0,0 +1,421 @@ +using SPTarkov.DI.Annotations; +using SPTarkov.Server.Core.Helpers; +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.Utils; +using SPTarkov.Server.Core.Servers; +using SPTarkov.Server.Core.Services; +using SPTarkov.Server.Core.Utils; +using SPTarkov.Server.Core.Utils.Json; + +namespace SPTarkov.Server.Core.Generators.RepeatableQuestGeneration; + +[Injectable] +public class CompletionQuestGenerator( + ISptLogger logger, + RepeatableQuestHelper repeatableQuestHelper, + RepeatableQuestRewardGenerator repeatableQuestRewardGenerator, + DatabaseService databaseService, + SeasonalEventService seasonalEventService, + LocalisationService localisationService, + RandomUtil randomUtil, + MathUtil mathUtil, + HashUtil hashUtil, + ItemHelper itemHelper + ) +{ + protected const int MaxRandomNumberAttempts = 6; + + /// + /// Generates a valid Completion quest + /// + /// session Id to generate the quest for + /// player's level for requested items and reward generation + /// trader from which the quest will be provided + /// + /// The configuration for the repeatably kind (daily, weekly) as configured in QuestConfig + /// for the requested quest + /// + /// quest type format for "Completion" (see assets/database/templates/repeatableQuests.json) + public RepeatableQuest? Generate( + string sessionId, + int pmcLevel, + string traderId, + RepeatableQuestConfig repeatableConfig + ) + { + var completionConfig = repeatableConfig.QuestConfig.Completion; + var levelsConfig = repeatableConfig.RewardScaling.Levels; + var roublesConfig = repeatableConfig.RewardScaling.Roubles; + + var quest = repeatableQuestHelper.GenerateRepeatableTemplate( + RepeatableQuestType.Completion, + traderId, + repeatableConfig.Side, + sessionId + ); + + if (quest is null) + { + logger.Error("Quest template null when attempting to create completion operational task."); + return null; + } + + // Filter the items.json items to items the player must retrieve to complete quest: shouldn't be a quest item or "non-existent" + var itemsToRetrievePool = GetItemsToRetrievePool( + completionConfig, + repeatableConfig.RewardBlacklist + ); + + // Filter items within our budget + var (hashSet, budget) = GetItemsWithinBudget(pmcLevel, levelsConfig, roublesConfig, itemsToRetrievePool); + itemsToRetrievePool = hashSet; + + // We also have the option to use whitelist and/or blacklist which is defined in repeatableQuests.json as + // [{"minPlayerLevel": 1, "itemIds": ["id1",...]}, {"minPlayerLevel": 15, "itemIds": ["id3",...]}] + if (repeatableConfig.QuestConfig.Completion.UseWhitelist) + { + itemsToRetrievePool = GetWhitelistedItemSelection(itemsToRetrievePool, pmcLevel); + } + + if (repeatableConfig.QuestConfig.Completion.UseBlacklist) + { + itemsToRetrievePool = GetBlacklistedItemSelection(itemsToRetrievePool, pmcLevel); + } + + // Filtering too harsh + if (itemsToRetrievePool.Count == 0) + { + logger.Error( + localisationService.GetText( + "repeatable-completion_quest_whitelist_too_small_or_blacklist_too_restrictive" + ) + ); + + return null; + } + + + var selectedItems = GenerateAvailableForFinish( + quest, completionConfig, repeatableConfig, itemsToRetrievePool.ToList(), budget + ); + + quest.Rewards = repeatableQuestRewardGenerator.GenerateReward( + pmcLevel, + 1, + traderId, + repeatableConfig, + completionConfig, + selectedItems + ); + + return quest; + } + + /// + /// Generate a pool of item tpls the player should reasonably be able to retrieve + /// + /// Completion quest type config + /// Item tpls to not add to pool + /// Set of item tpls + protected HashSet GetItemsToRetrievePool( + Completion completionConfig, + HashSet itemTplBlacklist + ) + { + // Get seasonal items that should not be added to pool as seasonal event is not active + var seasonalItems = seasonalEventService.GetInactiveSeasonalEventItems(); + + // Check for specific base classes which don't make sense as reward item + // also check if the price is greater than 0; there are some items whose price can not be found + return databaseService + .GetItems() + .Values.Where(itemTemplate => + { + // Base "Item" item has no parent, ignore it + if (itemTemplate.Parent == string.Empty) + { + return false; + } + + if (seasonalItems.Contains(itemTemplate.Id)) + { + return false; + } + + // Valid reward items share same logic as items to retrieve + return repeatableQuestRewardGenerator.IsValidRewardItem( + itemTemplate.Id, + itemTplBlacklist, + completionConfig.RequiredItemTypeBlacklist + ); + }) + .Select(item => item.Id) + .ToHashSet(); + } + + /// + /// Filter item pool down to items we can afford on our budget + /// + /// Level of pmc + /// Levels config + /// Roubles config + /// Item pool + /// Filtered items and roubles budget + protected (HashSet, double) GetItemsWithinBudget( + int pmcLevel, + List levelsConfig, + List roublesConfig, + HashSet itemsToRetrievePool) + { + // Be fair, don't value the items be more expensive than the reward + var multiplier = randomUtil.GetDouble(0.5, 1); + var roublesBudget = Math.Floor( + mathUtil.Interp1(pmcLevel, levelsConfig, roublesConfig) * multiplier + ); + + // Make sure there is always a 5000 rouble budget available for selection + roublesBudget = Math.Max(roublesBudget, 5000d); + + return ( + itemsToRetrievePool.Where(itemTpl => itemHelper.GetItemPrice(itemTpl) < roublesBudget).ToHashSet(), + roublesBudget + ); + } + + /// + /// Filter item selection to items in the whitelist + /// + /// Item selection to filter + /// Level of pmc + /// Filtered selection, or original if null or empty + protected HashSet GetWhitelistedItemSelection(HashSet itemSelection, int pmcLevel) + { + var itemWhitelist = databaseService + .GetTemplates() + .RepeatableQuests?.Data?.Completion?.ItemsWhitelist; + + // Whitelist doesn't exist or is empty, return original + if (itemWhitelist is null || itemWhitelist.Count == 0) + { + return itemSelection; + } + + // Filter and concatenate items according to current player level + var itemIdsWhitelisted = itemWhitelist + .Where(p => p.MinPlayerLevel <= pmcLevel) + .SelectMany(x => x.ItemIds) + .ToHashSet(); //.Aggregate((a, p) => a.Concat(p.ItemIds), []); + + var filteredSelection = itemSelection + .Where(x => + { + // Whitelist can contain item tpls and item base type ids + return itemIdsWhitelisted.Any(v => itemHelper.IsOfBaseclass(x, v)) + || itemIdsWhitelisted.Contains(x); + }) + .ToHashSet(); + + // check if items are missing + // var flatList = itemSelection.reduce((a, il) => a.concat(il[0]), []); + // var missing = itemIdsWhitelisted.filter(l => !flatList.includes(l)); + + return filteredSelection; + } + + /// + /// Filter item selection based on the blacklist + /// + /// Item selection to filter + /// Level of pmc + /// Filtered selection, or original if null or empty + protected HashSet GetBlacklistedItemSelection(HashSet itemSelection, int pmcLevel) + { + var itemBlacklist = databaseService + .GetTemplates() + .RepeatableQuests?.Data?.Completion?.ItemsBlacklist; + + // Blacklist doesn't exist or is empty, return original + if (itemBlacklist is null || itemBlacklist.Count == 0) + { + return itemSelection; + } + + // Filter and concatenate the arrays according to current player level + var itemIdsBlacklisted = itemBlacklist + .Where(p => p.MinPlayerLevel <= pmcLevel) + .SelectMany(x => x.ItemIds) + .ToHashSet(); //.Aggregate(List , (a, p) => a.Concat(p.ItemIds) ); + + var filteredSelection = itemSelection + .Where(x => + { + return itemIdsBlacklisted.All(v => !itemHelper.IsOfBaseclass(x, v)) + || !itemIdsBlacklisted.Contains(x); + }) + .ToHashSet(); + + return filteredSelection; + } + + /// + /// Generate the available for finish conditions for this quest + /// + /// Quest to add the conditions to + /// Completion config + /// Repeatable config + /// Filtered item selection + /// Budget in roubles + /// Chosen item template Ids + protected List GenerateAvailableForFinish( + RepeatableQuest quest, + Completion completionConfig, + RepeatableQuestConfig repeatableConfig, + List itemSelection, + double roublesBudget + ) + { + // Store the indexes of items we are asking player to supply + var distinctItemsToRetrieveCount = randomUtil.GetInt(1, completionConfig.UniqueItemCount); + var chosenRequirementItemsTpls = new List(); + var usedItemIndexes = new HashSet(); + + for (var i = 0; i < distinctItemsToRetrieveCount; i++) + { + var chosenItemIndex = randomUtil.RandInt(itemSelection.Count); + var found = false; + + for (var j = 0; j < MaxRandomNumberAttempts; j++) + { + if (usedItemIndexes.Contains(chosenItemIndex)) + { + chosenItemIndex = randomUtil.RandInt(itemSelection.Count); + } + else + { + found = true; + break; + } + } + + if (!found) + { + logger.Error( + localisationService.GetText( + "repeatable-no_reward_item_found_in_price_range", + new { minPrice = 0, roublesBudget } + ) + ); + + return chosenRequirementItemsTpls; + } + + // Store index of item we've already chosen for later checking + usedItemIndexes.Add(chosenItemIndex); + + var tplChosen = itemSelection[chosenItemIndex]; + var itemPrice = itemHelper.GetItemPrice(tplChosen).Value; + var minValue = completionConfig.MinimumRequestedAmount; + var maxValue = completionConfig.MaximumRequestedAmount; + + var value = minValue; + + // Get the value range within budget + var x = (int)Math.Floor(roublesBudget / itemPrice); + maxValue = Math.Min(maxValue, x); + if (maxValue > minValue) + // If it doesn't blow the budget we have for the request, draw a random amount of the selected + // Item type to be requested + { + value = randomUtil.RandInt(minValue, maxValue + 1); + } + + roublesBudget -= value * itemPrice; + + // Push a CompletionCondition with the item and the amount of the item into quest + chosenRequirementItemsTpls.Add(tplChosen); + quest.Conditions.AvailableForFinish.Add( + GenerateCondition( + tplChosen, + value, + repeatableConfig.QuestConfig.Completion + ) + ); + + // Is there budget left for more items + if (roublesBudget > 0) + { + // Reduce item pool to fit budget + itemSelection = itemSelection + .Where(tpl => itemHelper.GetItemPrice(tpl) < roublesBudget) + .ToList(); + + if (itemSelection.Count == 0) + { + // Nothing fits new budget, exit + break; + } + + continue; + } + + break; + } + + return chosenRequirementItemsTpls; + } + + /// + /// A repeatable quest, besides some more or less static components, exists of reward and condition (see + /// assets/database/templates/repeatableQuests.json) + /// This is a helper method for GenerateCompletionQuest to create a completion condition (of which a completion quest + /// theoretically can have many) + /// + /// Id of the item to request + /// Amount of items of this specific type to request + /// Completion config from quest.json + /// object of "Completion"-condition + protected QuestCondition GenerateCondition( + string itemTpl, + double value, + Completion completionConfig + ) + { + var onlyFoundInRaid = completionConfig.RequiredItemsAreFiR; + var minDurability = itemHelper.IsOfBaseclasses( + itemTpl, + [BaseClasses.WEAPON, BaseClasses.ARMOR] + ) + ? randomUtil.GetArrayValue( + [ + completionConfig.RequiredItemMinDurabilityMinMax.Min, + completionConfig.RequiredItemMinDurabilityMinMax.Max, + ] + ) + : 0; + + // Dog tags MUST NOT be FiR for them to work + if (itemHelper.IsDogtag(itemTpl)) + { + onlyFoundInRaid = false; + } + + return new QuestCondition + { + Id = hashUtil.Generate(), + Index = 0, + ParentId = "", + DynamicLocale = true, + VisibilityConditions = [], + Target = new ListOrT([itemTpl], null), + Value = value, + MinDurability = minDurability, + MaxDurability = 100, + DogtagLevel = 0, + OnlyFoundInRaid = onlyFoundInRaid, + IsEncoded = false, + ConditionType = "HandoverItem", + }; + } +} diff --git a/Libraries/SPTarkov.Server.Core/Generators/RepeatableQuestGenerator.cs b/Libraries/SPTarkov.Server.Core/Generators/RepeatableQuestGenerator.cs index be5d6bf3..36ef7532 100644 --- a/Libraries/SPTarkov.Server.Core/Generators/RepeatableQuestGenerator.cs +++ b/Libraries/SPTarkov.Server.Core/Generators/RepeatableQuestGenerator.cs @@ -1,4 +1,5 @@ using SPTarkov.DI.Annotations; +using SPTarkov.Server.Core.Generators.RepeatableQuestGeneration; using SPTarkov.Server.Core.Helpers; using SPTarkov.Server.Core.Models.Eft.Common; using SPTarkov.Server.Core.Models.Eft.Common.Tables; @@ -13,9 +14,11 @@ using SPTarkov.Server.Core.Utils.Cloners; using SPTarkov.Server.Core.Utils.Collections; using SPTarkov.Server.Core.Utils.Json; using BodyParts = SPTarkov.Server.Core.Constants.BodyParts; +using LogLevel = SPTarkov.Server.Core.Models.Spt.Logging.LogLevel; namespace SPTarkov.Server.Core.Generators; +[Obsolete("In the process of being removed, do NOT add any new logic!!")] [Injectable] public class RepeatableQuestGenerator( ISptLogger _logger, @@ -28,8 +31,9 @@ public class RepeatableQuestGenerator( DatabaseService _databaseService, LocalisationService _localisationService, ConfigServer _configServer, - SeasonalEventService _seasonalEventService, - ICloner _cloner + ICloner _cloner, + // This is temporary while this is being refactored, eventually these will all live in the RepeatableQuestController. + CompletionQuestGenerator _completionQuestGenerator ) { /// @@ -72,11 +76,24 @@ public class RepeatableQuestGenerator( var traders = repeatableConfig .TraderWhitelist.Where(x => x.QuestTypes.Contains(questType)) .Select(x => x.TraderId) + // filter out locked traders + .Where(x => pmcTraderInfo[x].Unlocked.GetValueOrDefault(false)) .ToList(); - // filter out locked traders - traders = traders.Where(x => pmcTraderInfo[x].Unlocked.GetValueOrDefault(false)).ToList(); + var traderId = _randomUtil.DrawRandomFromList(traders).FirstOrDefault(); + if (traderId is null) + { + // TODO: Localize me! + _logger.Error("Could not draw traderId from whitelist during repeatable quest generation"); + return null; + } + + if (_logger.IsLogEnabled(LogLevel.Debug)) + { + _logger.Debug($"Generating operation task type: {questType} for {traderId}"); + } + return questType switch { "Elimination" => GenerateEliminationQuest( @@ -86,7 +103,7 @@ public class RepeatableQuestGenerator( questTypePool, repeatableConfig ), - "Completion" => GenerateCompletionQuest( + "Completion" => _completionQuestGenerator.Generate( sessionId, pmcLevel, traderId, @@ -417,8 +434,8 @@ public class RepeatableQuestGenerator( // crazy maximum difficulty will lead to a higher difficulty reward gain factor than 1 var difficulty = _mathUtil.MapToRange(curDifficulty, minDifficulty, maxDifficulty, 0.5, 2); - var quest = GenerateRepeatableTemplate( - "Elimination", + var quest = _repeatableQuestHelper.GenerateRepeatableTemplate( + RepeatableQuestType.Elimination, traderId, repeatableConfig.Side, sessionId @@ -595,301 +612,6 @@ public class RepeatableQuestGenerator( return killConditionProps; } - /// - /// Generates a valid Completion quest - /// - /// player's level for requested items and reward generation - /// trader from which the quest will be provided - /// - /// The configuration for the repeatably kind (daily, weekly) as configured in QuestConfig - /// for the requested quest - /// - /// quest type format for "Completion" (see assets/database/templates/repeatableQuests.json) - protected RepeatableQuest? GenerateCompletionQuest( - string sessionId, - int pmcLevel, - string traderId, - RepeatableQuestConfig repeatableConfig - ) - { - var completionConfig = repeatableConfig.QuestConfig.Completion; - var levelsConfig = repeatableConfig.RewardScaling.Levels; - var roublesConfig = repeatableConfig.RewardScaling.Roubles; - - var quest = GenerateRepeatableTemplate( - "Completion", - traderId, - repeatableConfig.Side, - sessionId - ); - - // Filter the items.json items to items the player must retrieve to complete quest: shouldn't be a quest item or "non-existent" - var itemsToRetrievePool = GetItemsToRetrievePool( - completionConfig, - repeatableConfig.RewardBlacklist - ); - - // Be fair, don't value the items be more expensive than the reward - var multiplier = _randomUtil.GetDouble(0.5, 1); - var roublesBudget = Math.Floor( - _mathUtil.Interp1(pmcLevel, levelsConfig, roublesConfig) * multiplier - ); - roublesBudget = Math.Max(roublesBudget, 5000d); - var itemSelection = itemsToRetrievePool - .Where(itemTpl => _itemHelper.GetItemPrice(itemTpl) < roublesBudget) - .ToList(); - - // We also have the option to use whitelist and/or blacklist which is defined in repeatableQuests.json as - // [{"minPlayerLevel": 1, "itemIds": ["id1",...]}, {"minPlayerLevel": 15, "itemIds": ["id3",...]}] - if (repeatableConfig.QuestConfig.Completion.UseWhitelist) - { - var itemWhitelist = _databaseService - .GetTemplates() - .RepeatableQuests.Data.Completion.ItemsWhitelist; - - // Filter and concatenate items according to current player level - var itemIdsWhitelisted = itemWhitelist - .Where(p => p.MinPlayerLevel <= pmcLevel) - .SelectMany(x => x.ItemIds) - .ToHashSet(); //.Aggregate((a, p) => a.Concat(p.ItemIds), []); - itemSelection = itemSelection - .Where(x => - { - // Whitelist can contain item tpls and item base type ids - return itemIdsWhitelisted.Any(v => _itemHelper.IsOfBaseclass(x, v)) - || itemIdsWhitelisted.Contains(x); - }) - .ToList(); - // check if items are missing - // var flatList = itemSelection.reduce((a, il) => a.concat(il[0]), []); - // var missing = itemIdsWhitelisted.filter(l => !flatList.includes(l)); - } - - if (repeatableConfig.QuestConfig.Completion.UseBlacklist) - { - var itemBlacklist = _databaseService - .GetTemplates() - .RepeatableQuests.Data.Completion.ItemsBlacklist; - - // Filter and concatenate the arrays according to current player level - var itemIdsBlacklisted = itemBlacklist - .Where(p => p.MinPlayerLevel <= pmcLevel) - .SelectMany(x => x.ItemIds) - .ToHashSet(); //.Aggregate(List , (a, p) => a.Concat(p.ItemIds) ); - - itemSelection = itemSelection - .Where(x => - { - return itemIdsBlacklisted.All(v => !_itemHelper.IsOfBaseclass(x, v)) - || !itemIdsBlacklisted.Contains(x); - }) - .ToList(); - } - - // Filtering too harsh - if (!itemSelection.Any()) - { - _logger.Error( - _localisationService.GetText( - "repeatable-completion_quest_whitelist_too_small_or_blacklist_too_restrictive" - ) - ); - - return null; - } - - // Store the indexes of items we are asking player to supply - var distinctItemsToRetrieveCount = _randomUtil.GetInt(1, completionConfig.UniqueItemCount); - var chosenRequirementItemsTpls = new List(); - var usedItemIndexes = new HashSet(); - for (var i = 0; i < distinctItemsToRetrieveCount; i++) - { - var chosenItemIndex = _randomUtil.RandInt(itemSelection.Count); - var found = false; - - for (var j = 0; j < _maxRandomNumberAttempts; j++) - { - if (usedItemIndexes.Contains(chosenItemIndex)) - { - chosenItemIndex = _randomUtil.RandInt(itemSelection.Count); - } - else - { - found = true; - break; - } - } - - if (!found) - { - _logger.Error( - _localisationService.GetText( - "repeatable-no_reward_item_found_in_price_range", - new { minPrice = 0, roublesBudget } - ) - ); - - return null; - } - - // Store index of item we've already chosen for later checking - usedItemIndexes.Add(chosenItemIndex); - - var tplChosen = itemSelection[chosenItemIndex]; - var itemPrice = _itemHelper.GetItemPrice(tplChosen).Value; - var minValue = completionConfig.MinimumRequestedAmount; - var maxValue = completionConfig.MaximumRequestedAmount; - - var value = minValue; - - // Get the value range within budget - var x = (int)Math.Floor(roublesBudget / itemPrice); - maxValue = Math.Min(maxValue, x); - if (maxValue > minValue) - // If it doesn't blow the budget we have for the request, draw a random amount of the selected - // Item type to be requested - { - value = _randomUtil.RandInt(minValue, maxValue + 1); - } - - roublesBudget -= value * itemPrice; - - // Push a CompletionCondition with the item and the amount of the item into quest - chosenRequirementItemsTpls.Add(tplChosen); - quest.Conditions.AvailableForFinish.Add( - GenerateCompletionAvailableForFinish( - tplChosen, - value, - repeatableConfig.QuestConfig.Completion - ) - ); - - // Is there budget left for more items - if (roublesBudget > 0) - { - // Reduce item pool to fit budget - itemSelection = itemSelection - .Where(tpl => _itemHelper.GetItemPrice(tpl) < roublesBudget) - .ToList(); - if (!itemSelection.Any()) - { - // Nothing fits new budget, exit - break; - } - } - else - { - break; - } - } - - quest.Rewards = _repeatableQuestRewardGenerator.GenerateReward( - pmcLevel, - 1, - traderId, - repeatableConfig, - completionConfig, - chosenRequirementItemsTpls - ); - - return quest; - } - - /// - /// Generate a pool of item tpls the player should reasonably be able to retrieve - /// - /// Completion quest type config - /// Item tpls to not add to pool - /// Set of item tpls - protected HashSet GetItemsToRetrievePool( - Completion completionConfig, - HashSet itemTplBlacklist - ) - { - // Get seasonal items that should not be added to pool as seasonal event is not active - var seasonalItems = _seasonalEventService.GetInactiveSeasonalEventItems(); - - // Check for specific base classes which don't make sense as reward item - // also check if the price is greater than 0; there are some items whose price can not be found - return _databaseService - .GetItems() - .Values.Where(itemTemplate => - { - // Base "Item" item has no parent, ignore it - if (itemTemplate.Parent == string.Empty) - { - return false; - } - - if (seasonalItems.Contains(itemTemplate.Id)) - { - return false; - } - - // Valid reward items share same logic as items to retrieve - return _repeatableQuestRewardGenerator.IsValidRewardItem( - itemTemplate.Id, - itemTplBlacklist, - completionConfig.RequiredItemTypeBlacklist - ); - }) - .Select(item => item.Id) - .ToHashSet(); - } - - /// - /// A repeatable quest, besides some more or less static components, exists of reward and condition (see - /// assets/database/templates/repeatableQuests.json) - /// This is a helper method for GenerateCompletionQuest to create a completion condition (of which a completion quest - /// theoretically can have many) - /// - /// Id of the item to request - /// Amount of items of this specific type to request - /// Completion config from quest.json - /// object of "Completion"-condition - protected QuestCondition GenerateCompletionAvailableForFinish( - string itemTpl, - double value, - Completion completionConfig - ) - { - var onlyFoundInRaid = completionConfig.RequiredItemsAreFiR; - var minDurability = _itemHelper.IsOfBaseclasses( - itemTpl, - [BaseClasses.WEAPON, BaseClasses.ARMOR] - ) - ? _randomUtil.GetArrayValue( - [ - completionConfig.RequiredItemMinDurabilityMinMax.Min, - completionConfig.RequiredItemMinDurabilityMinMax.Max, - ] - ) - : 0; - - // Dog tags MUST NOT be FiR for them to work - if (_itemHelper.IsDogtag(itemTpl)) - { - onlyFoundInRaid = false; - } - - return new QuestCondition - { - Id = _hashUtil.Generate(), - Index = 0, - ParentId = "", - DynamicLocale = true, - VisibilityConditions = [], - Target = new ListOrT([itemTpl], null), - Value = value, - MinDurability = minDurability, - MaxDurability = 100, - DogtagLevel = 0, - OnlyFoundInRaid = onlyFoundInRaid, - IsEncoded = false, - ConditionType = "HandoverItem", - }; - } - /// /// Generates a valid Exploration quest /// @@ -938,8 +660,8 @@ public class RepeatableQuestGenerator( : explorationConfig.MaximumExtracts + 1; var numExtracts = _randomUtil.RandInt(1, exitTimesMax); - var quest = GenerateRepeatableTemplate( - "Exploration", + var quest = _repeatableQuestHelper.GenerateRepeatableTemplate( + RepeatableQuestType.Exploration, traderId, repeatableConfig.Side, sessionId @@ -1048,8 +770,8 @@ public class RepeatableQuestGenerator( { var pickupConfig = repeatableConfig.QuestConfig.Pickup; - var quest = GenerateRepeatableTemplate( - "Pickup", + var quest = _repeatableQuestHelper.GenerateRepeatableTemplate( + RepeatableQuestType.Pickup, traderId, repeatableConfig.Side, sessionId @@ -1124,104 +846,4 @@ public class RepeatableQuestGenerator( ConditionType = "ExitName", }; } - - /// - /// Generates the base object of quest type format given as templates in - /// assets/database/templates/repeatableQuests.json - /// The templates include Elimination, Completion and Extraction quest types - /// - /// Quest type: "Elimination", "Completion" or "Extraction" - /// Trader from which the quest will be provided - /// Scav daily or pmc daily/weekly quest - /// - /// Object which contains the base elements for repeatable quests of the requests type - /// (needs to be filled with reward and conditions by called to make a valid quest) - /// - protected RepeatableQuest GenerateRepeatableTemplate( - string type, - string traderId, - PlayerGroup playerGroup, - string sessionId - ) - { - RepeatableQuest questData = null; - switch (type) - { - case "Elimination": - questData = _databaseService.GetTemplates().RepeatableQuests.Templates.Elimination; - break; - case "Completion": - questData = _databaseService.GetTemplates().RepeatableQuests.Templates.Completion; - break; - case "Exploration": - questData = _databaseService.GetTemplates().RepeatableQuests.Templates.Exploration; - break; - case "Pickup": - questData = _databaseService.GetTemplates().RepeatableQuests.Templates.Pickup; - break; - } - - var questClone = _cloner.Clone(questData); - questClone.Id = _hashUtil.Generate(); - questClone.TraderId = traderId; - - /* in locale, these id correspond to the text of quests - template ids -pmc : Elimination = 616052ea3054fc0e2c24ce6e / Completion = 61604635c725987e815b1a46 / Exploration = 616041eb031af660100c9967 - template ids -scav : Elimination = 62825ef60e88d037dc1eb428 / Completion = 628f588ebb558574b2260fe5 / Exploration = 62825ef60e88d037dc1eb42c - */ - - // Get template id from config based on side and type of quest - var typeIds = _repeatableQuestHelper.GetRepeatableQuestTemplatesByGroup(playerGroup); - - questClone.TemplateId = typeIds[type]; - - // Force REF templates to use prapors ID - solves missing text issue - var desiredTraderId = traderId == Traders.REF ? Traders.PRAPOR : traderId; - - questClone.Name = questClone - .Name.Replace("{traderId}", traderId) - .Replace("{templateId}", questClone.TemplateId); - - questClone.Note = questClone - .Note.Replace("{traderId}", desiredTraderId) - .Replace("{templateId}", questClone.TemplateId); - - questClone.Description = questClone - .Description.Replace("{traderId}", desiredTraderId) - .Replace("{templateId}", questClone.TemplateId); - - questClone.SuccessMessageText = questClone - .SuccessMessageText.Replace("{traderId}", desiredTraderId) - .Replace("{templateId}", questClone.TemplateId); - - questClone.FailMessageText = questClone - .FailMessageText.Replace("{traderId}", desiredTraderId) - .Replace("{templateId}", questClone.TemplateId); - - questClone.StartedMessageText = questClone - .StartedMessageText.Replace("{traderId}", desiredTraderId) - .Replace("{templateId}", questClone.TemplateId); - - questClone.ChangeQuestMessageText = questClone - .ChangeQuestMessageText.Replace("{traderId}", desiredTraderId) - .Replace("{templateId}", questClone.TemplateId); - - questClone.AcceptPlayerMessage = questClone - .AcceptPlayerMessage.Replace("{traderId}", desiredTraderId) - .Replace("{templateId}", questClone.TemplateId); - - questClone.DeclinePlayerMessage = questClone - .DeclinePlayerMessage.Replace("{traderId}", desiredTraderId) - .Replace("{templateId}", questClone.TemplateId); - - questClone.CompletePlayerMessage = questClone - .CompletePlayerMessage.Replace("{traderId}", desiredTraderId) - .Replace("{templateId}", questClone.TemplateId); - - questClone.QuestStatus.Id = _hashUtil.Generate(); - questClone.QuestStatus.Uid = sessionId; // Needs to match user id - questClone.QuestStatus.QId = questClone.Id; // Needs to match quest id - - return questClone; - } } diff --git a/Libraries/SPTarkov.Server.Core/Helpers/RepeatableQuestHelper.cs b/Libraries/SPTarkov.Server.Core/Helpers/RepeatableQuestHelper.cs index 3fd20875..43776fc6 100644 --- a/Libraries/SPTarkov.Server.Core/Helpers/RepeatableQuestHelper.cs +++ b/Libraries/SPTarkov.Server.Core/Helpers/RepeatableQuestHelper.cs @@ -1,18 +1,25 @@ using SPTarkov.DI.Annotations; +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.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.Helpers; [Injectable] public class RepeatableQuestHelper( - ISptLogger _logger, - ConfigServer _configServer + ISptLogger logger, + DatabaseService databaseService, + HashUtil hashUtil, + ICloner cloner, + ConfigServer configServer ) { - protected QuestConfig _questConfig = _configServer.GetConfig(); + protected QuestConfig _questConfig = configServer.GetConfig(); /// /// Get the relevant elimination config based on the current players PMC level @@ -36,7 +43,7 @@ public class RepeatableQuestHelper( /// Side to get the templates for /// /// - public Dictionary? GetRepeatableQuestTemplatesByGroup(PlayerGroup playerGroup) + public Dictionary GetRepeatableQuestTemplatesByGroup(PlayerGroup playerGroup) { var templates = _questConfig.RepeatableQuestTemplates; @@ -47,4 +54,145 @@ public class RepeatableQuestHelper( _ => throw new ArgumentOutOfRangeException(nameof(playerGroup), playerGroup, null), }; } + + /// + /// Gets a cloned repeatable quest template for the provided type with a unique id + /// + /// Type of template to retrieve + /// TraderId that should provide this quest + /// Cloned quest template + /// + public RepeatableQuest? GetClonedQuestTemplateForType( + RepeatableQuestType type, + string traderId + ) + { + var quest = type switch + { + RepeatableQuestType.Elimination => cloner.Clone( + databaseService.GetTemplates().RepeatableQuests?.Templates?.Elimination), + RepeatableQuestType.Completion => cloner.Clone( + databaseService.GetTemplates().RepeatableQuests?.Templates?.Completion), + RepeatableQuestType.Exploration => cloner.Clone( + databaseService.GetTemplates().RepeatableQuests?.Templates?.Exploration), + RepeatableQuestType.Pickup => cloner.Clone( + databaseService.GetTemplates().RepeatableQuests?.Templates?.Pickup), + _ => null + }; + + if (quest is null) + { + return null; + } + + quest.Id = hashUtil.Generate(); + quest.TraderId = traderId; + + return quest; + } + + /// + /// Generates the base object of quest type format given as templates in + /// assets/database/templates/repeatableQuests.json + /// The templates include Elimination, Completion and Extraction quest types + /// + /// Quest type: "Elimination", "Completion" or "Extraction" + /// Trader from which the quest will be provided + /// Scav daily or pmc daily/weekly quest + /// sessionId to generate template for + /// + /// Object which contains the base elements for repeatable quests of the requests type + /// (needs to be filled with reward and conditions by called to make a valid quest) + /// + public RepeatableQuest? GenerateRepeatableTemplate( + RepeatableQuestType type, + string traderId, + PlayerGroup playerGroup, + string sessionId + ) + { + var questData = GetClonedQuestTemplateForType(type, traderId); + + if (questData is null) + { + // TODO: Localize me! + logger.Error($"No repeatable quest template found for type {type}"); + return null; + } + + // Get template id from config based on side and type of quest + var typeIds = GetRepeatableQuestTemplatesByGroup(playerGroup); + + var templateName = Enum.GetName(type); + + if (templateName is null) + { + // TODO: Localize me! + logger.Error($"Could not resolve template name for {type}"); + return null; + } + + questData.TemplateId = typeIds[templateName]; + + // Force REF templates to use prapors ID - solves missing text issue + var desiredTraderId = traderId == Traders.REF ? Traders.PRAPOR : traderId; + + /* in locale, these id correspond to the text of quests + template ids -pmc : Elimination = 616052ea3054fc0e2c24ce6e / Completion = 61604635c725987e815b1a46 / Exploration = 616041eb031af660100c9967 + template ids -scav : Elimination = 62825ef60e88d037dc1eb428 / Completion = 628f588ebb558574b2260fe5 / Exploration = 62825ef60e88d037dc1eb42c + */ + + questData.Name = questData + .Name.Replace("{traderId}", traderId) + .Replace("{templateId}", questData.TemplateId); + + questData.Note = questData + .Note?.Replace("{traderId}", desiredTraderId) + .Replace("{templateId}", questData.TemplateId); + + questData.Description = questData + .Description.Replace("{traderId}", desiredTraderId) + .Replace("{templateId}", questData.TemplateId); + + questData.SuccessMessageText = questData + .SuccessMessageText?.Replace("{traderId}", desiredTraderId) + .Replace("{templateId}", questData.TemplateId); + + questData.FailMessageText = questData + .FailMessageText?.Replace("{traderId}", desiredTraderId) + .Replace("{templateId}", questData.TemplateId); + + questData.StartedMessageText = questData + .StartedMessageText?.Replace("{traderId}", desiredTraderId) + .Replace("{templateId}", questData.TemplateId); + + questData.ChangeQuestMessageText = questData + .ChangeQuestMessageText?.Replace("{traderId}", desiredTraderId) + .Replace("{templateId}", questData.TemplateId); + + questData.AcceptPlayerMessage = questData + .AcceptPlayerMessage?.Replace("{traderId}", desiredTraderId) + .Replace("{templateId}", questData.TemplateId); + + questData.DeclinePlayerMessage = questData + .DeclinePlayerMessage?.Replace("{traderId}", desiredTraderId) + .Replace("{templateId}", questData.TemplateId); + + questData.CompletePlayerMessage = questData + .CompletePlayerMessage?.Replace("{traderId}", desiredTraderId) + .Replace("{templateId}", questData.TemplateId); + + if (questData.QuestStatus is null) + { + // TODO: Localize me! + logger.Error($"No quest status found for type {type}"); + return null; + } + + questData.QuestStatus.Id = hashUtil.Generate(); + questData.QuestStatus.Uid = sessionId; // Needs to match user id + questData.QuestStatus.QId = questData.Id; // Needs to match quest id + + return questData; + } } diff --git a/Libraries/SPTarkov.Server.Core/Models/Enums/RepeatableQuestType.cs b/Libraries/SPTarkov.Server.Core/Models/Enums/RepeatableQuestType.cs new file mode 100644 index 00000000..035ffaab --- /dev/null +++ b/Libraries/SPTarkov.Server.Core/Models/Enums/RepeatableQuestType.cs @@ -0,0 +1,12 @@ +using SPTarkov.Server.Core.Utils.Json.Converters; + +namespace SPTarkov.Server.Core.Models.Enums; + +[EftEnumConverter] +public enum RepeatableQuestType +{ + Elimination, + Completion, + Exploration, + Pickup +}