From 524fa65c293ead38c8d1d8d0010607931959d7b4 Mon Sep 17 00:00:00 2001 From: Cj <161484149+CJ-SPT@users.noreply.github.com> Date: Mon, 23 Jun 2025 09:54:43 -0400 Subject: [PATCH] Repeatable quest generation (Part 3) (#420) * clean up EliminationQuestGenerator.cs * add locales --- .../SPT_Data/configs/quest.json | 32 +- .../SPT_Data/database/locales/server/en.json | 8 + .../EliminationQuestGenerator.cs | 673 ++++++++++++------ .../Models/Spt/Config/QuestConfig.cs | 12 +- 4 files changed, 496 insertions(+), 229 deletions(-) diff --git a/Libraries/SPTarkov.Server.Assets/SPT_Data/configs/quest.json b/Libraries/SPTarkov.Server.Assets/SPT_Data/configs/quest.json index 16a82bfa..415ebf98 100644 --- a/Libraries/SPTarkov.Server.Assets/SPT_Data/configs/quest.json +++ b/Libraries/SPTarkov.Server.Assets/SPT_Data/configs/quest.json @@ -474,8 +474,8 @@ "minBossKills": 1, "maxPmcKills": 2, "minPmcKills": 1, - "weaponRequirementProb": 0, - "weaponCategoryRequirementProb": 0.15, + "weaponRequirementChance": 0, + "weaponCategoryRequirementChance": 15, "weaponCategoryRequirements": [ { "key": "Shotgun", @@ -700,8 +700,8 @@ "minBossKills": 1, "maxPmcKills": 5, "minPmcKills": 2, - "weaponRequirementProb": 0, - "weaponCategoryRequirementProb": 0.15, + "weaponRequirementChance": 0, + "weaponCategoryRequirementChance": 15, "weaponCategoryRequirements": [ { "key": "Shotgun", @@ -921,8 +921,8 @@ "minBossKills": 2, "maxPmcKills": 6, "minPmcKills": 3, - "weaponRequirementProb": 0, - "weaponCategoryRequirementProb": 0.15, + "weaponRequirementChance": 0, + "weaponCategoryRequirementChance": 15, "weaponCategoryRequirements": [ { "key": "Shotgun", @@ -1351,8 +1351,8 @@ "minBossKills": 3, "maxPmcKills": 8, "minPmcKills": 5, - "weaponRequirementProb": 0, - "weaponCategoryRequirementProb": 0.2, + "weaponRequirementChance": 0, + "weaponCategoryRequirementChance": 20, "weaponCategoryRequirements": [ { "key": "Shotgun", @@ -1572,8 +1572,8 @@ "minBossKills": 5, "maxPmcKills": 15, "minPmcKills": 10, - "weaponRequirementProb": 0, - "weaponCategoryRequirementProb": 0.15, + "weaponRequirementChance": 0, + "weaponCategoryRequirementChance": 15, "weaponCategoryRequirements": [ { "key": "Shotgun", @@ -1793,8 +1793,8 @@ "minBossKills": 7, "maxPmcKills": 25, "minPmcKills": 10, - "weaponRequirementProb": 0, - "weaponCategoryRequirementProb": 0.2, + "weaponRequirementChance": 0, + "weaponCategoryRequirementChance": 20, "weaponCategoryRequirements": [ { "key": "Shotgun", @@ -2081,8 +2081,8 @@ "minBossKills": 1, "maxPmcKills": 2, "minPmcKills": 1, - "weaponRequirementProb": 0, - "weaponCategoryRequirementProb": 0.3, + "weaponRequirementChance": 0, + "weaponCategoryRequirementChance": 30, "weaponCategoryRequirements": [ { "key": "Shotgun", @@ -2218,8 +2218,8 @@ "minBossKills": 1, "maxPmcKills": 5, "minPmcKills": 2, - "weaponRequirementProb": 0, - "weaponCategoryRequirementProb": 0.3, + "weaponRequirementChance": 0, + "weaponCategoryRequirementChance": 30, "weaponCategoryRequirements": [ { "key": "Shotgun", diff --git a/Libraries/SPTarkov.Server.Assets/SPT_Data/database/locales/server/en.json b/Libraries/SPTarkov.Server.Assets/SPT_Data/database/locales/server/en.json index 89197d33..5cdd11ff 100644 --- a/Libraries/SPTarkov.Server.Assets/SPT_Data/database/locales/server/en.json +++ b/Libraries/SPTarkov.Server.Assets/SPT_Data/database/locales/server/en.json @@ -669,6 +669,14 @@ "repeatable-unable_to_find_location_id_for_location_name": "Unable to find a locationId for: %s", "repeatable-unable_to_find_exits_for_location": "Unable to get location exits for location: %s", "repeatable-unable_choose_exit_pool_empty": "Unable to choose specific exit for map: %s as exit pool is empty", + "repeatable-eliminationQuestGenerationData-is-null" : "Elimination generation data is null", + "repeatable-no-bot-types-remain": "No remaining bot types remain in pool, skipping Elimination quest generation", + "repeatable-unable-targets-are-null": "Unable to generate Elimination quest generation, targets are null", + "repeatable-unable-get-target-pool": "Unable to get targetLocationPool for bot type: %s", + "repeatable-unable-get-location-key": "Unable to get locationKey for bot type: %s", + "repeatable-elimination-config-not-found": "Unable to find the elimination config", + "repeatable-elimination-any-not-found": "We're not targeting a specific location and `any` was not found in locations", + "repeatable-elimination-specific-weapon-null": "Specific allowed weapon categories are null", "reward-type_not_handled": "Reward type: {{rewardType}} not handled for quest/achievement: {{questId}}", "reward-unable_to_find_matching_hideout_production": "Unable to find matching hideout craft unlock for quest/achievement: {{questId}}, matches found: {{matchCount}}", "route_onupdate_no_response": "onUpdate: %s route doesn't report success or fail", diff --git a/Libraries/SPTarkov.Server.Core/Generators/RepeatableQuestGeneration/EliminationQuestGenerator.cs b/Libraries/SPTarkov.Server.Core/Generators/RepeatableQuestGeneration/EliminationQuestGenerator.cs index 9a03cad1..48bba447 100644 --- a/Libraries/SPTarkov.Server.Core/Generators/RepeatableQuestGeneration/EliminationQuestGenerator.cs +++ b/Libraries/SPTarkov.Server.Core/Generators/RepeatableQuestGeneration/EliminationQuestGenerator.cs @@ -42,8 +42,22 @@ public class EliminationQuestGenerator( { BodyParts.Chest, [BodyParts.Chest, BodyParts.Stomach] }, }; + /// + /// MaxDistDifficulty is defined by 2, this could be a tuning parameter if we don't like the reward generation + /// + protected const int MaxDistDifficulty = 2; + protected QuestConfig QuestConfig = configServer.GetConfig(); + protected record EliminationQuestGenerationData( + EliminationConfig EliminationConfig, + Dictionary> LocationsConfig, + ProbabilityObjectArray TargetsConfig, + ProbabilityObjectArray> BodyPartsConfig, + ProbabilityObjectArray> WeaponCategoryRequirementConfig, + ProbabilityObjectArray> WeaponRequirementConfig + ); + /// /// Generate a randomised Elimination quest /// @@ -64,33 +78,12 @@ public class EliminationQuestGenerator( 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 - ); + var generationData = GetGenerationData(repeatableConfig, pmcLevel); + if (generationData is null) + { + logger.Error(localisationService.GetText("repeatable-eliminationQuestGenerationData-is-null")); + return null; + } // the difficulty of the quest varies in difficulty depending on the condition // possible conditions are @@ -108,229 +101,120 @@ public class EliminationQuestGenerator( // } // 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 + // We want to generate a reward which is scaled by the difficulty of this mission. To get an 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 + var minDifficulty = + 1 / generationData.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 maxTargetDifficulty = 1 / generationData.TargetsConfig.MinProbability(); + var maxBodyPartsDifficulty = + generationData.EliminationConfig.MinKills / generationData.BodyPartsConfig.MinProbability(); + var maxKillDifficulty = generationData.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 ?? false)) + // Get a random bot type to eliminate + var (botTypeToEliminate, targetsConfig) = GetBotTypeToEliminate(generationData, questTypePool); + if (botTypeToEliminate is null || targetsConfig is null) { - // 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(); + logger.Warning(localisationService.GetText("repeatable-no-bot-types-remain")); 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") - && ( - randomUtil.GetChance100(eliminationConfig.SpecificLocationChance) - || locations.Count <= 1 - ) - ) + if (targetPool.Targets is null) { - locationKey = "any"; - targetPool.Targets.Remove(botTypeToEliminate); + logger.Error(localisationService.GetText("repeatable-unable-targets-are-null")); + return null; } - 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 - ) + // Try and get a target location pool for this bot type + if (!targetPool.Targets.TryGetValue(botTypeToEliminate, out var targetLocationPool)) + { + logger.Error(localisationService.GetText( + "repeatable-unable-get-target-pool", + botTypeToEliminate ) - { - 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" - ) - ); - } + return null; } - // draw the target body part and calculate the difficulty factor + // Try and get a location key for this quest + if (!TryGetLocationKey(generationData, targetPool, botTypeToEliminate, targetLocationPool.Locations, + out var locationKey) || locationKey is null) + { + logger.Error(localisationService.GetText( + "repeatable-unable-get-location-key", + botTypeToEliminate + ) + ); + + return null; + } + + // Generate a body part, make sure we ref the body part difficulty so it can be adjusted var bodyPartsToClient = new List(); var bodyPartDifficulty = 0d; - if (randomUtil.GetChance100(eliminationConfig.BodyPartChance)) + var generateBodyParts = randomUtil.GetChance100(generationData.EliminationConfig.BodyPartChance); + if (generateBodyParts) { - // 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 the target body part and calculate the difficulty factor + bodyPartsToClient.AddRange(GenerateBodyParts(generationData, ref bodyPartDifficulty)); } // Draw a distance condition + var isDistanceRequirementAllowed = IsDistanceRequirementAllowed( + generationData, botTypeToEliminate, locationKey, targetsConfig + ); + int? distance = null; var distanceDifficulty = 0; - var isDistanceRequirementAllowed = !eliminationConfig.DistLocationBlacklist.Contains( - locationKey - ); - if (targetsConfig.Data(botTypeToEliminate)?.IsBoss ?? false) + // Generate a distance requirement + if (isDistanceRequirementAllowed) { - // 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.Any(); - } - - if ( - randomUtil.GetChance100(eliminationConfig.DistanceProbability) - && 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 - ); - - distance = (int)Math.Ceiling((decimal)(distance / 5)) * 5; - distanceDifficulty = (int)( - maxDistDifficulty * distance / eliminationConfig.MaxDistance - ); + var (dist, distDiff) = GenerateDistanceRequirement(generationData); + distance = dist; + distanceDifficulty = distDiff; } string? allowedWeaponsCategory = null; - if (randomUtil.GetChance100(eliminationConfig.WeaponCategoryRequirementProbability)) + + var generateWeaponCategoryRequirement = + randomUtil.GetChance100(generationData.EliminationConfig.WeaponCategoryRequirementChance); + + // Generate a weapon category requirement + if (generateWeaponCategoryRequirement) { - // 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]; + allowedWeaponsCategory = GenerateWeaponCategoryRequirement(generationData, distance); } // 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 generateWeaponRequirement = + randomUtil.GetChance100(generationData.EliminationConfig.WeaponRequirementChance); + + // Generate a weapon requirement + if (!generateWeaponCategoryRequirement && generateWeaponRequirement) { - var weaponRequirement = weaponRequirementConfig.Draw(1, false); - var specificAllowedWeaponCategory = weaponRequirementConfig.Data(weaponRequirement[0]); - var allowedWeapons = itemHelper.GetItemTplsOfBaseType(specificAllowedWeaponCategory[0]); - allowedWeapon = randomUtil.GetArrayValue(allowedWeapons); + allowedWeapon = GenerateSpecificWeaponRequirement(generationData); } // Draw how many npm kills are required var desiredKillCount = GetEliminationKillCount( botTypeToEliminate, targetsConfig, - eliminationConfig + generationData.EliminationConfig ); + var killDifficulty = desiredKillCount; // not perfectly happy here; we give difficulty = 1 to the quest reward generation when we have the most difficult mission @@ -339,7 +223,7 @@ public class EliminationQuestGenerator( var curDifficulty = DifficultyWeighing( targetDifficulty.Value / maxTargetDifficulty, bodyPartDifficulty / maxBodyPartsDifficulty, - distanceDifficulty / maxDistDifficulty, + distanceDifficulty / MaxDistDifficulty, killDifficulty / maxKillDifficulty, allowedWeaponsCategory is not null || allowedWeapon is not null ? 1 : 0 ); @@ -356,14 +240,26 @@ public class EliminationQuestGenerator( sessionId ); + if (quest is null) + { + logger.Error( + localisationService.GetText( + "repeatable-quest_generation_failed_no_template", + "elimination" + ) + ); + + return null; + } + // 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(); + var availableForFinishCondition = quest.Conditions.AvailableForFinish![0]; + availableForFinishCondition.Counter!.Id = hashUtil.Generate(); availableForFinishCondition.Counter.Conditions = []; // Only add specific location condition if specific map selected @@ -371,7 +267,7 @@ public class EliminationQuestGenerator( { var locationId = Enum.Parse(locationKey); availableForFinishCondition.Counter.Conditions.Add( - GenerateEliminationLocation(locationsConfig[locationId]) + GenerateEliminationLocation(generationData.LocationsConfig[locationId]) ); } @@ -395,12 +291,375 @@ public class EliminationQuestGenerator( Math.Min(difficulty, 1), traderId, repeatableConfig, - eliminationConfig + generationData.EliminationConfig ); return quest; } + protected EliminationQuestGenerationData? GetGenerationData(RepeatableQuestConfig repeatableConfig, int pmcLevel) + { + var eliminationConfig = repeatableQuestHelper.GetEliminationConfigByPmcLevel( + pmcLevel, + repeatableConfig + ); + + if (eliminationConfig is null) + { + logger.Error(localisationService.GetText("repeatable-elimination-config-not-found")); + return null; + } + + 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 + ); + + return new EliminationQuestGenerationData( + eliminationConfig, + locationsConfig, + targetsConfig, + bodyPartsConfig, + weaponCategoryRequirementConfig, + weaponRequirementConfig + ); + } + + /// + /// Gets and filters a bot type for this elimination quest + /// + /// Generation data + /// Quest pool to generate from + /// target, filtered targets config + protected (string?, ProbabilityObjectArray?) GetBotTypeToEliminate( + EliminationQuestGenerationData generationData, + QuestTypePool questTypePool + ) + { + var targetPool = questTypePool.Pool.Elimination; + + var targetsConfig = generationData.TargetsConfig + .Filter(x => targetPool.Targets.ContainsKey(x.Key)); + + if (targetsConfig.Count != 0 && !targetsConfig.All(x => x.Data?.IsBoss ?? false)) + { + return (targetsConfig.Draw()[0], targetsConfig); + } + + // 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, null); + } + + /// + /// Try and get a location key to generate this quest for + /// + /// Generation data + /// Target pool + /// Bot type to eliminate + /// locations to choose from + /// selected location key + /// True if location key selected, false otherwise + protected bool TryGetLocationKey( + EliminationQuestGenerationData generationData, + EliminationPool targetPool, + string botTypeToEliminate, + List locations, + out string? locationKey + ) + { + var useSpecificLocation = randomUtil.GetChance100(generationData.EliminationConfig.SpecificLocationChance); + + switch (useSpecificLocation) + { + // We're not using a specific location, and the locations contain any. + case false when locations.Contains("any"): + locationKey = "any"; + return true; + // We're not using a specific location and locations didn't contain any. + case false: + logger.Error(localisationService.GetText("repeatable-elimination-any-not-found")); + locationKey = null; + return false; + } + + // Specific location + locations = locations.Where(location => location != "any").ToList(); + if (locations.Count == 0) + { + // Never should reach this if everything works out + logger.Error( + localisationService.GetText( + "quest-repeatable_elimination_generation_failed_please_report" + ) + ); + + locationKey = null; + return false; + } + + // Get name of location we want elimination to occur on + locationKey = randomUtil.DrawRandomFromList(locations).First(); + + // 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" + ); + + locationKey = null; + return false; + } + + // Can't use out params in lambda's + var tmpKey = locationKey; + + // Filter locations bot can be killed on to just those not chosen by key + possibleLocationPool.Locations = possibleLocationPool + .Locations?.Where(location => location != tmpKey) + .ToList(); + + // None left after filtering + if (possibleLocationPool.Locations?.Count is null or 0) + { + // TODO: Why do any of this?! + // Remove chosen bot to eliminate from pool + targetPool.Targets.Remove(botTypeToEliminate); + } + + return true; + } + + /// + /// Selects body parts to add to the condition. Modifies the bodyPartDifficulty based on selection. + /// + /// Generation data + /// BodyPartDifficulty to modify based on selection + /// List of selected body parts + protected List GenerateBodyParts( + EliminationQuestGenerationData generationData, + ref double bodyPartDifficulty + ) + { + // 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 + var bodyPartsToClient = new List(); + + var bodyParts = generationData.BodyPartsConfig.Draw( + randomUtil.RandInt(1, 3), + false); + + var probability = 0d; + + foreach (var bodyPart in bodyParts) + { + // more than one part lead to an "OR" condition hence more parts reduce the difficulty + probability += generationData.BodyPartsConfig?.Probability(bodyPart) ?? 0d; + + // Add multiple body parts needed for key + if (_bodyPartsToClient.TryGetValue(bodyPart, out var bodyPartListToClient)) + { + bodyPartsToClient.AddRange(bodyPartListToClient); + continue; + } + + // Add singular body-part, e.g. head + bodyPartsToClient.Add(bodyPart); + } + + bodyPartDifficulty = 1 / probability; + + return bodyPartsToClient; + } + + /// + /// Determines if we're allowed to generate a distance requirement for this location. + /// Takes into account location whitelist, random chance, and boss location modifiers + /// + /// Generation data + /// Bot type to eliminate + /// Location key to check + /// Targets config + /// True if allowed, false if not + protected bool IsDistanceRequirementAllowed( + EliminationQuestGenerationData generationData, + string botTypeToEliminate, + string locationKey, + ProbabilityObjectArray targetsConfig + ) + { + // This location is can be chosen for a distance requirement + var whitelisted = !generationData.EliminationConfig.DistLocationBlacklist.Contains( + locationKey + ); + + // We're not whitelisted, exit early to avoid doing a roll for no reason + if (!whitelisted) + { + return false; + } + + // Are we allowed a distance condition by chance? + var isAllowedByChance = randomUtil.GetChance100(generationData.EliminationConfig.DistanceProbability); + + // Not allowed by chance, return early. + // We now just assume we rolled this condition and don't take it into account anymore. + if (!isAllowedByChance) + { + return false; + } + + // We're not a boss, return true if this location is whitelisted + if (!(targetsConfig.Data(botTypeToEliminate)?.IsBoss ?? false)) + { + return whitelisted; + } + + // Get all boss spawn information + var bossSpawns = databaseService + .GetLocations() + .GetDictionary() + .Select(x => x.Value) + .Where(location => location.Base?.Id != null) + .Select(location => new + { + location.Base.Id, + BossSpawn = location.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.Any()); + + // remove blacklisted locations + var allowedSpawns = thisBossSpawns.Where(x => + !generationData.EliminationConfig.DistLocationBlacklist.Contains(x.Id) + ); + + // if the boss spawns on non-blacklisted locations and the current location is allowed, + // we can generate a distance kill requirement + return whitelisted && allowedSpawns.Any(); + } + + /// + /// Generate a distance requirement and difficulty modifier + /// + /// Generation data + /// distance and difficulty modifier + protected (int, int) GenerateDistanceRequirement(EliminationQuestGenerationData generationData) + { + // Random distance with lower values more likely; simple distribution for starters... + var distance = (int) + Math.Floor( + Math.Abs(randomUtil.Random.NextDouble() - randomUtil.Random.NextDouble()) + * (1 + generationData.EliminationConfig.MaxDistance - generationData.EliminationConfig.MinDistance) + + generationData.EliminationConfig.MinDistance + ); + + distance = (int) Math.Ceiling((decimal) (distance / 5d)) * 5; + + var distanceDifficulty = (int) ( + MaxDistDifficulty * distance / generationData.EliminationConfig.MaxDistance + ); + + return (distance, distanceDifficulty); + } + + /// + /// Generate a weapon category requirement + /// + /// Generation data + /// Distance to generate it for, pass null if not required + /// Weapon requirement category selected + protected string? GenerateWeaponCategoryRequirement( + EliminationQuestGenerationData generationData, + int? distance + ) + { + switch (distance) + { + // Filter out close range weapons from far distance requirement + case > 50: + { + List weaponTypeBlacklist = ["Shotgun", "Pistol"]; + + // Filter out close range weapons from long distance requirement + generationData.WeaponCategoryRequirementConfig.RemoveAll(category => + weaponTypeBlacklist.Contains(category.Key) + ); + break; + } + // Filter out long range weapons from close distance requirement + case < 20: + { + List weaponTypeBlacklist = ["MarksmanRifle", "DMR"]; + + // Filter out far range weapons from close distance requirement + generationData.WeaponCategoryRequirementConfig.RemoveAll(category => + weaponTypeBlacklist.Contains(category.Key) + ); + break; + } + } + + // Pick a weighted weapon category + var weaponRequirement = generationData.WeaponCategoryRequirementConfig.Draw(1, false); + + // Get the hideout id value stored in the .data array + return generationData.WeaponCategoryRequirementConfig.Data(weaponRequirement[0])?[0]; + } + + /// + /// Generate a specific weapon to use, only use this if we aren't already using a weapon category requirement + /// + /// Generation data + /// Weapon to use + protected string? GenerateSpecificWeaponRequirement( + EliminationQuestGenerationData generationData + ) + { + var weaponRequirement = generationData.WeaponRequirementConfig.Draw(1, false); + var specificAllowedWeaponCategory = generationData.WeaponRequirementConfig.Data(weaponRequirement[0]); + + if (specificAllowedWeaponCategory?[0] is null) + { + logger.Error(localisationService.GetText("repeatable-elimination-specific-weapon-null")); + return null; + } + + var allowedWeapons = itemHelper.GetItemTplsOfBaseType(specificAllowedWeaponCategory[0]); + + return randomUtil.GetArrayValue(allowedWeapons); + } + /// /// Get a number of kills needed to complete elimination quest /// diff --git a/Libraries/SPTarkov.Server.Core/Models/Spt/Config/QuestConfig.cs b/Libraries/SPTarkov.Server.Core/Models/Spt/Config/QuestConfig.cs index d5db38d9..e291b869 100644 --- a/Libraries/SPTarkov.Server.Core/Models/Spt/Config/QuestConfig.cs +++ b/Libraries/SPTarkov.Server.Core/Models/Spt/Config/QuestConfig.cs @@ -598,16 +598,16 @@ public record EliminationConfig : BaseQuestConfig public required int MinPmcKills { get; set; } /// - /// Probability that a specific weapon requirement is chosen + /// Chance that a specific weapon requirement is chosen /// - [JsonPropertyName("weaponRequirementProb")] - public required double WeaponRequirementProbability { get; set; } + [JsonPropertyName("weaponRequirementChance")] + public required int WeaponRequirementChance { get; set; } /// - /// Probability that a weapon category requirement is chosen + /// Chance that a weapon category requirement is chosen /// - [JsonPropertyName("weaponCategoryRequirementProb")] - public required double WeaponCategoryRequirementProbability { get; set; } + [JsonPropertyName("weaponCategoryRequirementChance")] + public required int WeaponCategoryRequirementChance { get; set; } /// /// If a weapon category requirement is chosen, pick from these categories