using SPTarkov.DI.Annotations; using SPTarkov.Server.Core.Helpers; using SPTarkov.Server.Core.Models.Eft.Common; 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.Spt.Repeatable; 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; using SPTarkov.Server.Core.Utils.Collections; using SPTarkov.Server.Core.Utils.Json; using BodyParts = SPTarkov.Server.Core.Constants.BodyParts; namespace SPTarkov.Server.Core.Generators; [Injectable] public class RepeatableQuestGenerator( ISptLogger _logger, RandomUtil _randomUtil, HashUtil _hashUtil, MathUtil _mathUtil, RepeatableQuestHelper _repeatableQuestHelper, ItemHelper _itemHelper, RepeatableQuestRewardGenerator _repeatableQuestRewardGenerator, DatabaseService _databaseService, LocalisationService _localisationService, ConfigServer _configServer, SeasonalEventService _seasonalEventService, ICloner _cloner ) { /// /// Body parts to present to the client as opposed to the body part information in quest data. /// private static readonly Dictionary> _bodyPartsToClient = new() { { BodyParts.Arms, [BodyParts.LeftArm, BodyParts.RightArm] }, { BodyParts.Legs, [BodyParts.LeftLeg, BodyParts.RightLeg] }, { BodyParts.Head, [BodyParts.Head] }, { BodyParts.Chest, [BodyParts.Chest, BodyParts.Stomach] }, }; protected int _maxRandomNumberAttempts = 6; protected QuestConfig _questConfig = _configServer.GetConfig(); /// /// This method is called by /GetClientRepeatableQuests/ and creates one element of quest type format (see /// assets/database/templates/repeatableQuests.json). /// It randomly draws a quest type (currently Elimination, Completion or Exploration) as well as a trader who is /// providing the quest /// /// Session id /// Player's level for requested items and reward generation /// Players trader standing/rep levels /// Possible quest types pool /// Repeatable quest config /// RepeatableQuest public RepeatableQuest? GenerateRepeatableQuest( string sessionId, int pmcLevel, Dictionary pmcTraderInfo, QuestTypePool questTypePool, RepeatableQuestConfig repeatableConfig ) { var questType = _randomUtil.DrawRandomFromList(questTypePool.Types).First(); // Get traders from whitelist and filter by quest type availability var traders = repeatableConfig .TraderWhitelist.Where(x => x.QuestTypes.Contains(questType)) .Select(x => x.TraderId) .ToList(); // filter out locked traders traders = traders.Where(x => pmcTraderInfo[x].Unlocked.GetValueOrDefault(false)).ToList(); var traderId = _randomUtil.DrawRandomFromList(traders).FirstOrDefault(); return questType switch { "Elimination" => GenerateEliminationQuest( sessionId, pmcLevel, traderId, questTypePool, repeatableConfig ), "Completion" => GenerateCompletionQuest( sessionId, pmcLevel, traderId, repeatableConfig ), "Exploration" => GenerateExplorationQuest( sessionId, pmcLevel, traderId, questTypePool, repeatableConfig ), "Pickup" => GeneratePickupQuest( sessionId, pmcLevel, traderId, questTypePool, repeatableConfig ), _ => null, }; } /// /// Generate a randomised Elimination quest /// /// Session id /// Player's level for requested items and reward generation /// Trader from which the quest will be provided /// Pools for quests (used to avoid redundant quests) /// /// The configuration for the repeatably kind (daily, weekly) as configured in QuestConfig /// for the requestd quest /// /// Object of quest type format for "Elimination" (see assets/database/templates/repeatableQuests.json) protected RepeatableQuest GenerateEliminationQuest( string sessionId, int pmcLevel, string traderId, QuestTypePool questTypePool, RepeatableQuestConfig repeatableConfig ) { var rand = new Random(); var eliminationConfig = _repeatableQuestHelper.GetEliminationConfigByPmcLevel( pmcLevel, repeatableConfig ); var locationsConfig = repeatableConfig.Locations; var targetsConfig = new ProbabilityObjectArray( _mathUtil, _cloner, eliminationConfig.Targets ); var bodyPartsConfig = new ProbabilityObjectArray>( _mathUtil, _cloner, eliminationConfig.BodyParts ); var weaponCategoryRequirementConfig = new ProbabilityObjectArray>( _mathUtil, _cloner, eliminationConfig.WeaponCategoryRequirements ); var weaponRequirementConfig = new ProbabilityObjectArray>( _mathUtil, _cloner, eliminationConfig.WeaponRequirements ); // the difficulty of the quest varies in difficulty depending on the condition // possible conditions are // - amount of npcs to kill // - type of npc to kill (scav, boss, pmc) // - with hit to what body part they should be killed // - from what distance they should be killed // a random combination of listed conditions can be required // possible conditions elements and their relative probability can be defined in QuestConfig.js // We use ProbabilityObjectArray to draw by relative probability. e.g. for targets: // "targets": { // "Savage": 7, // "AnyPmc": 2, // "bossBully": 0.5 // } // higher is more likely. We define the difficulty to be the inverse of the relative probability. // We want to generate a reward which is scaled by the difficulty of this mission. To get a upper bound with which we scale // the actual difficulty we calculate the minimum and maximum difficulty (max being the sum of max of each condition type // times the number of kills we have to perform): // The minimum difficulty is the difficulty for the most probable (= easiest target) with no additional conditions var minDifficulty = 1 / targetsConfig.MaxProbability(); // min difficulty is the lowest amount of scavs without any constraints // Target on bodyPart max. difficulty is that of the least probable element var maxTargetDifficulty = 1 / targetsConfig.MinProbability(); var maxBodyPartsDifficulty = eliminationConfig.MinKills / bodyPartsConfig.MinProbability(); // maxDistDifficulty is defined by 2, this could be a tuning parameter if we don't like the reward generation const int maxDistDifficulty = 2; var maxKillDifficulty = eliminationConfig.MaxKills; var targetPool = questTypePool.Pool.Elimination; targetsConfig = targetsConfig.Filter(x => targetPool.Targets.ContainsKey(x.Key)); if ( targetsConfig.Count == 0 || targetsConfig.All(x => x.Data.IsBoss.GetValueOrDefault(false)) ) { // There are no more targets left for elimination; delete it as a possible quest type // also if only bosses are left we need to leave otherwise it's a guaranteed boss elimination // -> then it would not be a quest with low probability anymore questTypePool.Types = questTypePool.Types.Where(t => t != "Elimination").ToList(); return null; } var botTypeToEliminate = targetsConfig.Draw()[0]; var targetDifficulty = 1 / targetsConfig.Probability(botTypeToEliminate); targetPool.Targets.TryGetValue(botTypeToEliminate, out var targetLocationPool); var locations = targetLocationPool.Locations; // we use any as location if "any" is in the pool, and we don't hit the specific location random // we use any also if the random condition is not met in case only "any" was in the pool var locationKey = "any"; if ( locations.Contains("any") && ( eliminationConfig.SpecificLocationProbability < rand.NextDouble() || locations.Count <= 1 ) ) { locationKey = "any"; targetPool.Targets.Remove(botTypeToEliminate); } else { // Specific location locations = locations.Where(l => l != "any").ToList(); if (locations.Count > 0) { // Get name of location we want elimination to occur on locationKey = _randomUtil.DrawRandomFromList(locations).FirstOrDefault(); // Get a pool of locations the chosen bot type can be eliminated on if ( !targetPool.Targets.TryGetValue( botTypeToEliminate, out var possibleLocationPool ) ) { _logger.Warning( $"Bot to kill: {botTypeToEliminate} not found in elimination dict" ); } // Filter locations bot can be killed on to just those not chosen by key possibleLocationPool.Locations = possibleLocationPool .Locations.Where(location => location != locationKey) .ToList(); // None left after filtering if (possibleLocationPool.Locations.Count == 0) { // TODO: Why do any of this?! // Remove chosen bot to eliminate from pool targetPool.Targets.Remove(botTypeToEliminate); } } else { // Never should reach this if everything works out _logger.Error( _localisationService.GetText( "quest-repeatable_elimination_generation_failed_please_report" ) ); } } // draw the target body part and calculate the difficulty factor var bodyPartsToClient = new List(); var bodyPartDifficulty = 0d; if (eliminationConfig.BodyPartProbability > rand.NextDouble()) { // if we add a bodyPart condition, we draw randomly one or two parts // each bodyPart of the BODYPARTS ProbabilityObjectArray includes the string(s) which need to be presented to the client in ProbabilityObjectArray.data // e.g. we draw "Arms" from the probability array but must present ["LeftArm", "RightArm"] to the client bodyPartsToClient = []; var bodyParts = bodyPartsConfig.Draw(_randomUtil.RandInt(1, 3), false); double probability = 0; foreach (var bodyPart in bodyParts) { // more than one part lead to an "OR" condition hence more parts reduce the difficulty probability += bodyPartsConfig.Probability(bodyPart).Value; if (_bodyPartsToClient.TryGetValue(bodyPart, out var bodyPartListToClient)) { bodyPartsToClient.AddRange(bodyPartListToClient); } else { bodyPartsToClient.Add(bodyPart); } } bodyPartDifficulty = 1 / probability; } // Draw a distance condition int? distance = null; var distanceDifficulty = 0; var isDistanceRequirementAllowed = !eliminationConfig.DistLocationBlacklist.Contains( locationKey ); if (targetsConfig.Data(botTypeToEliminate).IsBoss.GetValueOrDefault(false)) { // Get all boss spawn information var bossSpawns = _databaseService .GetLocations() .GetDictionary() .Select(x => x.Value) .Where(x => x.Base?.Id != null) .Select(x => new { x.Base.Id, BossSpawn = x.Base.BossLocationSpawn }); // filter for the current boss to spawn on map var thisBossSpawns = bossSpawns .Select(x => new { x.Id, BossSpawn = x.BossSpawn.Where(e => e.BossName == botTypeToEliminate), }) .Where(x => x.BossSpawn.Count() > 0); // remove blacklisted locations var allowedSpawns = thisBossSpawns.Where(x => !eliminationConfig.DistLocationBlacklist.Contains(x.Id) ); // if the boss spawns on nom-blacklisted locations and the current location is allowed we can generate a distance kill requirement isDistanceRequirementAllowed = isDistanceRequirementAllowed && allowedSpawns.Count() > 0; } if ( eliminationConfig.DistanceProbability > rand.NextDouble() && isDistanceRequirementAllowed ) { // Random distance with lower values more likely; simple distribution for starters... distance = (int) Math.Floor( Math.Abs(rand.NextDouble() - rand.NextDouble()) * (1 + eliminationConfig.MaxDistance - eliminationConfig.MinDistance) + eliminationConfig.MinDistance ?? 0 ); distance = (int)Math.Ceiling((decimal)(distance / 5)) * 5; distanceDifficulty = (int)( maxDistDifficulty * distance / eliminationConfig.MaxDistance ); } string? allowedWeaponsCategory = null; if (eliminationConfig.WeaponCategoryRequirementProbability > rand.NextDouble()) { // Filter out close range weapons from far distance requirement if (distance > 50) { List weaponTypeBlacklist = ["Shotgun", "Pistol"]; // Filter out close range weapons from long distance requirement weaponCategoryRequirementConfig.RemoveAll(category => weaponTypeBlacklist.Contains(category.Key) ); } else if (distance < 20) { List weaponTypeBlacklist = ["MarksmanRifle", "DMR"]; // Filter out far range weapons from close distance requirement weaponCategoryRequirementConfig.RemoveAll(category => weaponTypeBlacklist.Contains(category.Key) ); } // Pick a weighted weapon category var weaponRequirement = weaponCategoryRequirementConfig.Draw(1, false); // Get the hideout id value stored in the .data array allowedWeaponsCategory = weaponCategoryRequirementConfig.Data(weaponRequirement[0])[0]; } // Only allow a specific weapon requirement if a weapon category was not chosen string? allowedWeapon = null; if ( allowedWeaponsCategory is not null && eliminationConfig.WeaponRequirementProbability > rand.NextDouble() ) { var weaponRequirement = weaponRequirementConfig.Draw(1, false); var specificAllowedWeaponCategory = weaponRequirementConfig.Data(weaponRequirement[0]); var allowedWeapons = _itemHelper.GetItemTplsOfBaseType( specificAllowedWeaponCategory[0] ); allowedWeapon = _randomUtil.GetArrayValue(allowedWeapons); } // Draw how many npm kills are required var desiredKillCount = GetEliminationKillCount( botTypeToEliminate, targetsConfig, eliminationConfig ); var killDifficulty = desiredKillCount; // not perfectly happy here; we give difficulty = 1 to the quest reward generation when we have the most difficult mission // e.g. killing reshala 5 times from a distance of 200m with a headshot. var maxDifficulty = DifficultyWeighing(1, 1, 1, 1, 1); var curDifficulty = DifficultyWeighing( targetDifficulty.Value / maxTargetDifficulty, bodyPartDifficulty / maxBodyPartsDifficulty.Value, distanceDifficulty / maxDistDifficulty, killDifficulty / maxKillDifficulty.Value, allowedWeaponsCategory is not null || allowedWeapon is not null ? 1 : 0 ); // Aforementioned issue makes it a bit crazy since now all easier quests give significantly lower rewards than Completion / Exploration // I therefore moved the mapping a bit up (from 0.2...1 to 0.5...2) so that normal difficulty still gives good reward and having the // 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", traderId, repeatableConfig.Side, sessionId ); // ASSUMPTION: All fence quests are for scavs if (traderId == Traders.FENCE) { quest.Side = "Scav"; } var availableForFinishCondition = quest.Conditions.AvailableForFinish[0]; availableForFinishCondition.Counter.Id = _hashUtil.Generate(); availableForFinishCondition.Counter.Conditions = []; // Only add specific location condition if specific map selected if (locationKey != "any") { var locationId = Enum.Parse(locationKey); availableForFinishCondition.Counter.Conditions.Add( GenerateEliminationLocation(locationsConfig[locationId]) ); } availableForFinishCondition.Counter.Conditions.Add( GenerateEliminationCondition( botTypeToEliminate, bodyPartsToClient, distance, allowedWeapon, allowedWeaponsCategory ) ); availableForFinishCondition.Value = desiredKillCount; availableForFinishCondition.Id = _hashUtil.Generate(); quest.Location = GetQuestLocationByMapId(locationKey); quest.Rewards = _repeatableQuestRewardGenerator.GenerateReward( pmcLevel, Math.Min(difficulty, 1), traderId, repeatableConfig, eliminationConfig ); return quest; } /// /// Get a number of kills needed to complete elimination quest /// /// Target type desired e.g. anyPmc/bossBully/Savage /// Config of the target /// Config of the elimination /// Number of AI to kill protected int GetEliminationKillCount( string targetKey, ProbabilityObjectArray targetsConfig, EliminationConfig eliminationConfig ) { if (targetsConfig.Data(targetKey).IsBoss.GetValueOrDefault(false)) { return _randomUtil.RandInt( eliminationConfig.MinBossKills.Value, eliminationConfig.MaxBossKills + 1 ); } if (targetsConfig.Data(targetKey).IsPmc.GetValueOrDefault(false)) { return _randomUtil.RandInt( eliminationConfig.MinPmcKills.Value, eliminationConfig.MaxPmcKills + 1 ); } return _randomUtil.RandInt( eliminationConfig.MinKills.Value, eliminationConfig.MaxKills + 1 ); } protected double DifficultyWeighing( double target, double bodyPart, int dist, int kill, int weaponRequirement ) { return Math.Sqrt(Math.Sqrt(target) + bodyPart + dist + weaponRequirement) * kill; } /// /// 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 GenerateEliminationQuest to create a location condition. /// /// the location on which to fulfill the elimination quest /// Elimination-location-subcondition object protected QuestConditionCounterCondition GenerateEliminationLocation(List location) { return new QuestConditionCounterCondition { Id = _hashUtil.Generate(), DynamicLocale = true, Target = new ListOrT(location, null), ConditionType = "Location", }; } /// /// Create kill condition for an elimination quest /// /// Bot type target of elimination quest e.g. "AnyPmc", "Savage" /// Body parts player must hit /// Distance from which to kill (currently only >= supported) /// What weapon must be used - undefined = any /// What category of weapon must be used - undefined = any /// EliminationCondition object protected QuestConditionCounterCondition GenerateEliminationCondition( string target, List? targetedBodyParts, double? distance, string? allowedWeapon, string? allowedWeaponCategory ) { var killConditionProps = new QuestConditionCounterCondition { Id = _hashUtil.Generate(), DynamicLocale = true, Target = new ListOrT(null, target), // e,g, "AnyPmc" Value = 1, ResetOnSessionEnd = false, EnemyHealthEffects = [], Daytime = new DaytimeCounter { From = 0, To = 0 }, ConditionType = "Kills", }; if (target.StartsWith("boss")) { killConditionProps.Target = new ListOrT(null, "Savage"); killConditionProps.SavageRole = [target]; } // Has specific body part hit condition if (targetedBodyParts is not null) { killConditionProps.BodyPart = targetedBodyParts; } // Don't allow distance + melee requirement if (distance is not null && allowedWeaponCategory != "5b5f7a0886f77409407a7f96") { killConditionProps.Distance = new CounterConditionDistance { CompareMethod = ">=", Value = distance.Value, }; } // Has specific weapon requirement if (allowedWeapon is not null) { killConditionProps.Weapon = [allowedWeapon]; } // Has specific weapon category requirement if (allowedWeaponCategory?.Length > 0) { // TODO - fix - does weaponCategories exist? // killConditionProps.weaponCategories = [allowedWeaponCategory]; } 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; if (completionConfig is null) { _logger.Error("Unable to generate Completion quest, no Completion config found"); return null; } 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( (double)(_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.GetValueOrDefault(false)) { 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.GetValueOrDefault(false)) { 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.Value ); 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.Value; var maxValue = completionConfig.MaximumRequestedAmount.Value; 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 = [], GlobalQuestCounterId = "", Target = new ListOrT([itemTpl], null), Value = value, MinDurability = minDurability, MaxDurability = 100, DogtagLevel = 0, OnlyFoundInRaid = onlyFoundInRaid, IsEncoded = false, ConditionType = "HandoverItem", }; } /// /// Generates a valid Exploration quest /// /// session id for the quest /// player's level for reward generation /// trader from which the quest will be provided /// Pools for quests (used to avoid redundant quests) /// /// The configuration for the repeatably kind (daily, weekly) as configured in QuestConfig /// for the requested quest /// /// object of quest type format for "Exploration" (see assets/database/templates/repeatableQuests.json) protected RepeatableQuest? GenerateExplorationQuest( string sessionId, int pmcLevel, string traderId, QuestTypePool questTypePool, RepeatableQuestConfig repeatableConfig ) { var explorationConfig = repeatableConfig.QuestConfig.Exploration; var requiresSpecificExtract = _randomUtil.Random.Next() < repeatableConfig.QuestConfig.Exploration.SpecificExits.Probability; if (questTypePool.Pool.Exploration.Locations.Count == 0) { // there are no more locations left for exploration; delete it as a possible quest type questTypePool.Types = questTypePool.Types.Where(t => t != "Exploration").ToList(); return null; } // If location drawn is factory, it's possible to either get factory4_day and factory4_night or only one // of the both var locationKey = _randomUtil.DrawRandomFromDict(questTypePool.Pool.Exploration.Locations)[ 0 ]; var locationTarget = questTypePool.Pool.Exploration.Locations[locationKey]; // Remove the location from the available pool questTypePool.Pool.Exploration.Locations.Remove(locationKey); // Different max extract count when specific extract needed var exitTimesMax = requiresSpecificExtract ? explorationConfig.MaximumExtractsWithSpecificExit : explorationConfig.MaximumExtracts + 1; var numExtracts = _randomUtil.RandInt(1, exitTimesMax); var quest = GenerateRepeatableTemplate( "Exploration", traderId, repeatableConfig.Side, sessionId ); var exitStatusCondition = new QuestConditionCounterCondition { Id = _hashUtil.Generate(), DynamicLocale = true, Status = ["Survived"], ConditionType = "ExitStatus", }; var locationCondition = new QuestConditionCounterCondition { Id = _hashUtil.Generate(), DynamicLocale = true, Target = new ListOrT(locationTarget, null), ConditionType = "Location", }; quest.Conditions.AvailableForFinish[0].Counter.Id = _hashUtil.Generate(); quest.Conditions.AvailableForFinish[0].Counter.Conditions = [ exitStatusCondition, locationCondition, ]; quest.Conditions.AvailableForFinish[0].Value = numExtracts; quest.Conditions.AvailableForFinish[0].Id = _hashUtil.Generate(); quest.Location = GetQuestLocationByMapId(locationKey.ToString()); if (requiresSpecificExtract) { // Fetch extracts for the requested side var mapExits = GetLocationExitsForSide(locationKey.ToString(), repeatableConfig.Side); // Only get exits that have a greater than 0% chance to spawn var exitPool = mapExits.Where(exit => exit.Chance > 0).ToList(); // Exclude exits with a requirement to leave (e.g. car extracts) var possibleExits = exitPool .Where(exit => exit.PassageRequirement is not null || repeatableConfig.QuestConfig.Exploration.SpecificExits.PassageRequirementWhitelist.Contains( "PassageRequirement" ) ) .ToList(); if (possibleExits.Count == 0) { _logger.Error( $"Unable to choose specific exit on map: {locationKey}, Possible exit pool was empty" ); } else { // Choose one of the exits we filtered above var chosenExit = _randomUtil.DrawRandomFromList(possibleExits)[0]; // Create a quest condition to leave raid via chosen exit var exitCondition = GenerateExplorationExitCondition(chosenExit); quest.Conditions.AvailableForFinish[0].Counter.Conditions.Add(exitCondition); } } // Difficulty for exploration goes from 1 extract to maxExtracts // Difficulty for reward goes from 0.2...1 -> map var difficulty = _mathUtil.MapToRange( numExtracts, 1, explorationConfig.MaximumExtracts.Value, 0.2, 1 ); quest.Rewards = _repeatableQuestRewardGenerator.GenerateReward( pmcLevel, difficulty, traderId, repeatableConfig, explorationConfig ); return quest; } /// /// Filter a maps exits to just those for the desired side /// /// Map id (e.g. factory4_day) /// Scav/Pmc /// List of Exit objects protected List GetLocationExitsForSide(string locationKey, string playerSide) { var mapExtracts = _databaseService.GetLocation(locationKey.ToLower()).AllExtracts; return mapExtracts.Where(exit => exit.Side == playerSide).ToList(); } protected RepeatableQuest GeneratePickupQuest( string sessionId, int pmcLevel, string traderId, QuestTypePool questTypePool, RepeatableQuestConfig repeatableConfig ) { var pickupConfig = repeatableConfig.QuestConfig.Pickup; var quest = GenerateRepeatableTemplate( "Pickup", traderId, repeatableConfig.Side, sessionId ); var itemTypeToFetchWithCount = _randomUtil.GetArrayValue( pickupConfig.ItemTypeToFetchWithMaxCount ); var itemCountToFetch = _randomUtil.RandInt( itemTypeToFetchWithCount.MinimumPickupCount.Value, itemTypeToFetchWithCount.MaximumPickupCount + 1 ); // Choose location - doesnt seem to work for anything other than 'any' // var locationKey: string = this.randomUtil.drawRandomFromDict(questTypePool.pool.Pickup.locations)[0]; // var locationTarget = questTypePool.pool.Pickup.locations[locationKey]; var findCondition = quest.Conditions.AvailableForFinish.FirstOrDefault(x => x.ConditionType == "FindItem" ); findCondition.Target = new ListOrT([itemTypeToFetchWithCount.ItemType], null); findCondition.Value = itemCountToFetch; var counterCreatorCondition = quest.Conditions.AvailableForFinish.FirstOrDefault(x => x.ConditionType == "CounterCreator" ); // var locationCondition = counterCreatorCondition._props.counter.conditions.find(x => x._parent === "Location"); // (locationCondition._props as ILocationConditionProps).target = [...locationTarget]; var equipmentCondition = counterCreatorCondition.Counter.Conditions.FirstOrDefault(x => x.ConditionType == "Equipment" ); equipmentCondition.EquipmentInclusive = [ [itemTypeToFetchWithCount.ItemType], ]; // Add rewards quest.Rewards = _repeatableQuestRewardGenerator.GenerateReward( pmcLevel, 1, traderId, repeatableConfig, pickupConfig ); return quest; } /// /// Convert a location into an quest code can read (e.g. factory4_day into 55f2d3fd4bdc2d5f408b4567) /// /// e.g factory4_day /// guid protected string GetQuestLocationByMapId(string locationKey) { return _questConfig.LocationIdMap[locationKey]; } /// /// Exploration repeatable quests can specify a required extraction point. /// This method creates the according object which will be appended to the conditions list /// /// The exit name to generate the condition for /// Exit condition protected QuestConditionCounterCondition GenerateExplorationExitCondition(Exit exit) { return new QuestConditionCounterCondition { Id = _hashUtil.Generate(), DynamicLocale = true, ExitName = exit.Name, 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, string side, 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 = string.Equals(side, "pmc", StringComparison.OrdinalIgnoreCase) ? _questConfig.QuestTemplateIds.Pmc : _questConfig.QuestTemplateIds.Scav; var templateId = string.Empty; switch (type) { case "Completion": templateId = typeIds.Completion; break; case "Elimination": templateId = typeIds.Elimination; break; case "Exploration": templateId = typeIds.Exploration; break; case "Pickup": templateId = typeIds.Pickup; break; } questClone.TemplateId = templateId; // 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; } }