From dc7378cd7077b1a603fa76e07c89b52c2ee27c32 Mon Sep 17 00:00:00 2001 From: Chomp Date: Thu, 23 Jan 2025 12:46:21 +0000 Subject: [PATCH] Repeatable Quest Implementation --- .../Controllers/RepeatableQuestController.cs | 85 ++-- .../Generators/BotEquipmentModGenerator.cs | 2 +- .../Generators/RepeatableQuestGenerator.cs | 379 +++++++++++------ .../RepeatableQuestRewardGenerator.cs | 390 ++++++++++++++---- Libraries/Core/Helpers/PresetHelper.cs | 6 +- .../Core/Helpers/RepeatableQuestHelper.cs | 6 +- .../Spt/Repeatable/QuestRewardValues.cs | 4 +- .../Models/Spt/Server/ExhaustableArray.cs | 49 --- .../Utils/Collections/ExhaustableArray.cs | 2 +- 9 files changed, 627 insertions(+), 296 deletions(-) delete mode 100644 Libraries/Core/Models/Spt/Server/ExhaustableArray.cs diff --git a/Libraries/Core/Controllers/RepeatableQuestController.cs b/Libraries/Core/Controllers/RepeatableQuestController.cs index 6cc3e2b6..47d4c5f0 100644 --- a/Libraries/Core/Controllers/RepeatableQuestController.cs +++ b/Libraries/Core/Controllers/RepeatableQuestController.cs @@ -1,19 +1,19 @@ -using SptCommon.Annotations; +using Core.Generators; +using Core.Helpers; using Core.Models.Eft.Common; using Core.Models.Eft.Common.Tables; using Core.Models.Eft.ItemEvent; using Core.Models.Eft.Quests; +using Core.Models.Enums; using Core.Models.Spt.Config; using Core.Models.Spt.Repeatable; -using Core.Generators; -using Core.Helpers; -using Core.Models.Enums; using Core.Models.Utils; using Core.Routers; using Core.Servers; using Core.Services; using Core.Utils; using Core.Utils.Cloners; +using SptCommon.Annotations; namespace Core.Controllers; @@ -39,7 +39,8 @@ public class RepeatableQuestController( { protected QuestConfig _questConfig = _configServer.GetConfig(); - public ItemEventRouterResponse ChangeRepeatableQuest(PmcData pmcData, RepeatableQuestChangeRequest info, string sessionId) + public ItemEventRouterResponse ChangeRepeatableQuest(PmcData pmcData, RepeatableQuestChangeRequest info, + string sessionId) { throw new NotImplementedException(); } @@ -108,7 +109,9 @@ public class RepeatableQuestController( lifeline++; if (lifeline > 10) { - _logger.Debug("We were stuck in repeatable quest generation. This should never happen. Please report"); + _logger.Debug( + "We were stuck in repeatable quest generation. This should never happen. Please report" + ); break; } @@ -133,13 +136,11 @@ public class RepeatableQuestController( // Create stupid redundant change requirements from quest data foreach (var quest in generatedRepeatables.ActiveQuests) - { generatedRepeatables.ChangeRequirement[quest.Id] = new ChangeRequirement { ChangeCost = quest.ChangeCost, - ChangeStandingCost = _randomUtil.GetArrayValue([0, 0.01]), // Randomise standing cost to replace + ChangeStandingCost = _randomUtil.GetArrayValue([0, 0.01]) // Randomise standing cost to replace }; - } // Reset free repeatable values in player profile to defaults generatedRepeatables.FreeChanges = repeatableConfig.FreeChanges; @@ -155,7 +156,7 @@ public class RepeatableQuestController( InactiveQuests = generatedRepeatables.InactiveQuests, ChangeRequirement = generatedRepeatables.ChangeRequirement, FreeChanges = generatedRepeatables.FreeChanges, - FreeChangesAvailable = generatedRepeatables.FreeChanges, + FreeChangesAvailable = generatedRepeatables.FreeChanges } ); } @@ -163,26 +164,26 @@ public class RepeatableQuestController( return returnData; } - private PmcDataRepeatableQuest GetRepeatableQuestSubTypeFromProfile(RepeatableQuestConfig repeatableConfig, PmcData pmcData) + private PmcDataRepeatableQuest GetRepeatableQuestSubTypeFromProfile(RepeatableQuestConfig repeatableConfig, + PmcData pmcData) { // Get from profile, add if missing var repeatableQuestDetails = pmcData.RepeatableQuests.FirstOrDefault( - (repeatable) => repeatable.Name == repeatableConfig.Name + repeatable => repeatable.Name == repeatableConfig.Name ); if (repeatableQuestDetails is null) { // Not in profile, generate var hasAccess = _profileHelper.HasAccessToRepeatableFreeRefreshSystem(pmcData); - repeatableQuestDetails = new PmcDataRepeatableQuest() + repeatableQuestDetails = new PmcDataRepeatableQuest { Id = repeatableConfig.Id, Name = repeatableConfig.Name, ActiveQuests = [], InactiveQuests = [], EndTime = 0, - ChangeRequirement = { }, FreeChanges = hasAccess ? repeatableConfig.FreeChanges : 0, - FreeChangesAvailable = hasAccess ? repeatableConfig.FreeChangesAvailable : 0, + FreeChangesAvailable = hasAccess ? repeatableConfig.FreeChangesAvailable : 0 }; // Add base object that holds repeatable data to profile @@ -229,9 +230,9 @@ public class RepeatableQuestController( */ private bool PlayerHasDailyScavQuestsUnlocked(PmcData pmcData) { - return ( - pmcData?.Hideout?.Areas?.FirstOrDefault((hideoutArea) => hideoutArea.Type == HideoutAreas.INTEL_CENTER)?.Level >= 1 - ); + return pmcData?.Hideout?.Areas?.FirstOrDefault(hideoutArea => hideoutArea.Type == HideoutAreas.INTEL_CENTER) + ?.Level >= + 1; } private void ProcessExpiredQuests(PmcDataRepeatableQuest generatedRepeatables, PmcData pmcData) @@ -239,7 +240,7 @@ public class RepeatableQuestController( var questsToKeep = new List(); foreach (var activeQuest in generatedRepeatables.ActiveQuests) { - var questStatusInProfile = pmcData.Quests.FirstOrDefault((quest) => quest.QId == activeQuest.Id); + var questStatusInProfile = pmcData.Quests.FirstOrDefault(quest => quest.QId == activeQuest.Id); if (questStatusInProfile is null) { continue; @@ -249,7 +250,9 @@ public class RepeatableQuestController( if (questStatusInProfile.Status == QuestStatusEnum.AvailableForFinish) { questsToKeep.Add(activeQuest); - _logger.Debug($"Keeping repeatable quest: {activeQuest.Id} in activeQuests since it is available to hand in"); + _logger.Debug( + $"Keeping repeatable quest: {activeQuest.Id} in activeQuests since it is available to hand in" + ); continue; } @@ -258,7 +261,7 @@ public class RepeatableQuestController( _profileFixerService.RemoveDanglingConditionCounters(pmcData); // Remove expired quest from pmc.quest array - pmcData.Quests = pmcData.Quests.Where((quest) => quest.QId != activeQuest.Id).ToList(); + pmcData.Quests = pmcData.Quests.Where(quest => quest.QId != activeQuest.Id).ToList(); // Store in inactive array generatedRepeatables.InactiveQuests.Add(activeQuest); @@ -276,27 +279,25 @@ public class RepeatableQuestController( // Populate Exploration and Pickup quest locations foreach (var (location, value) in locations) - { if (location != ELocationName.any) { questPool.Pool.Exploration.Locations[location] = value; questPool.Pool.Pickup.Locations[location] = value; } - } // Add "any" to pickup quest pool questPool.Pool.Pickup.Locations[ELocationName.any] = ["any"]; var eliminationConfig = _repeatableQuestHelper.GetEliminationConfigByPmcLevel(pmcLevel.Value, repeatableConfig); - var targetsConfig = _repeatableQuestHelper.ProbabilityObjectArray(eliminationConfig.Targets); + var targetsConfig = + _repeatableQuestHelper.ProbabilityObjectArray(eliminationConfig.Targets); // Populate Elimination quest targets and their locations foreach (var targetKvP in targetsConfig) - { // Target is boss if (targetKvP.Data.IsBoss.GetValueOrDefault(false)) { - questPool.Pool.Elimination.Targets[targetKvP.Key] = new TargetLocation{ Locations = ["any"] }; + questPool.Pool.Elimination.Targets[targetKvP.Key] = new TargetLocation { Locations = ["any"] }; } else { @@ -305,12 +306,14 @@ public class RepeatableQuestController( var allowedLocations = targetKvP.Key == "Savage" - ? possibleLocations.Where((location) => location != ELocationName.laboratory) // Exclude labs for Savage targets. - : possibleLocations; + ? possibleLocations.Where( + location => location != ELocationName.laboratory + ) // Exclude labs for Savage targets. + : possibleLocations; - questPool.Pool.Elimination.Targets[targetKvP.Key] = new TargetLocation{ Locations = allowedLocations.Select(x => x.ToString()).ToList() }; + questPool.Pool.Elimination.Targets[targetKvP.Key] = new TargetLocation + { Locations = allowedLocations.Select(x => x.ToString()).ToList() }; } - } return questPool; } @@ -334,11 +337,12 @@ public class RepeatableQuestController( { Locations = new Dictionary>() } - }, + } }; } - private Dictionary> GetAllowedLocationsForPmcLevel(Dictionary> locations, int pmcLevel) + private Dictionary> GetAllowedLocationsForPmcLevel( + Dictionary> locations, int pmcLevel) { var allowedLocation = new Dictionary>(); @@ -346,12 +350,10 @@ public class RepeatableQuestController( { var locationNames = new List(); foreach (var locationName in value) - { if (IsPmcLevelAllowedOnLocation(locationName, pmcLevel)) { locationNames.Add(locationName); } - } if (locationNames.Count > 0) { @@ -363,11 +365,11 @@ public class RepeatableQuestController( } /** - * Return true if the given pmcLevel is allowed on the given location - * @param location The location name to check - * @param pmcLevel The level of the pmc - * @returns True if the given pmc level is allowed to access the given location - */ + * Return true if the given pmcLevel is allowed on the given location + * @param location The location name to check + * @param pmcLevel The level of the pmc + * @returns True if the given pmc level is allowed to access the given location + */ protected bool IsPmcLevelAllowedOnLocation(string location, int pmcLevel) { // All PMC levels are allowed for 'any' location requirement @@ -386,7 +388,7 @@ public class RepeatableQuestController( } /// - /// Get count of repeatable quests profile should have access to + /// Get count of repeatable quests profile should have access to /// /// /// Player profile @@ -400,7 +402,8 @@ public class RepeatableQuestController( } // Add elite bonus to daily quests - if (repeatableConfig.Name.ToLower() == "daily" && _profileHelper.HasEliteSkillLevel(SkillTypes.Charisma, pmcData) + if (repeatableConfig.Name.ToLower() == "daily" && + _profileHelper.HasEliteSkillLevel(SkillTypes.Charisma, pmcData) ) { // Elite charisma skill gives extra daily quest(s) diff --git a/Libraries/Core/Generators/BotEquipmentModGenerator.cs b/Libraries/Core/Generators/BotEquipmentModGenerator.cs index d70472d0..67cdb9a7 100644 --- a/Libraries/Core/Generators/BotEquipmentModGenerator.cs +++ b/Libraries/Core/Generators/BotEquipmentModGenerator.cs @@ -6,11 +6,11 @@ using Core.Models.Spt.Bots; using Core.Models.Spt.Config; using Core.Models.Utils; using Core.Helpers; -using Core.Models.Spt.Server; using Core.Servers; using Core.Services; using Core.Utils; using Core.Utils.Cloners; +using Core.Utils.Collections; namespace Core.Generators; diff --git a/Libraries/Core/Generators/RepeatableQuestGenerator.cs b/Libraries/Core/Generators/RepeatableQuestGenerator.cs index f163e9fe..dd02980d 100644 --- a/Libraries/Core/Generators/RepeatableQuestGenerator.cs +++ b/Libraries/Core/Generators/RepeatableQuestGenerator.cs @@ -1,18 +1,19 @@ -using SptCommon.Annotations; +using Core.Helpers; 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; +using Core.Utils.Cloners; using Core.Utils.Collections; +using Core.Utils.Json; +using SptCommon.Annotations; using SptCommon.Extensions; using BodyPart = Core.Models.Spt.Config.BodyPart; -using Core.Utils.Cloners; namespace Core.Generators; @@ -35,8 +36,10 @@ public class RepeatableQuestGenerator( 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 + /// 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 @@ -74,13 +77,16 @@ public class RepeatableQuestGenerator( } /// - /// Generate a randomised Elimination quest + /// 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 + /// + /// 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, @@ -94,12 +100,18 @@ public class RepeatableQuestGenerator( var eliminationConfig = _repeatableQuestHelper.GetEliminationConfigByPmcLevel(pmcLevel, repeatableConfig); var locationsConfig = repeatableConfig.Locations; - var targetsConfig = _repeatableQuestHelper.ProbabilityObjectArray(eliminationConfig.Targets); - var bodyPartsConfig = _repeatableQuestHelper.ProbabilityObjectArray>(eliminationConfig.BodyParts); + var targetsConfig = + _repeatableQuestHelper.ProbabilityObjectArray(eliminationConfig.Targets); + var bodyPartsConfig = + _repeatableQuestHelper.ProbabilityObjectArray>(eliminationConfig.BodyParts); var weaponCategoryRequirementConfig = - _repeatableQuestHelper.ProbabilityObjectArray>(eliminationConfig.WeaponCategoryRequirements); + _repeatableQuestHelper.ProbabilityObjectArray>( + eliminationConfig.WeaponCategoryRequirements + ); var weaponRequirementConfig = - _repeatableQuestHelper.ProbabilityObjectArray>(eliminationConfig.WeaponRequirements); + _repeatableQuestHelper.ProbabilityObjectArray>( + eliminationConfig.WeaponRequirements + ); // the difficulty of the quest varies in difficulty depending on the condition // possible conditions are @@ -122,7 +134,8 @@ public class RepeatableQuestGenerator( // 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 + 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(); @@ -134,14 +147,14 @@ public class RepeatableQuestGenerator( var maxKillDifficulty = eliminationConfig.MaxKills; var targetPool = questTypePool.Pool.Elimination; - targetsConfig = targetsConfig.Filter((x) => questTypePool.Pool.Elimination.Targets.ContainsKey(x.Key)); + targetsConfig = targetsConfig.Filter(x => questTypePool.Pool.Elimination.Targets.ContainsKey(x.Key)); - if (targetsConfig.Count == 0 || targetsConfig.All((x) => x.Data.IsBoss.GetValueOrDefault(false))) + 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(); + questTypePool.Types = questTypePool.Types.Where(t => t != "Elimination").ToList(); return null; } @@ -154,7 +167,8 @@ public class RepeatableQuestGenerator( // 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) + if (locations.Contains("any") && + (eliminationConfig.SpecificLocationProbability < rand.Next() || locations.Count <= 1) ) { locationKey = "any"; @@ -166,11 +180,13 @@ public class RepeatableQuestGenerator( if (locations.Count > 0) { locationKey = _randomUtil.DrawRandomFromList(locations).FirstOrDefault(); - questTypePool.Pool.Elimination.Targets.GetByJsonProp(targetKey).Locations = locations.Where( - (l) => l != locationKey + questTypePool.Pool.Elimination.Targets.GetByJsonProp(targetKey).Locations = locations + .Where( + l => l != locationKey ) .ToList(); - if (questTypePool.Pool.Elimination.Targets.GetByJsonProp(targetKey).Locations.Count == 0) + if (questTypePool.Pool.Elimination.Targets.GetByJsonProp(targetKey).Locations.Count == + 0) { questTypePool.Pool.Elimination.Targets.Remove(targetKey); } @@ -197,10 +213,7 @@ public class RepeatableQuestGenerator( { // 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); - } + foreach (var biClient in bodyPartsConfig.Data(bi)) bodyPartsToClient.Add(biClient); } bodyPartDifficulty = 1 / probability; @@ -218,7 +231,7 @@ public class RepeatableQuestGenerator( .GetDictionary() .Select(x => x.Value) .Where(x => x.Base?.Id != null) - .Select(x => (new { x.Base.Id, BossSpawn = x.Base.BossLocationSpawn })); + .Select(x => new { x.Base.Id, BossSpawn = x.Base.BossLocationSpawn }); // filter for the current boss to spawn on map var thisBossSpawns = bossSpawns .Select( @@ -229,9 +242,9 @@ public class RepeatableQuestGenerator( .Where(e => e.BossName == targetKey) } ) - .Where((x) => x.BossSpawn.Count() > 0); + .Where(x => x.BossSpawn.Count() > 0); // remove blacklisted locations - var allowedSpawns = thisBossSpawns.Where((x) => !eliminationConfig.DistLocationBlacklist.Contains(x.Id)); + 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; } @@ -240,7 +253,8 @@ public class RepeatableQuestGenerator( { // 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) + + (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; @@ -254,21 +268,23 @@ public class RepeatableQuestGenerator( if (distance > 50) { List weaponTypes = ["Shotgun", "Pistol"]; - weaponCategoryRequirementConfig = (ProbabilityObjectArray>)weaponCategoryRequirementConfig - .Where( - (category) => weaponTypes - .Contains(category.Key) - ); + 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) - ); + weaponCategoryRequirementConfig = + (ProbabilityObjectArray>)weaponCategoryRequirementConfig + .Where( + category => weaponTypes + .Contains(category.Key) + ); } // Pick a weighted weapon category @@ -324,7 +340,9 @@ public class RepeatableQuestGenerator( if (locationKey != "any") { Enum.TryParse(typeof(ELocationName), locationKey, true, out var locationId); - availableForFinishCondition.Counter.Conditions.Add(GenerateEliminationLocation(locationsConfig[(ELocationName)locationId])); + availableForFinishCondition.Counter.Conditions.Add( + GenerateEliminationLocation(locationsConfig[(ELocationName)locationId]) + ); } availableForFinishCondition.Counter.Conditions.Add( @@ -387,8 +405,9 @@ public class RepeatableQuestGenerator( } /// - /// 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. + /// 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 @@ -404,7 +423,7 @@ public class RepeatableQuestGenerator( } /// - /// Create kill condition for an elimination quest + /// Create kill condition for an elimination quest /// /// Bot type target of elimination quest e.g. "AnyPmc", "Savage" /// Body parts player must hit @@ -428,7 +447,7 @@ public class RepeatableQuestGenerator( Value = 1, ResetOnSessionEnd = false, EnemyHealthEffects = [], - Daytime = new DaytimeCounter() { From = 0, To = 0 }, + Daytime = new DaytimeCounter { From = 0, To = 0 }, ConditionType = "Kills" }; @@ -467,11 +486,14 @@ public class RepeatableQuestGenerator( } /// - /// Generates a valid Completion quest + /// 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 + /// + /// 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, @@ -489,55 +511,72 @@ public class RepeatableQuestGenerator( // 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); + 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)); + (double)(_mathUtil.Interp1(pmcLevel, levelsConfig, roublesConfig) * multi) + ); roublesBudget = Math.Max(roublesBudget, 5000d); var itemSelection = possibleItemsToRetrievePool.Where( - (x) => _itemHelper.GetItemPrice(x.Id) < roublesBudget).ToList(); + 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)) { + 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(); + .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)) { + 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) ); + .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(); + 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")); + if (!itemSelection.Any()) + { + _logger.Error( + _localisationService.GetText( + "repeatable-completion_quest_whitelist_too_small_or_blacklist_too_restrictive" + ) + ); return null; } @@ -549,67 +588,89 @@ public class RepeatableQuestGenerator( var distinctItemsToRetrieveCount = _randomUtil.GetInt(1, completionConfig.UniqueItemCount.Value); var chosenRequirementItemsTpls = new List(); var usedItemIndexes = new HashSet(); - for (var i = 0; i < distinctItemsToRetrieveCount; i++) { + 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)) { + for (var j = 0; j < _maxRandomNumberAttempts; j++) + if (usedItemIndexes.Contains(chosenItemIndex)) + { chosenItemIndex = _randomUtil.RandInt(itemSelection.Count()); - } else { + } + else + { found = true; break; } - } - if (!found) { - _logger.Error(_localisationService.GetText("repeatable-no_reward_item_found_in_price_range", new { - minPrice = 0, - roublesBudget = roublesBudget })); + if (!found) + { + _logger.Error( + _localisationService.GetText( + "repeatable-no_reward_item_found_in_price_range", + new + { + minPrice = 0, 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)) { + if (_itemHelper.IsOfBaseclass(itemSelected.Id, BaseClasses.AMMO)) + { // Prevent multiple ammo requirements from being picked - if (isAmmo > 0 && isAmmo < _maxRandomNumberAttempts) { + if (isAmmo > 0 && isAmmo < _maxRandomNumberAttempts) + { isAmmo++; i--; continue; } + isAmmo++; - minValue = (double)completionConfig.MinimumRequestedBulletAmount.Value; - maxValue = (double)completionConfig.MaximumRequestedBulletAmount.Value; + minValue = completionConfig.MinimumRequestedBulletAmount.Value; + maxValue = 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 (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) { + 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()) { + itemSelection = itemSelection.Where(dbItem => _itemHelper.GetItemPrice(dbItem.Id) < roublesBudget) + .ToList(); + if (!itemSelection.Any()) + { break; } - } else { + } + else + { break; } } @@ -620,32 +681,69 @@ public class RepeatableQuestGenerator( traderId, repeatableConfig, completionConfig, - chosenRequirementItemsTpls); + 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) + /// 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(); + var minDurability = 0; + var onlyFoundInRaid = true; + if ( + _itemHelper.IsOfBaseclass(itemTpl, BaseClasses.WEAPON) || + _itemHelper.IsOfBaseclass(itemTpl, BaseClasses.ARMOR) + ) + { + minDurability = _randomUtil.GetArrayValue([60, 80]); + } + + // By default all collected items must be FiR, except dog tags + if (_itemHelper.IsDogtag(itemTpl)) + { + onlyFoundInRaid = false; + } + + return new QuestCondition + { + Id = _hashUtil.Generate(), + Index = 0, + ParentId = "", + DynamicLocale = true, + VisibilityConditions = [], + GlobalQuestCounterId = "", + Target = new ListOrT([], itemTpl), + Value = value, + MinDurability = minDurability, + MaxDurability = 100, + DogtagLevel = 0, + OnlyFoundInRaid = onlyFoundInRaid, + IsEncoded = false, + ConditionType = "HandoverItem" + }; } /// - /// Generates a valid Exploration quest + /// 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 + /// + /// 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, @@ -661,7 +759,7 @@ public class RepeatableQuestGenerator( 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(); + questTypePool.Types = questTypePool.Types.Where(t => t != "Exploration").ToList(); return null; } @@ -681,17 +779,19 @@ public class RepeatableQuestGenerator( var quest = GenerateRepeatableTemplate("Exploration", traderId, repeatableConfig.Side, sessionId); - var exitStatusCondition = new QuestConditionCounterCondition{ + var exitStatusCondition = new QuestConditionCounterCondition + { Id = _hashUtil.Generate(), DynamicLocale = true, Status = ["Survived"], - ConditionType = "ExitStatus", + ConditionType = "ExitStatus" }; - var locationCondition = new QuestConditionCounterCondition{ + var locationCondition = new QuestConditionCounterCondition + { Id = _hashUtil.Generate(), DynamicLocale = true, Target = locationTarget, - ConditionType = "Location", + ConditionType = "Location" }; quest.Conditions.AvailableForFinish[0].Counter.Id = _hashUtil.Generate(); @@ -706,13 +806,17 @@ public class RepeatableQuestGenerator( 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(); + 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(); + exit => + exit.PassageRequirement is not null || + repeatableConfig.QuestConfig.Exploration.SpecificExits.PassageRequirementWhitelist.Contains( + "PassageRequirement" + ) + ) + .ToList(); if (possibleExits.Count == 0) { @@ -721,7 +825,7 @@ public class RepeatableQuestGenerator( else { // Choose one of the exits we filtered above - var chosenExit = _randomUtil.DrawRandomFromList(possibleExits, 1)[0]; + var chosenExit = _randomUtil.DrawRandomFromList(possibleExits)[0]; // Create a quest condition to leave raid via chosen exit var exitCondition = GenerateExplorationExitCondition(chosenExit); @@ -737,13 +841,14 @@ public class RepeatableQuestGenerator( difficulty, traderId, repeatableConfig, - explorationConfig); + explorationConfig + ); return quest; } /// - /// Filter a maps exits to just those for the desired side + /// Filter a maps exits to just those for the desired side /// /// Map id (e.g. factory4_day) /// Scav/Pmc @@ -752,7 +857,7 @@ public class RepeatableQuestGenerator( { var mapExtracts = _databaseService.GetLocation(locationKey.ToLower()).AllExtracts; - return mapExtracts.Where((exit) => exit.Side == playerSide).ToList(); + return mapExtracts.Where(exit => exit.Side == playerSide).ToList(); } protected RepeatableQuest GeneratePickupQuest( @@ -762,11 +867,48 @@ public class RepeatableQuestGenerator( QuestTypePool questTypePool, RepeatableQuestConfig repeatableConfig) { - throw new NotImplementedException(); + 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); + 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) + /// Convert a location into an quest code can read (e.g. factory4_day into 55f2d3fd4bdc2d5f408b4567) /// /// e.g factory4_day /// guid @@ -776,14 +918,15 @@ public class RepeatableQuestGenerator( } /// - /// Exploration repeatable quests can specify a required extraction point. - /// This method creates the according object which will be appended to the conditions list + /// 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 { + return new QuestConditionCounterCondition + { Id = _hashUtil.Generate(), DynamicLocale = true, ExitName = exit.Name, @@ -792,14 +935,17 @@ public class RepeatableQuestGenerator( } /// - /// 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 + /// 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) + /// + /// 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, @@ -822,6 +968,7 @@ public class RepeatableQuestGenerator( questData = _databaseService.GetTemplates().RepeatableQuests.Templates.Pickup; break; } + var questClone = _cloner.Clone(questData); questClone.Id = _hashUtil.Generate(); questClone.TraderId = traderId; diff --git a/Libraries/Core/Generators/RepeatableQuestRewardGenerator.cs b/Libraries/Core/Generators/RepeatableQuestRewardGenerator.cs index f31b76c4..879606c7 100644 --- a/Libraries/Core/Generators/RepeatableQuestRewardGenerator.cs +++ b/Libraries/Core/Generators/RepeatableQuestRewardGenerator.cs @@ -1,5 +1,5 @@ -using SptCommon.Annotations; using Core.Helpers; +using Core.Models.Eft.Common; using Core.Models.Eft.Common.Tables; using Core.Models.Enums; using Core.Models.Spt.Config; @@ -9,8 +9,8 @@ using Core.Servers; using Core.Services; using Core.Utils; using Core.Utils.Cloners; -using Core.Models.Eft.Common; -using Core.Models.Spt.Server; +using Core.Utils.Collections; +using SptCommon.Annotations; namespace Core.Generators; @@ -42,12 +42,12 @@ public class RepeatableQuestRewardGenerator( * - Items * - Trader Reputation * - Skill level experience - * + * * The reward is dependent on the player level as given by the wiki. The exact mapping of pmcLevel to * experience / money / items / trader reputation can be defined in QuestConfig.js - * + * * There's also a random variation of the reward the spread of which can be also defined in the config - * + * * Additionally, a scaling factor w.r.t. quest difficulty going from 0.2...1 can be used * @param pmcLevel Level of player reward is being generated for * @param difficulty Reward scaling factor from 0.2 to 1 @@ -81,7 +81,7 @@ public class RepeatableQuestRewardGenerator( if (rewardParams.RewardXP > 0) { rewards.Success.Add( - new() + new Reward { Id = _hashUtil.Generate(), Unknown = false, @@ -105,9 +105,10 @@ public class RepeatableQuestRewardGenerator( // Add preset weapon to reward if checks pass var traderWhitelistDetails = repeatableConfig.TraderWhitelist.FirstOrDefault( - (traderWhitelist) => traderWhitelist.TraderId == traderId + traderWhitelist => traderWhitelist.TraderId == traderId ); - if (traderWhitelistDetails?.RewardCanBeWeapon ?? false && _randomUtil.GetChance100(traderWhitelistDetails.WeaponRewardChancePercent ?? 0) + if (traderWhitelistDetails?.RewardCanBeWeapon ?? + (false && _randomUtil.GetChance100(traderWhitelistDetails.WeaponRewardChancePercent ?? 0)) ) { var chosenWeapon = GetRandomWeaponPresetWithinBudget(itemRewardBudget.Value, rewardIndex); @@ -126,7 +127,7 @@ public class RepeatableQuestRewardGenerator( { // Filter reward pool of items from blacklist, only use if there's at least 1 item remaining var filteredRewardItemPool = inBudgetRewardItemPool.Where( - (item) => !rewardTplBlacklist.Contains(item.Id) + item => !rewardTplBlacklist.Contains(item.Id) ); if (filteredRewardItemPool.Count() > 0) { @@ -142,8 +143,8 @@ public class RepeatableQuestRewardGenerator( { var itemsToReward = GetRewardableItemsFromPoolWithinBudget( inBudgetRewardItemPool, - rewardParams.RewardNumItems, - itemRewardBudget, + rewardParams.RewardNumItems.Value, + itemRewardBudget.Value, repeatableConfig ); @@ -217,40 +218,61 @@ public class RepeatableQuestRewardGenerator( _logger.Warning(_localisationService.GetText("repeatable-difficulty_was_nan")); } - return new() + return new QuestRewardValues { SkillPointReward = _mathUtil.Interp1(pmcLevel, levelsConfig, skillPointRewardConfig), SkillRewardChance = _mathUtil.Interp1(pmcLevel, levelsConfig, skillRewardChanceConfig), - RewardReputation = GetRewardRep(effectiveDifficulty, pmcLevel, levelsConfig, reputationConfig, rewardSpreadConfig), + RewardReputation = GetRewardRep( + effectiveDifficulty, + pmcLevel, + levelsConfig, + reputationConfig, + rewardSpreadConfig + ), RewardNumItems = GetRewardNumItems(pmcLevel, levelsConfig, itemsConfig), - RewardRoubles = GetRewardRoubles(effectiveDifficulty, pmcLevel, levelsConfig, roublesConfig, rewardSpreadConfig), - GpCoinRewardCount = GetGpCoinRewardCount(effectiveDifficulty, pmcLevel, levelsConfig, gpCoinConfig, rewardSpreadConfig), - RewardXP = GetRewardXp(effectiveDifficulty, pmcLevel, levelsConfig, xpConfig, rewardSpreadConfig), + RewardRoubles = GetRewardRoubles( + effectiveDifficulty, + pmcLevel, + levelsConfig, + roublesConfig, + rewardSpreadConfig + ), + GpCoinRewardCount = GetGpCoinRewardCount( + effectiveDifficulty, + pmcLevel, + levelsConfig, + gpCoinConfig, + rewardSpreadConfig + ), + RewardXP = GetRewardXp(effectiveDifficulty, pmcLevel, levelsConfig, xpConfig, rewardSpreadConfig) }; } - private double GetRewardXp(double? effectiveDifficulty, int pmcLevel, List? levelsConfig, List? xpConfig, double? rewardSpreadConfig) + private double GetRewardXp(double? effectiveDifficulty, int pmcLevel, List? levelsConfig, + List? xpConfig, double? rewardSpreadConfig) { return Math.Floor( - (effectiveDifficulty * - _mathUtil.Interp1(pmcLevel, levelsConfig, xpConfig) * - _randomUtil.GetFloat((float)(1 - rewardSpreadConfig), (float)(1 + rewardSpreadConfig))) ?? + effectiveDifficulty * + _mathUtil.Interp1(pmcLevel, levelsConfig, xpConfig) * + _randomUtil.GetFloat((float)(1 - rewardSpreadConfig), (float)(1 + rewardSpreadConfig)) ?? 0 ); } - private double GetGpCoinRewardCount(double? effectiveDifficulty, int pmcLevel, List? levelsConfig, List? gpCoinConfig, + private double GetGpCoinRewardCount(double? effectiveDifficulty, int pmcLevel, List? levelsConfig, + List? gpCoinConfig, double? rewardSpreadConfig) { return Math.Ceiling( - (effectiveDifficulty * - _mathUtil.Interp1(pmcLevel, levelsConfig, gpCoinConfig) * - _randomUtil.GetFloat((float)(1 - rewardSpreadConfig), (float)(1 + rewardSpreadConfig))) ?? + effectiveDifficulty * + _mathUtil.Interp1(pmcLevel, levelsConfig, gpCoinConfig) * + _randomUtil.GetFloat((float)(1 - rewardSpreadConfig), (float)(1 + rewardSpreadConfig)) ?? 0 ); } - private double GetRewardRep(double? effectiveDifficulty, int pmcLevel, List? levelsConfig, List? reputationConfig, + private double GetRewardRep(double? effectiveDifficulty, int pmcLevel, List? levelsConfig, + List? reputationConfig, double? rewardSpreadConfig) { return Math.Round( @@ -263,29 +285,175 @@ public class RepeatableQuestRewardGenerator( 100; } - private double GetRewardNumItems(int pmcLevel, List? levelsConfig, List? itemsConfig) + private int GetRewardNumItems(int pmcLevel, List? levelsConfig, List? itemsConfig) { return _randomUtil.RandInt(1, (int)Math.Round(_mathUtil.Interp1(pmcLevel, levelsConfig, itemsConfig) ?? 0) + 1); } - private double GetRewardRoubles(double? effectiveDifficulty, int pmcLevel, List? levelsConfig, List? roublesConfig, + private double GetRewardRoubles(double? effectiveDifficulty, int pmcLevel, List? levelsConfig, + List? roublesConfig, double? rewardSpreadConfig) { return Math.Floor( - (effectiveDifficulty * - _mathUtil.Interp1(pmcLevel, levelsConfig, roublesConfig) * - _randomUtil.GetFloat((float)(1 - rewardSpreadConfig), (float)(1 + rewardSpreadConfig))) ?? + effectiveDifficulty * + _mathUtil.Interp1(pmcLevel, levelsConfig, roublesConfig) * + _randomUtil.GetFloat((float)(1 - rewardSpreadConfig), (float)(1 + rewardSpreadConfig)) ?? 0 ); } - private List> GetRewardableItemsFromPoolWithinBudget(List inBudgetRewardItemPool, - object rewardNumItems, double? itemRewardBudget, RepeatableQuestConfig repeatableConfig) + private Dictionary GetRewardableItemsFromPoolWithinBudget(List itemPool, + int maxItemCount, double itemRewardBudget, RepeatableQuestConfig repeatableConfig) { - throw new NotImplementedException(); + var itemsToReturn = new Dictionary(); + var exhausableItemPool = new ExhaustableArray(itemPool, _randomUtil, _cloner); + + for (var i = 0; i < maxItemCount; i++) + { + // Default stack size to 1 + var rewardItemStackCount = 1; + + // Get a random item + var chosenItemFromPool = exhausableItemPool.GetRandomValue(); + if (!exhausableItemPool.HasValues()) + { + break; + } + + // Handle edge case - ammo + if (_itemHelper.IsOfBaseclass(chosenItemFromPool.Id, BaseClasses.AMMO)) + { + // Don't reward ammo that stacks to less than what's allowed in config + if (chosenItemFromPool.Properties.StackMaxSize < repeatableConfig.RewardAmmoStackMinSize) + { + i--; + continue; + } + + // Choose smallest value between budget, fitting size and stack max + rewardItemStackCount = CalculateAmmoStackSizeThatFitsBudget( + chosenItemFromPool, + itemRewardBudget, + maxItemCount + ); + } + + // 25% chance to double, triple or quadruple reward stack + // (Only occurs when item is stackable and not weapon, armor or ammo) + if (CanIncreaseRewardItemStackSize(chosenItemFromPool, 70000, 25)) + { + rewardItemStackCount = GetRandomisedRewardItemStackSizeByPrice(chosenItemFromPool); + } + + itemsToReturn.Add(chosenItemFromPool, rewardItemStackCount); + + var itemCost = _presetHelper.GetDefaultPresetOrItemPrice(chosenItemFromPool.Id); + var calculatedItemRewardBudget = itemRewardBudget - rewardItemStackCount * itemCost; + _logger.Debug($"Added item: {chosenItemFromPool.Id} with price: {rewardItemStackCount * itemCost}"); + + // If we still have budget narrow down possible items + if (calculatedItemRewardBudget > 0) + { + // Filter possible reward items to only items with a price below the remaining budget + exhausableItemPool = new ExhaustableArray( + FilterRewardPoolWithinBudget(itemPool, calculatedItemRewardBudget, 0), + _randomUtil, + _cloner + ); + + if (!exhausableItemPool.HasValues()) + { + _logger.Debug($"Reward pool empty with: {calculatedItemRewardBudget} roubles of budget remaining"); + } + } + + // No budget for more items, end loop + break; + } + + return itemsToReturn; } - private List ChooseRewardItemsWithinBudget(RepeatableQuestConfig repeatableConfig, double? roublesBudget, string traderId) + /** + * Get a count of cartridges that fits the rouble budget amount provided + * e.g. how many M80s for 50,000 roubles + * @param itemSelected Cartridge + * @param roublesBudget Rouble budget + * @param rewardNumItems + * @returns Count that fits budget (min 1) + */ + private int CalculateAmmoStackSizeThatFitsBudget(TemplateItem itemSelected, double roublesBudget, + int rewardNumItems) + { + // Calculate budget per reward item + var stackRoubleBudget = roublesBudget / rewardNumItems; + + var singleCartridgePrice = _handbookHelper.GetTemplatePrice(itemSelected.Id); + + // Get a stack size of ammo that fits rouble budget + var stackSizeThatFitsBudget = Math.Round(stackRoubleBudget / singleCartridgePrice.Value); + + // Get itemDbs max stack size for ammo - don't go above 100 (some mods mess around with stack sizes) + var stackMaxCount = Math.Min(itemSelected.Properties.StackMaxSize.Value, 100); + + // Ensure stack size is at least 1 + is no larger than the max possible stack size + return (int)Math.Max(1, Math.Min(stackSizeThatFitsBudget, stackMaxCount)); + } + + private bool CanIncreaseRewardItemStackSize(TemplateItem item, int maxRoublePriceToStack, + int? randomChanceToPass = null) + { + var isEligibleForStackSizeIncrease = + _presetHelper.GetDefaultPresetOrItemPrice(item.Id) < maxRoublePriceToStack && + !_itemHelper.IsOfBaseclasses( + item.Id, + [ + BaseClasses.WEAPON, + BaseClasses.ARMORED_EQUIPMENT, + BaseClasses.AMMO + ] + ) && + !_itemHelper.ItemRequiresSoftInserts(item.Id); + + return isEligibleForStackSizeIncrease && _randomUtil.GetChance100(randomChanceToPass ?? 100); + } + + /** + * Get a randomised number a reward items stack size should be based on its handbook price + * @param item Reward item to get stack size for + * @returns matching stack size for the passed in items price + */ + private int GetRandomisedRewardItemStackSizeByPrice(TemplateItem item) + { + var rewardItemPrice = _presetHelper.GetDefaultPresetOrItemPrice(item.Id); + + // Define price tiers and corresponding stack size options + var priceTiers = new List?>> + { + new(3000, [2, 3, 4]), + new(10000, [2, 3]), + new(int.MaxValue, [2, 3, 4]) // Default for prices 10001+ RUB + }; + + // Find the appropriate price tier and return a random stack size from its options + var tier = priceTiers.FirstOrDefault(tier => rewardItemPrice < tier.Item1); + if (tier is null) + { + return 4; // Default to 2 if no tier matches + } + + return _randomUtil.GetArrayValue(tier.Item2); + } + + /** + * Select a number of items that have a collective value of the passed in parameter + * @param repeatableConfig Config + * @param roublesBudget Total value of items to return + * @param traderId Id of the trader who will give player reward + * @returns Array of reward items that fit budget + */ + private List ChooseRewardItemsWithinBudget(RepeatableQuestConfig repeatableConfig, + double? roublesBudget, string traderId) { // First filter for type and baseclass to avoid lookup in handbook for non-available items var rewardableItemPool = GetRewardableItems(repeatableConfig, traderId); @@ -294,29 +462,47 @@ public class RepeatableQuestRewardGenerator( var rewardableItemPoolWithinBudget = FilterRewardPoolWithinBudget( rewardableItemPool, roublesBudget.Value, - minPrice); + minPrice + ); if (rewardableItemPoolWithinBudget.Count == 0) { - _logger.Warning(_localisationService.GetText("repeatable-no_reward_item_found_in_price_range", new { - minPrice = minPrice, - roublesBudget = roublesBudget })); + _logger.Warning( + _localisationService.GetText( + "repeatable-no_reward_item_found_in_price_range", + new + { + minPrice, roublesBudget + } + ) + ); // In case we don't find any items in the price range rewardableItemPoolWithinBudget = rewardableItemPool - .Where((x) => _itemHelper.GetItemPrice(x.Id) < roublesBudget) + .Where(x => _itemHelper.GetItemPrice(x.Id) < roublesBudget) .ToList(); } return rewardableItemPoolWithinBudget; } - private List FilterRewardPoolWithinBudget(List rewardItems, double roublesBudget, double minPrice) + /** + * @param rewardItems List of reward items to filter + * @param roublesBudget The budget remaining for rewards + * @param minPrice The minimum priced item to include + * @returns True if any items remain in `rewardItems`, false otherwise + */ + private List FilterRewardPoolWithinBudget(List rewardItems, double roublesBudget, + double minPrice) { - return rewardItems.Where((item) => { - var itemPrice = _presetHelper.GetDefaultPresetOrItemPrice(item.Id); - return itemPrice < roublesBudget && itemPrice > minPrice; - }).ToList(); + return rewardItems.Where( + item => + { + var itemPrice = _presetHelper.GetDefaultPresetOrItemPrice(item.Id); + return itemPrice < roublesBudget && itemPrice > minPrice; + } + ) + .ToList(); } private KeyValuePair? GetRandomWeaponPresetWithinBudget(double roublesBudget, int rewardIndex) @@ -325,7 +511,8 @@ public class RepeatableQuestRewardGenerator( var defaultPresetPool = new ExhaustableArray( _presetHelper.GetDefaultWeaponPresets().Values.ToList(), _randomUtil, - _cloner); + _cloner + ); while (defaultPresetPool.HasValues()) { @@ -336,16 +523,19 @@ public class RepeatableQuestRewardGenerator( } // Gather all tpls so we can get prices of them - var tpls = randomPreset.Items.Select((item) => item.Template).ToList(); + var tpls = randomPreset.Items.Select(item => item.Template).ToList(); // Does preset items fit our budget var presetPrice = _itemHelper.GetItemAndChildrenPrice(tpls); if (presetPrice <= roublesBudget) { - _logger.Debug("Added weapon: ${ tpls[0]}with price: ${ presetPrice}"); + _logger.Debug($"Added weapon: {tpls[0]}with price: {presetPrice}"); var chosenPreset = _cloner.Clone(randomPreset); - return new KeyValuePair(GeneratePresetReward(chosenPreset.Encyclopedia, 1, rewardIndex, chosenPreset.Items), presetPrice); + return new KeyValuePair( + GeneratePresetReward(chosenPreset.Encyclopedia, 1, rewardIndex, chosenPreset.Items), + presetPrice + ); } } @@ -354,7 +544,7 @@ public class RepeatableQuestRewardGenerator( /** * Helper to create a reward item structured as required by the client - * + * * @param {string} tpl ItemId of the rewarded item * @param {integer} count Amount of items to give * @param {integer} index All rewards will be appended to a list, for unknown reasons the client wants the index @@ -364,25 +554,26 @@ public class RepeatableQuestRewardGenerator( protected Reward GeneratePresetReward(string tpl, int count, int index, List? preset, bool foundInRaid = true) { var id = _hashUtil.Generate(); - var questRewardItem = new Reward{ + var questRewardItem = new Reward + { Id = _hashUtil.Generate(), Unknown = false, - GameMode =[], - AvailableInGameEditions =[], + GameMode = [], + AvailableInGameEditions = [], Index = index, - Target =id, + Target = id, Value = count, IsEncoded = false, - FindInRaid =foundInRaid, - Type =RewardType.Item, - Items =[], + FindInRaid = foundInRaid, + Type = RewardType.Item, + Items = [] }; // Get presets root item - var rootItem = preset.FirstOrDefault((item) => item.Template == tpl); + var rootItem = preset.FirstOrDefault(item => item.Template == tpl); if (rootItem is null) { - _logger.Warning($"Root item of preset: ${ tpl} not found"); + _logger.Warning($"Root item of preset: {tpl} not found"); } if (rootItem.Upd is not null) @@ -396,10 +587,20 @@ public class RepeatableQuestRewardGenerator( return questRewardItem; } - private Reward GenerateItemReward(string tpl, double count, int index, bool foundInRaid = true) + /** + * Helper to create a reward item structured as required by the client + * + * @param {string} tpl ItemId of the rewarded item + * @param {integer} count Amount of items to give + * @param {integer} index All rewards will be appended to a list, for unknown reasons the client wants the index + * @param preset Optional array of preset items + * @returns {object} Object of "Reward"-item-type + */ + protected Reward GenerateItemReward(string tpl, double count, int index, bool foundInRaid = true) { var id = _hashUtil.Generate(); - var questRewardItem = new Reward{ + var questRewardItem = new Reward + { Id = _hashUtil.Generate(), Unknown = false, GameMode = [], @@ -410,10 +611,12 @@ public class RepeatableQuestRewardGenerator( IsEncoded = false, FindInRaid = foundInRaid, Type = RewardType.Item, - Items = [], + Items = [] }; - var rootItem = new Item { Id = id, Template = tpl, Upd = new Upd { StackObjectsCount = count, SpawnedInSession = foundInRaid } + var rootItem = new Item + { + Id = id, Template = tpl, Upd = new Upd { StackObjectsCount = count, SpawnedInSession = foundInRaid } }; questRewardItem.Items = [rootItem]; @@ -428,13 +631,23 @@ public class RepeatableQuestRewardGenerator( // Convert reward amount to Euros if necessary var rewardAmountToGivePlayer = - currency == Money.EUROS ? _handbookHelper.FromRUB(rewardRoubles, Money.EUROS) : rewardRoubles; + currency == Money.EUROS ? _handbookHelper.FromRUB(rewardRoubles, Money.EUROS) : rewardRoubles; // Get chosen currency + amount and return return GenerateItemReward(currency, rewardAmountToGivePlayer, rewardIndex, false); } + /** + * Picks rewardable items from items.json + * This means they must: + * - Fit into the inventory + * - Shouldn't be keys + * - Have a price greater than 0 + * @param repeatableQuestConfig Config file + * @param traderId Id of trader who will give reward to player + * @returns List of rewardable items [[_tpl, itemTemplate],...] + */ public List GetRewardableItems(RepeatableQuestConfig repeatableQuestConfig, string traderId) { // Get an array of seasonal items that should not be shown right now as seasonal event is not active @@ -443,26 +656,43 @@ public class RepeatableQuestRewardGenerator( // Check for specific baseclasses 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 // those are not in the game yet (e.g. AGS grenade launcher) - return _databaseService.GetItems().Values.Where((itemTemplate => { - // Base "Item" item has no parent, ignore it - if (itemTemplate.Parent == "") - { - return false; - } + return _databaseService.GetItems() + .Values.Where( + itemTemplate => + { + // Base "Item" item has no parent, ignore it + if (itemTemplate.Parent == "") + { + return false; + } - if (seasonalItems.Contains(itemTemplate.Id)) - { - return false; - } + if (seasonalItems.Contains(itemTemplate.Id)) + { + return false; + } - var traderWhitelist = repeatableQuestConfig.TraderWhitelist.FirstOrDefault( - (trader) => trader.TraderId == traderId); + var traderWhitelist = repeatableQuestConfig.TraderWhitelist.FirstOrDefault( + trader => trader.TraderId == traderId + ); - return IsValidRewardItem(itemTemplate.Id, repeatableQuestConfig, traderWhitelist?.RewardBaseWhitelist); - })).ToList(); + return IsValidRewardItem( + itemTemplate.Id, + repeatableQuestConfig, + traderWhitelist?.RewardBaseWhitelist + ); + } + ) + .ToList(); } - private bool IsValidRewardItem(string tpl, RepeatableQuestConfig repeatableQuestConfig, List? itemBaseWhitelist = null) + /** + * Checks if an id is a valid item. Valid meaning that it's an item that may be a reward + * or content of bot loot. Items that are tested as valid may be in a player backpack or stash. + * @param {string} tpl template id of item to check + * @returns True if item is valid reward + */ + private bool IsValidRewardItem(string tpl, RepeatableQuestConfig repeatableQuestConfig, + List? itemBaseWhitelist = null) { // Return early if not valid item to give as reward if (!_itemHelper.isValidItem(tpl)) @@ -482,7 +712,7 @@ public class RepeatableQuestRewardGenerator( } // Item has blacklisted base types - if (_itemHelper.IsOfBaseclasses(tpl, repeatableQuestConfig.RewardBaseTypeBlacklist )) + if (_itemHelper.IsOfBaseclasses(tpl, repeatableQuestConfig.RewardBaseTypeBlacklist)) { return false; } diff --git a/Libraries/Core/Helpers/PresetHelper.cs b/Libraries/Core/Helpers/PresetHelper.cs index 7ba243c2..133a22f3 100644 --- a/Libraries/Core/Helpers/PresetHelper.cs +++ b/Libraries/Core/Helpers/PresetHelper.cs @@ -15,7 +15,7 @@ public class PresetHelper( { protected Dictionary> _lookup = new(); protected Dictionary _defaultEquipmentPresets; - protected Dictionary _defaultWeaponPresets; + protected Dictionary? _defaultWeaponPresets; public void HydratePresetStore(Dictionary> input) { @@ -40,10 +40,10 @@ public class PresetHelper( */ public Dictionary GetDefaultWeaponPresets() { - if (_defaultWeaponPresets == null) + if (_defaultWeaponPresets is null) { var tempPresets = _databaseService.GetGlobals().ItemPresets; - tempPresets = tempPresets.Where( + _defaultWeaponPresets = tempPresets.Where( p => p.Value.Encyclopedia != null && _itemHelper.IsOfBaseclass(p.Value.Encyclopedia, BaseClasses.WEAPON) diff --git a/Libraries/Core/Helpers/RepeatableQuestHelper.cs b/Libraries/Core/Helpers/RepeatableQuestHelper.cs index b8b9fb5c..b04f6f1b 100644 --- a/Libraries/Core/Helpers/RepeatableQuestHelper.cs +++ b/Libraries/Core/Helpers/RepeatableQuestHelper.cs @@ -1,9 +1,9 @@ -using SptCommon.Annotations; using Core.Models.Spt.Config; using Core.Models.Utils; using Core.Utils; using Core.Utils.Cloners; using Core.Utils.Collections; +using SptCommon.Annotations; namespace Core.Helpers; @@ -15,7 +15,7 @@ public class RepeatableQuestHelper( ) { /// - /// Get the relevant elimination config based on the current players PMC level + /// Get the relevant elimination config based on the current players PMC level /// /// Level of PMC character /// Main repeatable config @@ -23,7 +23,7 @@ public class RepeatableQuestHelper( public EliminationConfig? GetEliminationConfigByPmcLevel(int pmcLevel, RepeatableQuestConfig repeatableConfig) { return repeatableConfig.QuestConfig.Elimination.FirstOrDefault( - (x) => pmcLevel >= x.LevelRange.Min && pmcLevel <= x.LevelRange.Max + x => pmcLevel >= x.LevelRange.Min && pmcLevel <= x.LevelRange.Max ); } diff --git a/Libraries/Core/Models/Spt/Repeatable/QuestRewardValues.cs b/Libraries/Core/Models/Spt/Repeatable/QuestRewardValues.cs index 9f3b7f5c..2aad9ee1 100644 --- a/Libraries/Core/Models/Spt/Repeatable/QuestRewardValues.cs +++ b/Libraries/Core/Models/Spt/Repeatable/QuestRewardValues.cs @@ -1,4 +1,4 @@ -using System.Text.Json.Serialization; +using System.Text.Json.Serialization; namespace Core.Models.Spt.Repeatable; @@ -14,7 +14,7 @@ public record QuestRewardValues public double? RewardReputation { get; set; } [JsonPropertyName("rewardNumItems")] - public double? RewardNumItems { get; set; } + public int? RewardNumItems { get; set; } [JsonPropertyName("rewardRoubles")] public double? RewardRoubles { get; set; } diff --git a/Libraries/Core/Models/Spt/Server/ExhaustableArray.cs b/Libraries/Core/Models/Spt/Server/ExhaustableArray.cs deleted file mode 100644 index 8313fb30..00000000 --- a/Libraries/Core/Models/Spt/Server/ExhaustableArray.cs +++ /dev/null @@ -1,49 +0,0 @@ -using Core.Utils; -using Core.Utils.Cloners; - -namespace Core.Models.Spt.Server; - -public record ExhaustableArray -{ - private readonly RandomUtil _randomUtil; - private readonly ICloner _cloner; - private List pool; - - public ExhaustableArray(List itemPool, RandomUtil randomUtil, ICloner cloner) - { - _randomUtil = randomUtil; - _cloner = cloner; - this.pool = _cloner.Clone(itemPool); - } - - public T GetRandomValue() - { - if (pool == null || pool.Count == 0) - { - return default; - } - - var index = _randomUtil.GetInt(0, pool.Count - 1); - T toReturn = _cloner.Clone(pool[index]); - pool.RemoveAt(index); - - return toReturn; - } - - public T GetFirstValue() - { - if (pool == null || pool.Count == 0) - { - return default; - } - - T toReturn = _cloner.Clone(pool[0]); - pool.RemoveAt(0); - return toReturn; - } - - public bool HasValues() - { - return pool?.Count > 0; - } -} diff --git a/Libraries/Core/Utils/Collections/ExhaustableArray.cs b/Libraries/Core/Utils/Collections/ExhaustableArray.cs index 7dc7e5b8..b897d057 100644 --- a/Libraries/Core/Utils/Collections/ExhaustableArray.cs +++ b/Libraries/Core/Utils/Collections/ExhaustableArray.cs @@ -2,7 +2,7 @@ using Core.Utils.Cloners; namespace Core.Utils.Collections; -public class ExhaustableArray : IExhaustableArray +public record ExhaustableArray : IExhaustableArray { private LinkedList? pool; private RandomUtil _randomUtil;