using SPTarkov.DI.Annotations; using SPTarkov.Server.Core.Extensions; using SPTarkov.Server.Core.Generators.RepeatableQuestGeneration; using SPTarkov.Server.Core.Helpers; using SPTarkov.Server.Core.Models.Common; using SPTarkov.Server.Core.Models.Eft.Common; using SPTarkov.Server.Core.Models.Eft.Common.Tables; using SPTarkov.Server.Core.Models.Eft.ItemEvent; using SPTarkov.Server.Core.Models.Eft.Profile; using SPTarkov.Server.Core.Models.Eft.Quests; using SPTarkov.Server.Core.Models.Enums; using SPTarkov.Server.Core.Models.Spt.Config; using SPTarkov.Server.Core.Models.Spt.Quests; using SPTarkov.Server.Core.Models.Spt.Repeatable; using SPTarkov.Server.Core.Models.Utils; using SPTarkov.Server.Core.Routers; using SPTarkov.Server.Core.Servers; using SPTarkov.Server.Core.Services; using SPTarkov.Server.Core.Utils; using SPTarkov.Server.Core.Utils.Cloners; using SPTarkov.Server.Core.Utils.Collections; using LogLevel = SPTarkov.Server.Core.Models.Spt.Logging.LogLevel; namespace SPTarkov.Server.Core.Controllers; [Injectable] public class RepeatableQuestController( ISptLogger logger, EliminationQuestGenerator eliminationQuestGenerator, CompletionQuestGenerator completionQuestGenerator, ExplorationQuestGenerator explorationQuestGenerator, PickupQuestGenerator pickupQuestGenerator, TimeUtil timeUtil, RandomUtil randomUtil, HttpResponseUtil httpResponseUtil, ProfileHelper profileHelper, ProfileFixerService profileFixerService, ServerLocalisationService serverLocalisationService, EventOutputHolder eventOutputHolder, PaymentService paymentService, RepeatableQuestHelper repeatableQuestHelper, QuestHelper questHelper, DatabaseService databaseService, ConfigServer configServer, ICloner cloner ) { protected static readonly List _questTypes = ["PickUp", "Exploration", "Elimination"]; protected readonly QuestConfig QuestConfig = configServer.GetConfig(); /// /// Handle the client accepting a repeatable quest and starting it /// Send starting rewards if any to player and /// Send start notification if any to player /// /// Players PMC profile /// Repeatable quest accepted /// Session/Player id /// ItemEventRouterResponse public ItemEventRouterResponse AcceptRepeatableQuest( PmcData pmcData, AcceptQuestRequestData acceptedQuest, string sessionID ) { // Create and store quest status object inside player profile var newRepeatableQuest = questHelper.GetQuestReadyForProfile( pmcData, QuestStatusEnum.Started, acceptedQuest ); pmcData.Quests.Add(newRepeatableQuest); // Look for the generated quest cache in profile.RepeatableQuests var repeatableQuestProfile = GetRepeatableQuestFromProfile(pmcData, acceptedQuest.QuestId); if (repeatableQuestProfile is null) { logger.Error( serverLocalisationService.GetText( "repeatable-accepted_repeatable_quest_not_found_in_active_quests", acceptedQuest.QuestId ) ); throw new Exception( serverLocalisationService.GetText("repeatable-unable_to_accept_quest_see_log") ); } // Some scav quests need to be added to scav profile for them to show up in-raid if ( repeatableQuestProfile.Side == "Scav" && _questTypes.Contains(repeatableQuestProfile.Type.ToString()) ) { var fullProfile = profileHelper.GetFullProfile(sessionID); fullProfile.CharacterData.ScavData.Quests ??= []; fullProfile.CharacterData.ScavData.Quests.Add(newRepeatableQuest); } var response = eventOutputHolder.GetOutput(sessionID); return response; } /// /// Handle RepeatableQuestChange event /// /// Players PMC profile /// Change quest request /// Session/Player id /// public ItemEventRouterResponse ChangeRepeatableQuest( PmcData pmcData, RepeatableQuestChangeRequest changeRequest, string sessionID ) { var output = eventOutputHolder.GetOutput(sessionID); var fullProfile = profileHelper.GetFullProfile(sessionID); // Check for existing quest in (daily/weekly/scav arrays) var repeatables = GetRepeatableById(changeRequest.QuestId, pmcData); var questToReplace = repeatables.Quest; var repeatablesOfTypeInProfile = repeatables.RepeatableType; if (repeatables.RepeatableType is null || repeatables.Quest is null) { // Unable to find quest being replaced var message = serverLocalisationService.GetText( "quest-unable_to_find_repeatable_to_replace" ); logger.Error(message); return httpResponseUtil.AppendErrorToOutput(output, message); } // Subtype name of quest - daily/weekly/scav var repeatableTypeLower = repeatablesOfTypeInProfile.Name.ToLowerInvariant(); // Save for later standing loss calculation var replacedQuestTraderId = questToReplace.TraderId; // Update active quests to exclude the quest we're replacing repeatablesOfTypeInProfile.ActiveQuests = repeatablesOfTypeInProfile .ActiveQuests.Where(quest => quest.Id != changeRequest.QuestId) .ToList(); // Save for later cost calculations var previousChangeRequirement = cloner.Clone( repeatablesOfTypeInProfile.ChangeRequirement[changeRequest.QuestId] ); // Delete the replaced quest change requirement data as we're going to add new data below repeatablesOfTypeInProfile.ChangeRequirement.Remove(changeRequest.QuestId); // Get config for this repeatable subtype (daily/weekly/scav) var repeatableConfig = QuestConfig.RepeatableQuests.FirstOrDefault(config => config.Name == repeatablesOfTypeInProfile.Name ); // If the configuration dictates to replace with the same quest type, adjust the available quest types if (repeatableConfig?.KeepDailyQuestTypeOnReplacement is not null) { repeatableConfig.Types = [questToReplace.Type.ToString()]; } // Generate meta-data for what type/level range of quests can be generated for player var allowedQuestTypes = GenerateQuestPool( repeatableConfig, pmcData.Info.Level.GetValueOrDefault(1) ); var newRepeatableQuest = AttemptToGenerateRepeatableQuest( sessionID, pmcData, allowedQuestTypes, repeatableConfig ); if (newRepeatableQuest is null) { // Unable to find quest being replaced var message = $"Unable to generate repeatable quest of type: {repeatableTypeLower} to replace trader: {replacedQuestTraderId} quest: {changeRequest.QuestId}"; logger.Error(message); return httpResponseUtil.AppendErrorToOutput(output, message); } // Add newly generated quest to daily/weekly/scav type array newRepeatableQuest.Side = Enum.GetName(repeatableConfig.Side); repeatablesOfTypeInProfile.ActiveQuests.Add(newRepeatableQuest); if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug( $"Removing: {repeatableConfig.Name} quest: {questToReplace.Id} from trader: {questToReplace.TraderId} as its been replaced" ); } // Remove the replaced quest from profile RemoveQuestFromProfile(fullProfile, questToReplace.Id); // Delete the replaced quest change requirement from profile CleanUpRepeatableChangeRequirements(repeatablesOfTypeInProfile, questToReplace.Id); // Add replacement quests change requirement data to profile repeatablesOfTypeInProfile.ChangeRequirement[newRepeatableQuest.Id] = new ChangeRequirement { ChangeCost = newRepeatableQuest.ChangeCost, ChangeStandingCost = randomUtil.GetArrayValue(repeatableConfig.StandingChangeCost), }; // Check if we should charge player for replacing quest var isFreeToReplace = UseFreeRefreshIfAvailable( fullProfile, repeatablesOfTypeInProfile, repeatableTypeLower ); if (!isFreeToReplace) { // Reduce standing with trader for not doing their quest var traderOfReplacedQuest = pmcData.TradersInfo[replacedQuestTraderId]; traderOfReplacedQuest.Standing -= previousChangeRequirement.ChangeStandingCost; var charismaBonus = pmcData.GetSkillFromProfile(SkillTypes.Charisma)?.Progress ?? 0; foreach (var cost in previousChangeRequirement.ChangeCost) { // Not free, Charge player + apply charisma bonus to cost of replacement cost.Count = (int) Math.Truncate( cost.Count.Value * (1 - (Math.Truncate(charismaBonus / 100) * 0.001)) ); paymentService.AddPaymentToOutput( pmcData, cost.TemplateId, cost.Count.Value, sessionID, output ); if (output.Warnings.Count > 0) { return output; } } } // Clone data before we send it to client var repeatableToChangeClone = cloner.Clone(repeatablesOfTypeInProfile); // Purge inactive repeatables repeatableToChangeClone.InactiveQuests = []; // Update client output with new repeatable output.ProfileChanges[sessionID].RepeatableQuests ??= []; output.ProfileChanges[sessionID].RepeatableQuests.Add(repeatableToChangeClone); return output; } /// /// Look for an accepted quest inside player profile, return quest that matches /// /// Players PMC profile /// Quest id to return /// RepeatableQuest protected RepeatableQuest? GetRepeatableQuestFromProfile(PmcData pmcData, string questId) { foreach (var repeatableQuest in pmcData.RepeatableQuests) { var matchingQuest = repeatableQuest.ActiveQuests?.FirstOrDefault(x => x.Id == questId); if (matchingQuest is null) { // No daily/weekly/scav repeatable, skip over to next subtype continue; } if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug($"Accepted repeatable quest: {questId} from: {repeatableQuest.Name}"); } matchingQuest.SptRepatableGroupName = repeatableQuest.Name; return matchingQuest; } return null; } /// /// Some accounts have access to free repeatable quest refreshes /// Track the usage of them inside players profile /// /// Full player profile /// Can be daily / weekly / scav repeatable /// Subtype of repeatable quest: daily / weekly / scav /// Is the repeatable being replaced for free protected bool UseFreeRefreshIfAvailable( SptProfile? fullProfile, PmcDataRepeatableQuest repeatableSubType, string repeatableTypeName ) { // No free refreshes, exit early if (repeatableSubType.FreeChangesAvailable <= 0) { // Reset counter to 0 repeatableSubType.FreeChangesAvailable = 0; return false; } // Only certain game versions have access to free refreshes var hasAccessToFreeRefreshSystem = profileHelper.HasAccessToRepeatableFreeRefreshSystem( fullProfile.CharacterData.PmcData ); // If the player has access and available refreshes: if (hasAccessToFreeRefreshSystem) { // Initialize/retrieve free refresh count for the desired subtype: daily/weekly fullProfile.SptData.FreeRepeatableRefreshUsedCount ??= new Dictionary(); var repeatableRefreshCounts = fullProfile.SptData.FreeRepeatableRefreshUsedCount; repeatableRefreshCounts.TryAdd(repeatableTypeName, 0); // Set to 0 if undefined // Increment the used count and decrement the available count. repeatableRefreshCounts[repeatableTypeName]++; repeatableSubType.FreeChangesAvailable--; return true; } return false; } /// /// Clean up the repeatables `changeRequirement` dictionary of expired data /// /// repeatables that have the replaced and new quest /// Id of the replaced quest protected void CleanUpRepeatableChangeRequirements( PmcDataRepeatableQuest repeatablesOfTypeInProfile, string replacedQuestId ) { if (repeatablesOfTypeInProfile.ActiveQuests.Count == 1) // Only one repeatable quest being replaced (e.g. scav_daily), remove everything ready for new quest requirement to be added // Will assist in cleanup of existing profiles data { repeatablesOfTypeInProfile.ChangeRequirement.Clear(); return; } // Multiple active quests of this type (e.g. daily or weekly) are active, just remove the single replaced quest repeatablesOfTypeInProfile.ChangeRequirement.Remove(replacedQuestId); } /// /// Generate a repeatable quest /// /// Session/Player id /// Players PMC profile /// What type/level range of quests can be generated for player /// Config for the quest type to generate /// protected RepeatableQuest? AttemptToGenerateRepeatableQuest( string sessionId, PmcData pmcData, QuestTypePool questTypePool, RepeatableQuestConfig repeatableConfig ) { const int maxAttempts = 10; RepeatableQuest? newRepeatableQuest = null; var attempts = 0; while (attempts < maxAttempts && questTypePool.Types.Count > 0) { newRepeatableQuest = PickAndGenerateRandomRepeatableQuest( sessionId, pmcData.Info.Level.Value, pmcData.TradersInfo, questTypePool, repeatableConfig ); if (newRepeatableQuest is not null) // Successfully generated a quest, exit loop { break; } attempts++; } if (attempts > maxAttempts) { logger.Error( serverLocalisationService.GetText( "quest-repeatable_generation_failed_please_report", attempts ) ); } return newRepeatableQuest; } /// /// This method is called by /GetClientRepeatableQuests/ and creates one element of quest type format (see /// assets/database/templates/repeatableQuests.json). /// It randomly draws a quest type (currently Elimination, Completion or Exploration) as well as a trader who is /// providing the quest /// /// Session id /// Player's level for requested items and reward generation /// Players trader standing/rep levels /// Possible quest types pool /// Repeatable quest config /// RepeatableQuest public RepeatableQuest? PickAndGenerateRandomRepeatableQuest( string sessionId, int pmcLevel, Dictionary pmcTraderInfo, QuestTypePool questTypePool, RepeatableQuestConfig repeatableConfig ) { var questType = randomUtil.DrawRandomFromList(questTypePool.Types).First(); // Get traders from whitelist and filter by quest type availability var traders = repeatableConfig .TraderWhitelist.Where(whitelist => whitelist.QuestTypes.Contains(questType)) .Select(x => x.TraderId) // filter out locked traders .Where(mongoId => pmcTraderInfo[mongoId].Unlocked.GetValueOrDefault(false)) .ToList(); var traderId = randomUtil.DrawRandomFromList(traders).FirstOrDefault(); if (traderId.IsEmpty()) { logger.Error( serverLocalisationService.GetText("repeatable-unable_to_find_trader_in_pool") ); return null; } if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug($"Generating operation task type: {questType} for {traderId}"); } return questType switch { "Elimination" => eliminationQuestGenerator.Generate( sessionId, pmcLevel, traderId, questTypePool, repeatableConfig ), "Completion" => completionQuestGenerator.Generate( sessionId, pmcLevel, traderId, questTypePool, repeatableConfig ), "Exploration" => explorationQuestGenerator.Generate( sessionId, pmcLevel, traderId, questTypePool, repeatableConfig ), "Pickup" => pickupQuestGenerator.Generate( sessionId, pmcLevel, traderId, questTypePool, repeatableConfig ), _ => null, }; } /// /// Remove the provided quest from pmc and scav character profiles /// /// Profile to remove quest from /// Quest id to remove from profile protected void RemoveQuestFromProfile(SptProfile fullProfile, string questToReplaceId) { // Find quest we're replacing in pmc profile quests array and remove it questHelper.FindAndRemoveQuestFromArrayIfExists( questToReplaceId, fullProfile.CharacterData.PmcData.Quests ); // Look for and remove quest we're replacing in scav profile too if (fullProfile.CharacterData.ScavData is not null) { questHelper.FindAndRemoveQuestFromArrayIfExists( questToReplaceId, fullProfile.CharacterData.ScavData.Quests ); } } /// /// Find a repeatable (daily/weekly/scav) from a players profile by its id /// /// Id of quest to find /// Profile that contains quests to look through /// protected GetRepeatableByIdResult? GetRepeatableById(string questId, PmcData pmcData) { foreach (var repeatablesInProfile in pmcData.RepeatableQuests) { // Check for existing quest in (daily/weekly/scav arrays) var questToReplace = repeatablesInProfile.ActiveQuests?.FirstOrDefault(repeatable => repeatable.Id == questId ); if (questToReplace is null) // Not found, skip to next repeatable subtype { continue; } return new GetRepeatableByIdResult { Quest = questToReplace, RepeatableType = repeatablesInProfile, }; } return null; } /// /// Handle client/repeatableQuests/activityPeriods /// Returns an array of objects in the format of repeatable quests to the client. /// repeatableQuestObject = { /// *id: Unique Id, /// name: "Daily", /// endTime: the time when the quests expire /// activeQuests: currently available quests in an array. Each element of quest type format(see assets/ database / templates / repeatableQuests.json). /// inactiveQuests: the quests which were previously active(required by client to fail them if they are not completed) /// } /// The method checks if the player level requirement for repeatable quests(e.g.daily lvl5, weekly lvl15) is met and if the previously active quests /// are still valid.This ischecked by endTime persisted in profile accordning to the resetTime configured for each repeatable kind(daily, weekly) /// in QuestCondig.js /// If the condition is met, new repeatableQuests are created, old quests(which are persisted in the profile.RepeatableQuests[i].activeQuests) are /// moved to profile.RepeatableQuests[i].inactiveQuests.This memory is required to get rid of old repeatable quest data in the profile, otherwise /// they'll litter the profile's Quests field. /// (if the are on "Succeed" but not "Completed" we keep them, to allow the player to complete them and get the rewards) /// The new quests generated are again persisted in profile.RepeatableQuests /// /// Session/Player id /// Array of repeatable quests public List GetClientRepeatableQuests(string sessionID) { var returnData = new List(); var fullProfile = profileHelper.GetFullProfile(sessionID); var pmcData = fullProfile.CharacterData.PmcData; var currentTime = timeUtil.GetTimeStamp(); // Daily / weekly / Daily_Savage foreach (var repeatableConfig in QuestConfig.RepeatableQuests) { // Get daily/weekly data from profile, add empty object if missing var generatedRepeatables = GetRepeatableQuestSubTypeFromProfile( repeatableConfig, pmcData ); var repeatableTypeLower = repeatableConfig.Name.ToLowerInvariant(); var canAccessRepeatables = CanProfileAccessRepeatableQuests(repeatableConfig, pmcData); if (!canAccessRepeatables) // Don't send any repeatables, even existing ones { continue; } // Existing repeatables are still valid, add to return data and move to next sub-type if (currentTime < generatedRepeatables.EndTime - 1) { returnData.Add(generatedRepeatables); if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug($"[Quest Check] {repeatableTypeLower} quests are still valid."); } continue; } // Current time is past expiry time // Set endtime to be now + new duration generatedRepeatables.EndTime = currentTime + repeatableConfig.ResetTime; generatedRepeatables.InactiveQuests = []; if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug($"Generating new {repeatableTypeLower}"); } // Put old quests to inactive (this is required since only then the client makes them fail due to non-completion) // Also need to push them to the "inactiveQuests" list since we need to remove them from offraidData.profile.Quests // after a raid (the client seems to keep quests internally and we want to get rid of old repeatable quests) // and remove them from the PMC's Quests and RepeatableQuests[i].activeQuests ProcessExpiredQuests(generatedRepeatables, pmcData); // Create dynamic quest pool to avoid generating duplicates var questTypePool = GenerateQuestPool( repeatableConfig, pmcData.Info.Level.GetValueOrDefault(1) ); // Add repeatable quests of this loops sub-type (daily/weekly) for (var i = 0; i < GetQuestCount(repeatableConfig, fullProfile); i++) { RepeatableQuest? quest = null; var lifeline = 0; while (quest?.Id == null && questTypePool.Types.Count > 0) { quest = PickAndGenerateRandomRepeatableQuest( sessionID, pmcData.Info.Level ?? 0, pmcData.TradersInfo, questTypePool, repeatableConfig ); lifeline++; if (lifeline > 10) { logger.Error( "We were stuck in repeatable quest generation. This should never happen. Please report" ); break; } } // check if there are no more quest types available if (questTypePool.Types.Count == 0) { break; } quest.Side = Enum.GetName(repeatableConfig.Side); generatedRepeatables.ActiveQuests.Add(quest); } // Nullguard fullProfile.SptData.FreeRepeatableRefreshUsedCount ??= new Dictionary(); // Reset players free quest count for this repeatable sub-type as we're generating new repeatables for this group (daily/weekly) fullProfile.SptData.FreeRepeatableRefreshUsedCount[repeatableTypeLower] = 0; // Create stupid redundant change requirements from quest data generatedRepeatables.ChangeRequirement = new Dictionary(); foreach (var quest in generatedRepeatables.ActiveQuests) { generatedRepeatables.ChangeRequirement.TryAdd( quest.Id, new ChangeRequirement { ChangeCost = quest.ChangeCost, ChangeStandingCost = randomUtil.GetArrayValue( repeatableConfig.StandingChangeCost ), // Randomise standing loss to replace } ); } // Reset free repeatable values in player profile to defaults generatedRepeatables.FreeChangesAvailable = generatedRepeatables.FreeChanges; returnData.Add( new PmcDataRepeatableQuest { Id = repeatableConfig.Id, Name = generatedRepeatables.Name, EndTime = generatedRepeatables.EndTime, ActiveQuests = generatedRepeatables.ActiveQuests, InactiveQuests = generatedRepeatables.InactiveQuests, ChangeRequirement = generatedRepeatables.ChangeRequirement, FreeChanges = generatedRepeatables.FreeChanges, FreeChangesAvailable = generatedRepeatables.FreeChangesAvailable, } ); } return returnData; } /// /// Get repeatable quest data from profile from name (daily/weekly), creates base repeatable quest object if none exists /// /// daily/weekly config /// Players PMC profile /// PmcDataRepeatableQuest protected PmcDataRepeatableQuest GetRepeatableQuestSubTypeFromProfile( RepeatableQuestConfig repeatableConfig, PmcData pmcData ) { // Get from profile, add if missing var repeatableQuestDetails = pmcData.RepeatableQuests.FirstOrDefault(repeatable => repeatable.Name == repeatableConfig.Name ); var hasAccess = profileHelper.HasAccessToRepeatableFreeRefreshSystem(pmcData); if (repeatableQuestDetails is null) { // Not in profile, generate repeatableQuestDetails = new PmcDataRepeatableQuest { Id = repeatableConfig.Id, Name = repeatableConfig.Name, ActiveQuests = [], InactiveQuests = [], EndTime = 0, FreeChanges = hasAccess ? repeatableConfig.FreeChanges : 0, FreeChangesAvailable = hasAccess ? repeatableConfig.FreeChangesAvailable : 0, }; // Add base object that holds repeatable data to profile pmcData.RepeatableQuests.Add(repeatableQuestDetails); } // There is a chance an invalid number of free changes was assigned to the profile in earlier versions // reset the number if the user doesn't have access if (!hasAccess) { repeatableQuestDetails.FreeChanges = 0; repeatableQuestDetails.FreeChangesAvailable = 0; } return repeatableQuestDetails; } /// /// Check if a repeatable quest type (daily/weekly) is active for the given profile /// /// Repeatable quest config /// Players PMC profile /// True if profile has access to repeatables protected bool CanProfileAccessRepeatableQuests( RepeatableQuestConfig repeatableConfig, PmcData pmcData ) { // PMC and daily quests not unlocked yet if ( repeatableConfig.Side == PlayerGroup.Pmc && !PlayerHasDailyPmcQuestsUnlocked(pmcData, repeatableConfig) ) { return false; } // Scav and daily quests not unlocked yet if (repeatableConfig.Side == PlayerGroup.Scav && !PlayerHasDailyScavQuestsUnlocked(pmcData)) { if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug("Daily scav quests still locked, Intel center not built"); } return false; } return true; } /// /// Does player have daily pmc quests unlocked /// /// Players PMC profile /// Config of daily type to check /// True if unlocked protected static bool PlayerHasDailyPmcQuestsUnlocked( PmcData pmcData, RepeatableQuestConfig repeatableConfig ) { return pmcData.Info.Level >= repeatableConfig.MinPlayerLevel; } /// /// Does player have daily scav quests unlocked /// /// Players PMC profile /// True if unlocked protected bool PlayerHasDailyScavQuestsUnlocked(PmcData pmcData) { return pmcData ?.Hideout?.Areas?.FirstOrDefault(hideoutArea => hideoutArea.Type == HideoutAreas.IntelligenceCenter ) ?.Level >= 1; } /// /// Expire quests and replace expired quests with ready-to-hand-in quests inside generatedRepeatables.activeQuests /// /// Repeatables to process (daily/weekly) /// Players PMC profile protected void ProcessExpiredQuests( PmcDataRepeatableQuest generatedRepeatables, PmcData pmcData ) { var questsToKeep = new List(); foreach (var activeQuest in generatedRepeatables.ActiveQuests) { var questStatusInProfile = pmcData.Quests.FirstOrDefault(quest => quest.QId == activeQuest.Id ); if (questStatusInProfile is null) { continue; } // Keep finished quests in list so player can hand in if (questStatusInProfile.Status == QuestStatusEnum.AvailableForFinish) { questsToKeep.Add(activeQuest); if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug( // TODO: this shouldn't happen, doesn't on live $"Keeping repeatable quest: {activeQuest.Id} in activeQuests since it is available to hand in" ); } continue; } // Clean up quest-related counters being left in profile profileFixerService.RemoveDanglingConditionCounters(pmcData); // Remove expired quest from pmc.quest array pmcData.Quests = pmcData.Quests.Where(quest => quest.QId != activeQuest.Id).ToList(); // Store in inactive array generatedRepeatables.InactiveQuests.Add(activeQuest); } generatedRepeatables.ActiveQuests = questsToKeep; } /// /// Used to create a quest pool during each cycle of repeatable quest generation. The pool will be subsequently /// narrowed down during quest generation to avoid duplicate quests. Like duplicate extractions or elimination quests /// where you have to e.g. kill scavs in same locations /// /// main repeatable quest config /// Players level /// Allowed quest pool protected QuestTypePool GenerateQuestPool(RepeatableQuestConfig repeatableConfig, int pmcLevel) { var questPool = CreateEmptyQuestPool(repeatableConfig); // Populate Exploration and Pickup quest locations foreach (var (location, value) in repeatableConfig.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, repeatableConfig ); var targetsConfig = new ProbabilityObjectArray( cloner, eliminationConfig.Targets ); // Populate Elimination quest targets and their locations foreach (var target in targetsConfig) { // Target is boss if (target.Data?.IsBoss ?? false) { questPool.Pool.Elimination.Targets.Add( target.Key, new TargetLocation { Locations = ["any"] } ); continue; } // Target is not boss var possibleLocations = repeatableConfig.Locations.Keys; var allowedLocations = target.Key == "Savage" ? possibleLocations.Where(location => location != ELocationName.laboratory) // Exclude labs for Savage targets. : possibleLocations; questPool.Pool.Elimination.Targets.Add( target.Key, new TargetLocation { Locations = allowedLocations.Select(x => x.ToString()).ToList(), } ); } return questPool; } /// /// Create a pool of quests to generate quests from /// /// Main repeatable config /// QuestTypePool protected QuestTypePool CreateEmptyQuestPool(RepeatableQuestConfig repeatableConfig) { return new QuestTypePool { Types = cloner.Clone(repeatableConfig.Types)!, Pool = new QuestPool { Exploration = new ExplorationPool { Locations = new Dictionary>(), }, Elimination = new EliminationPool { Targets = new Dictionary(), }, Pickup = new ExplorationPool { Locations = new Dictionary>(), }, }, }; } /// /// Get count of repeatable quests profile should have access to /// /// /// Full player profile /// Quest count protected int GetQuestCount(RepeatableQuestConfig repeatableConfig, SptProfile fullProfile) { var questCount = repeatableConfig.NumQuests; if (questCount == 0) { logger.Warning($"Repeatable: {repeatableConfig.Name} quests have a count of 0"); } // Add elite bonus to daily quests if ( string.Equals(repeatableConfig.Name, "daily", StringComparison.OrdinalIgnoreCase) && profileHelper.HasEliteSkillLevel( SkillTypes.Charisma, fullProfile.CharacterData.PmcData ) ) // Elite charisma skill gives extra daily quest(s) { questCount += databaseService .GetGlobals() .Configuration.SkillsSettings.Charisma.BonusSettings.EliteBonusSettings.RepeatableQuestExtraCount.GetValueOrDefault( 0 ); } // Add any extra repeatable quests the profile has unlocked questCount += (int) fullProfile.SptData.ExtraRepeatableQuests.GetValueOrDefault(repeatableConfig.Id, 0); return questCount; } }