using SptCommon.Annotations; using Core.Models.Eft.Common; using Core.Models.Eft.Common.Tables; using Core.Models.Enums; using Core.Models.Spt.Config; using Core.Models.Spt.Repeatable; using Core.Models.Utils; using Core.Utils; using Core.Helpers; using Core.Servers; using Core.Services; using Core.Utils.Collections; using SptCommon.Extensions; using BodyPart = Core.Models.Spt.Config.BodyPart; using Core.Utils.Cloners; namespace 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, ICloner _cloner ) { protected QuestConfig _questConfig = _configServer.GetConfig(); protected int _maxRandomNumberAttempts = 6; /// /// 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 = _repeatableQuestHelper.ProbabilityObjectArray(eliminationConfig.Targets); var bodyPartsConfig = _repeatableQuestHelper.ProbabilityObjectArray>(eliminationConfig.BodyParts); var weaponCategoryRequirementConfig = _repeatableQuestHelper.ProbabilityObjectArray>(eliminationConfig.WeaponCategoryRequirements); var weaponRequirementConfig = _repeatableQuestHelper.ProbabilityObjectArray>(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 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 var maxDistDifficulty = 2; var maxKillDifficulty = eliminationConfig.MaxKills; var targetPool = questTypePool.Pool.Elimination; targetsConfig = targetsConfig.Filter((x) => questTypePool.Pool.Elimination.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 targetKey = targetsConfig.Draw()[0]; var targetDifficulty = 1 / targetsConfig.Probability(targetKey); questTypePool.Pool.Elimination.Targets.TryGetValue(targetKey, out var targetLocationPool); var locations = targetLocationPool.Locations; // we use any as location if "any" is in the pool and we do not 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.Next() || locations.Count <= 1) ) { locationKey = "any"; questTypePool.Pool.Elimination.Targets.Remove(targetKey); } else { locations = locations.Where(l => l != "any").ToList(); if (locations.Count > 0) { locationKey = _randomUtil.DrawRandomFromList(locations).FirstOrDefault(); questTypePool.Pool.Elimination.Targets.GetByJsonProp(targetKey).Locations = locations.Where( (l) => l != locationKey ) .ToList(); if (questTypePool.Pool.Elimination.Targets.GetByJsonProp(targetKey).Locations.Count == 0) { questTypePool.Pool.Elimination.Targets.Remove(targetKey); } } else { // never should reach this if everything works out _logger.Debug("Encountered issue when creating Elimination quest. Please report."); } } // draw the target body part and calculate the difficulty factor var bodyPartsToClient = new List(); var bodyPartDifficulty = 0d; if (eliminationConfig.BodyPartProbability > rand.Next()) { // 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 bi in bodyParts) { // more than one part lead to an "OR" condition hence more parts reduce the difficulty probability += bodyPartsConfig.Probability(bi).Value; foreach (var biClient in bodyPartsConfig.Data(bi)) { bodyPartsToClient.Add(biClient); } } bodyPartDifficulty = 1 / probability; } // Draw a distance condition int? distance = -1; var distanceDifficulty = 0; var isDistanceRequirementAllowed = !eliminationConfig.DistLocationBlacklist.Contains(locationKey); if (targetsConfig.Data(targetKey).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 == targetKey) } ) .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.Next() && isDistanceRequirementAllowed) { // Random distance with lower values more likely; simple distribution for starters... distance = (int)Math.Floor( (decimal)(Math.Abs(rand.Next(0, 1) - rand.Next(0, 1)) * (1 + eliminationConfig.MaxDistance - eliminationConfig.MinDistance) + eliminationConfig.MinDistance) ); distance = (int)Math.Ceiling((decimal)(distance / 5)) * 5; distanceDifficulty = (int)(maxDistDifficulty * distance / eliminationConfig.MaxDistance); } string? allowedWeaponsCategory = null; if (eliminationConfig.WeaponCategoryRequirementProbability > rand.Next()) { // Filter out close range weapons from far distance requirement if (distance > 50) { List weaponTypes = ["Shotgun", "Pistol"]; weaponCategoryRequirementConfig = (ProbabilityObjectArray>)weaponCategoryRequirementConfig .Where( (category) => weaponTypes .Contains(category.Key) ); } else if (distance < 20) { List weaponTypes = ["MarksmanRifle", "DMR"]; // Filter out far range weapons from close distance requirement weaponCategoryRequirementConfig = (ProbabilityObjectArray>)weaponCategoryRequirementConfig .Where( (category) => weaponTypes .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.Next()) { var weaponRequirement = weaponRequirementConfig.Draw(1, false); var specificAllowedWeaponCategory = weaponRequirementConfig.Data(weaponRequirement[0])[0]; var allowedWeapons = _itemHelper.GetItemTplsOfBaseType(specificAllowedWeaponCategory); allowedWeapon = _randomUtil.GetArrayValue(allowedWeapons); } // Draw how many npm kills are required var desiredKillCount = GetEliminationKillCount(targetKey, 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") { Enum.TryParse(typeof(ELocationName), locationKey, true, out var locationId); availableForFinishCondition.Counter.Conditions.Add(GenerateEliminationLocation(locationsConfig[(ELocationName)locationId])); } availableForFinishCondition.Counter.Conditions.Add( GenerateEliminationCondition( targetKey, bodyPartsToClient, distance.Value, 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 * @param targetKey Target type desired e.g. anyPmc/bossBully/Savage * @param targetsConfig Config * @param eliminationConfig Config * @returns 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 = location, 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 = target, // e,g, "AnyPmc" Value = 1, ResetOnSessionEnd = false, EnemyHealthEffects = [], Daytime = new DaytimeCounter() { From = 0, To = 0 }, ConditionType = "Kills" }; if (target.StartsWith("boss")) { killConditionProps.Target = "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; 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-existant" var possibleItemsToRetrievePool = _repeatableQuestRewardGenerator.GetRewardableItems( repeatableConfig, traderId); // Be fair, don't var the items be more expensive than the reward var multi = _randomUtil.GetFloat((float)0.5, 1); var roublesBudget = Math.Floor( (double)(_mathUtil.Interp1(pmcLevel, levelsConfig, roublesConfig) * multi)); roublesBudget = Math.Max(roublesBudget, 5000d); var itemSelection = possibleItemsToRetrievePool.Where( (x) => _itemHelper.GetItemPrice(x.Id) < 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 the arrays according to current player level var itemIdsWhitelisted = itemWhitelist .Where((p) => p.MinPlayerLevel <= pmcLevel) .SelectMany(x => x.ItemIds).ToList(); //.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.Id, v)) || itemIdsWhitelisted.Contains(x.Id) ); }).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; // we filter and concatenate the arrays according to current player level var itemIdsBlacklisted = itemBlacklist .Where((p) => p.MinPlayerLevel <= pmcLevel) .SelectMany(x => x.ItemIds).ToList(); //.Aggregate(List , (a, p) => a.Concat(p.ItemIds) ); itemSelection = itemSelection.Where((x) => { return ( itemIdsBlacklisted.All((v) => !_itemHelper.IsOfBaseclass(x.Id, v)) || !itemIdsBlacklisted.Contains(x.Id) ); }).ToList(); } if (!itemSelection.Any()) { _logger.Error(_localisationService.GetText("repeatable-completion_quest_whitelist_too_small_or_blacklist_too_restrictive")); return null; } // Draw items to ask player to retrieve var isAmmo = 0; // Store the indexes of items we are asking player to provide 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 = roublesBudget })); return null; } usedItemIndexes.Add(chosenItemIndex); var itemSelected = itemSelection[chosenItemIndex]; var itemUnitPrice = _itemHelper.GetItemPrice(itemSelected.Id).Value; var minValue = (double)completionConfig.MinimumRequestedAmount.Value; var maxValue = (double)completionConfig.MaximumRequestedAmount.Value; if (_itemHelper.IsOfBaseclass(itemSelected.Id, BaseClasses.AMMO)) { // Prevent multiple ammo requirements from being picked if (isAmmo > 0 && isAmmo < _maxRandomNumberAttempts) { isAmmo++; i--; continue; } isAmmo++; minValue = (double)completionConfig.MinimumRequestedBulletAmount.Value; maxValue = (double)completionConfig.MaximumRequestedBulletAmount.Value; } var value = minValue; // Get the value range within budget var x = Math.Floor(roublesBudget / itemUnitPrice); 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((int)minValue, (int)maxValue + 1); } roublesBudget -= value * itemUnitPrice; // Push a CompletionCondition with the item and the amount of the item chosenRequirementItemsTpls.Add(itemSelected.Id); quest.Conditions.AvailableForFinish.Add(GenerateCompletionAvailableForFinish(itemSelected.Id, value)); if (roublesBudget > 0) { // Reduce the list possible items to fulfill the new budget constraint itemSelection = itemSelection.Where((dbItem) => _itemHelper.GetItemPrice(dbItem.Id) < roublesBudget).ToList(); if (!itemSelection.Any()) { break; } } else { break; } } quest.Rewards = _repeatableQuestRewardGenerator.GenerateReward( pmcLevel, 1, traderId, repeatableConfig, completionConfig, chosenRequirementItemsTpls); return quest; } /// /// 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 /// object of "Completion"-condition protected QuestCondition GenerateCompletionAvailableForFinish(string itemTpl, double value) { _logger.Error("NOT IMPLEMENTED - GenerateCompletionAvailableForFinish"); throw new NotImplementedException(); } /// /// 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 = locationTarget, 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, 1)[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) { throw new NotImplementedException(); } /// /// 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 = side.ToLower() == "pmc" ? _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; } }