From ac354bb54f156363982b0e9befa225ee54b2b43a Mon Sep 17 00:00:00 2001 From: Chomp Date: Fri, 17 Jan 2025 19:38:07 +0000 Subject: [PATCH] Partial implementation of `RepeatableQuestGenerator` --- Core/Generators/RepeatableQuestGenerator.cs | 289 +++++++++++++++++- .../RepeatableQuestRewardGenerator.cs | 21 ++ Core/Helpers/RepeatableQuestHelper.cs | 11 +- Core/Servers/Http/SptHttpListener.cs | 2 +- 4 files changed, 317 insertions(+), 6 deletions(-) create mode 100644 Core/Generators/RepeatableQuestRewardGenerator.cs diff --git a/Core/Generators/RepeatableQuestGenerator.cs b/Core/Generators/RepeatableQuestGenerator.cs index c3485b17..3176ff09 100644 --- a/Core/Generators/RepeatableQuestGenerator.cs +++ b/Core/Generators/RepeatableQuestGenerator.cs @@ -1,10 +1,17 @@ using Core.Annotations; using Core.Models.Eft.Common; using Core.Models.Eft.Common.Tables; +using Core.Models.Eft.Health; +using Core.Models.Eft.Hideout; +using Core.Models.Enums; using Core.Models.Spt.Config; using Core.Models.Spt.Repeatable; using Core.Models.Utils; using Core.Utils; +using static System.Runtime.InteropServices.JavaScript.JSType; +using System.Collections.Generic; +using Core.Helpers; +using Core.Services; namespace Core.Generators; @@ -13,13 +20,32 @@ public class RepeatableQuestGenerator { protected ISptLogger _logger; protected RandomUtil _randomUtil; + private readonly HashUtil _hashUtil; + private readonly MathUtil _mathUtil; + private readonly RepeatableQuestHelper _repeatableQuestHelper; + private readonly ItemHelper _itemHelper; + private readonly RepeatableQuestRewardGenerator _repeatableQuestRewardGenerator; + private readonly DatabaseService _databaseService; public RepeatableQuestGenerator( ISptLogger logger, - RandomUtil randomUtil) + RandomUtil randomUtil, + HashUtil hashUtil, + MathUtil mathUtil, + RepeatableQuestHelper repeatableQuestHelper, + ItemHelper itemHelper, + RepeatableQuestRewardGenerator repeatableQuestRewardGenerator, + DatabaseService databaseService + ) { _logger = logger; _randomUtil = randomUtil; + _hashUtil = hashUtil; + _mathUtil = mathUtil; + _repeatableQuestHelper = repeatableQuestHelper; + _itemHelper = itemHelper; + _repeatableQuestRewardGenerator = repeatableQuestRewardGenerator; + _databaseService = databaseService; } /// @@ -76,11 +102,268 @@ public class RepeatableQuestGenerator RepeatableQuestConfig repeatableConfig ) { - throw new NotImplementedException(); + 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; + + targetsConfig = targetsConfig.Where((x) => + (questTypePool.Pool.Elimination.Targets).Contains(x.key)); + if (targetsConfig.Count == 0 || targetsConfig.All((x) => x.Data.isBoss)) + { + // 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"); + return null; + } + + var targetKey = targetsConfig.Draw()[0]; + var targetDifficulty = 1 / targetsConfig.Probability(targetKey); + + var locations: string[] = questTypePool.Pool.Elimination.Targets[targetKey].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.includes("any") && + (eliminationConfig.specificLocationProb < Math.random() || locations.length <= 1) + ) + { + locationKey = "any"; + questTypePool.Pool.Elimination.Targets.Remove(targetKey); + } + else + { + locations = locations.Where((l) => l != "any"); + if (locations.length > 0) + { + locationKey = _randomUtil.DrawRandomFromList(locations)[0]; + questTypePool.Pool.Elimination.Targets[targetKey].locations = locations.Where( + (l) => l != locationKey); + if (questTypePool.Pool.Elimination.Targets[targetKey].locations.length == 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 = null; + var bodyPartDifficulty = 0; + if (eliminationConfig.BodyPartProb > Math.Random()) + { + // 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); + var 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); + foreach (var biClient in bodypartsConfig.Data(bi)) { + bodyPartsToClient.push(biClient); + } + } + bodyPartDifficulty = 1 / probability; + } + + // Draw a distance condition + var distance = null; + var distanceDifficulty = 0; + var isDistanceRequirementAllowed = !eliminationConfig.DistLocationBlacklist.Contains(locationKey); + + if (targetsConfig.Data(targetKey).isBoss) + { + // Get all boss spawn information + var bossSpawns = _databaseService.GetLocations().GetDictionary() + .Where((x) => "base" in x && "Id" in x.base) + .Select((x) => ({ Id: x.base.Id, BossSpawn: x.base.BossLocationSpawn })); + // filter for the current boss to spawn on map + var thisBossSpawns = bossSpawns.Select((x) => ({ Id: x.Id,BossSpawn: x.BossSpawn.Where((e) => e.BossName == targetKey)})).Where((x) => x.BossSpawn.length > 0); + // remove blacklisted locations + var allowedSpawns = thisBossSpawns.filter((x) => !eliminationConfig.distLocationBlacklist.includes(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.length > 0; + } + + if (eliminationConfig.DistProb > Math.Random() && isDistanceRequirementAllowed) + { + // Random distance with lower values more likely; simple distribution for starters... + distance = Math.Floor( + Math.Abs(Math.random() - Math.Random()) * (1 + eliminationConfig.MaxDist - eliminationConfig.MinDist) + + eliminationConfig.MinDist); + distance = Math.Ceiling(distance / 5) * 5; + distanceDifficulty = (maxDistDifficulty * distance) / eliminationConfig.MaxDist; + } + + string allowedWeaponsCategory; + if (eliminationConfig.WeaponCategoryRequirementProb > Math.Random()) + { + // Filter out close range weapons from far distance requirement + if (distance > 50) + { + weaponCategoryRequirementConfig = weaponCategoryRequirementConfig.Where((category) => + ["Shotgun", "Pistol"].Contains(category.key)); + } + else if (distance < 20) + { + // Filter out far range weapons from close distance requirement + weaponCategoryRequirementConfig = weaponCategoryRequirementConfig.Where((category) => + ["MarksmanRifle", "DMR"].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; + if (!allowedWeaponsCategory && eliminationConfig.WeaponRequirementProb > Math.Random()) + { + var weaponRequirement = weaponRequirementConfig.Draw(1, false); + var allowedWeaponsCategory = weaponRequirementConfig.Data(weaponRequirement[0])[0]; + var allowedWeapons = _itemHelper.GetItemTplsOfBaseType(allowedWeaponsCategory); + 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 / maxTargetDifficulty, + bodyPartDifficulty / maxBodyPartsDifficulty, + distanceDifficulty / maxDistDifficulty, + killDifficulty / maxKillDifficulty, + allowedWeaponsCategory || allowedWeapon ? 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") + { + availableForFinishCondition.Counter.Conditions.Add(GenerateEliminationLocation(locationsConfig[locationKey])); + } + availableForFinishCondition.Counter.Conditions.Add( + GenerateEliminationCondition( + targetKey, + 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 + * @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, + object targetsConfig , //ProbabilityObjectArray + EliminationConfig eliminationConfig) { + if (targetsConfig.Data(targetKey).isBoss) { + return _randomUtil.RandInt(eliminationConfig.MinBossKills, eliminationConfig.MaxBossKills + 1); + } + + if (targetsConfig.Data(targetKey).isPmc) { + return _randomUtil.RandInt(eliminationConfig.MinPmcKills, eliminationConfig.MaxPmcKills + 1); + } + + return _randomUtil.RandInt(eliminationConfig.MinKills, eliminationConfig.MaxKills + 1); + } + +protected double DifficultyWeighing( + int target, + int bodyPart, + int dist, + int kill, + int weaponRequirement) + { + return Math.Sqrt(Math.Sqrt(target) + bodyPart + dist + weaponRequirement) * kill; + } +} + /// - /// Get a number of kills neded to complete elimination quest + /// Get a number of kills needed to complete elimination quest /// /// Target type desired e.g. anyPmc/bossBully/Savage /// Config diff --git a/Core/Generators/RepeatableQuestRewardGenerator.cs b/Core/Generators/RepeatableQuestRewardGenerator.cs new file mode 100644 index 00000000..f0e70051 --- /dev/null +++ b/Core/Generators/RepeatableQuestRewardGenerator.cs @@ -0,0 +1,21 @@ +using Core.Annotations; +using Core.Models.Eft.Common.Tables; +using Core.Models.Spt.Config; + +namespace Core.Generators +{ + + [Injectable] + public class RepeatableQuestRewardGenerator + { + public QuestRewards GenerateReward(int pmcLevel, double min, string traderId, RepeatableQuestConfig repeatableConfig, EliminationConfig eliminationConfig) + { + throw new NotImplementedException(); + } + + public Dictionary> GetRewardableItems(RepeatableQuestConfig repeatableConfig, string traderId) + { + throw new NotImplementedException(); + } + } +} diff --git a/Core/Helpers/RepeatableQuestHelper.cs b/Core/Helpers/RepeatableQuestHelper.cs index 8288d43c..4a33fa99 100644 --- a/Core/Helpers/RepeatableQuestHelper.cs +++ b/Core/Helpers/RepeatableQuestHelper.cs @@ -21,9 +21,10 @@ public class RepeatableQuestHelper /// Level of PMC character /// Main repeatable config /// EliminationConfig - public EliminationConfig GetEliminationConfigByPmcLevel(int pmcLevel, RepeatableQuestConfig repeatableConfig) + public EliminationConfig? GetEliminationConfigByPmcLevel(int pmcLevel, RepeatableQuestConfig repeatableConfig) { - throw new NotImplementedException(); + return repeatableConfig.QuestConfig.Elimination.FirstOrDefault( + (x) => pmcLevel >= x.LevelRange.Min && pmcLevel <= x.LevelRange.Max); } public Dictionary> ProbabilityObjectArray(object configArrayInput) // TODO: ProbabilityObjectArray for return type , param type was List> @@ -33,6 +34,12 @@ public class RepeatableQuestHelper var x = new Dictionary>(); } + public int MaxProbability(int key) + { + _logger.Error("NOT IMPLEMENTED - MaxProbability"); + return key; + } + public class ProbabilityData { public int RelativeProbability { get; set; } diff --git a/Core/Servers/Http/SptHttpListener.cs b/Core/Servers/Http/SptHttpListener.cs index 86f53b93..4cd41357 100644 --- a/Core/Servers/Http/SptHttpListener.cs +++ b/Core/Servers/Http/SptHttpListener.cs @@ -23,7 +23,7 @@ public class SptHttpListener : IHttpListener protected readonly LocalisationService _localisationService; protected readonly JsonUtil _jsonUtil; public SptHttpListener( - HttpRouter httpRouter, // TODO: delay required + HttpRouter httpRouter, IEnumerable serializers, ISptLogger logger, ISptLogger requestsLogger,