using Core.Annotations; using Core.Models.Eft.Common; using Core.Models.Eft.Common.Tables; using Core.Models.Eft.ItemEvent; using Core.Models.Eft.Quests; 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 Core.Utils.Extensions; namespace Core.Controllers; [Injectable] public class RepeatableQuestController( ISptLogger _logger, TimeUtil _timeUtil, HashUtil _hashUtil, RandomUtil _randomUtil, HttpResponseUtil _responseUtil, ProfileHelper _profileHelper, ProfileFixerService _profileFixerService, LocalisationService _localisationService, EventOutputHolder _eventOutputHolder, PaymentService _paymentService, RepeatableQuestGenerator _repeatableQuestGenerator, RepeatableQuestHelper _repeatableQuestHelper, QuestHelper _questHelper, DatabaseService _databaseService, ConfigServer _configServer, ICloner _cloner ) { protected QuestConfig _questConfig = _configServer.GetConfig(); public ItemEventRouterResponse ChangeRepeatableQuest(PmcData pmcData, RepeatableQuestChangeRequest info, string sessionId) { throw new NotImplementedException(); } 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.ToLower(); 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); _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 = []; _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); // Add repeatable quests of this loops sub-type (daily/weekly) for (var i = 0; i < GetQuestCount(repeatableConfig, pmcData); i++) { var quest = new RepeatableQuest(); var lifeline = 0; while (quest.Id is null && questTypePool.Types.Count > 0) { quest = _repeatableQuestGenerator.GenerateRepeatableQuest( sessionID, pmcData.Info.Level ?? 0, pmcData.TradersInfo, questTypePool, repeatableConfig ); lifeline++; if (lifeline > 10) { _logger.Debug("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 = 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 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 }; } // Reset free repeatable values in player profile to defaults generatedRepeatables.FreeChanges = repeatableConfig.FreeChanges; generatedRepeatables.FreeChangesAvailable = repeatableConfig.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.FreeChanges, } ); } return returnData; } private PmcDataRepeatableQuest GetRepeatableQuestSubTypeFromProfile(RepeatableQuestConfig repeatableConfig, PmcData pmcData) { // Get from profile, add if missing var repeatableQuestDetails = pmcData.RepeatableQuests.FirstOrDefault( (repeatable) => repeatable.Name == repeatableConfig.Name ); if (repeatableQuestDetails is not null) { // Not in profile, generate var hasAccess = _profileHelper.HasAccessToRepeatableFreeRefreshSystem(pmcData); repeatableQuestDetails = new PmcDataRepeatableQuest() { Id = repeatableConfig.Id, Name = repeatableConfig.Name, ActiveQuests = [], InactiveQuests = [], EndTime = 0, ChangeRequirement = { }, FreeChanges = hasAccess ? repeatableConfig.FreeChanges : 0, FreeChangesAvailable = hasAccess ? repeatableConfig.FreeChangesAvailable : 0, }; // Add base object that holds repeatable data to profile pmcData.RepeatableQuests.Add(repeatableQuestDetails); } return repeatableQuestDetails; } private bool CanProfileAccessRepeatableQuests(RepeatableQuestConfig repeatableConfig, PmcData pmcData) { // PMC and daily quests not unlocked yet if (repeatableConfig.Side == "Pmc" && !PlayerHasDailyPmcQuestsUnlocked(pmcData, repeatableConfig)) { return false; } // Scav and daily quests not unlocked yet if (repeatableConfig.Side == "Scav" && !PlayerHasDailyScavQuestsUnlocked(pmcData)) { _logger.Debug("Daily scav quests still locked, Intel center not built"); return false; } return true; } /** * Does player have daily pmc quests unlocked * @param pmcData Player profile to check * @param repeatableConfig Config of daily type to check * @returns True if unlocked */ private bool PlayerHasDailyPmcQuestsUnlocked(PmcData pmcData, RepeatableQuestConfig repeatableConfig) { return pmcData.Info.Level >= repeatableConfig.MinPlayerLevel; } /** * Does player have daily scav quests unlocked * @param pmcData Player profile to check * @returns True if unlocked */ private bool PlayerHasDailyScavQuestsUnlocked(PmcData pmcData) { return ( pmcData?.Hideout?.Areas?.FirstOrDefault((hideoutArea) => hideoutArea.Type == HideoutAreas.INTEL_CENTER)?.Level >= 1 ); } private 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); _logger.Debug($"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; } private QuestTypePool GenerateQuestPool(RepeatableQuestConfig repeatableConfig, int? pmcLevel) { var questPool = CreateBaseQuestPool(repeatableConfig); // Get the allowed locations based on the PMC's level var locations = GetAllowedLocationsForPmcLevel(repeatableConfig.Locations, pmcLevel.Value); // 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); // 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.TryGetValue(targetKvP.Key, out var targets); targets.Locations.Clear(); targets.Locations.Add("any"); } else { // Non-boss targets var possibleLocations = locations; questPool.Pool.Elimination.Targets.TryGetValue(targetKvP.Key, out var targets); var targetsClone = _cloner.Clone(targets); var allowedLocations = targetKvP.Key == "Savage" ? targetsClone.Locations.Where((location) => location != "laboratory") // Exclude labs for Savage targets. : targetsClone.Locations; targets.Locations.Clear(); targets.Locations.AddRange(allowedLocations); } } return questPool; } private QuestTypePool CreateBaseQuestPool(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>() } }, }; } private Dictionary> GetAllowedLocationsForPmcLevel(Dictionary> locations, int pmcLevel) { var allowedLocation = new Dictionary>(); foreach (var (location, value) in locations) { var locationNames = new List(); foreach (var locationName in value) { if (IsPmcLevelAllowedOnLocation(locationName, pmcLevel)) { locationNames.Add(locationName); } } if (locationNames.Count > 0) { allowedLocation[location] = locationNames; } } return allowedLocation; } /** * 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 if (location == ELocationName.any.ToString()) { return true; } var locationBase = _databaseService.GetLocation(location.ToLower())?.Base; if (locationBase is not null) { return true; } return pmcLevel <= locationBase.RequiredPlayerLevelMax && pmcLevel >= locationBase.RequiredPlayerLevelMin; } /// /// Get count of repeatable quests profile should have access to /// /// /// Player profile /// Quest count private int GetQuestCount(RepeatableQuestConfig repeatableConfig, PmcData pmcData) { var questCount = repeatableConfig.NumQuests.GetValueOrDefault(0); if (questCount == 0) { _logger.Warning($"Repeatable {repeatableConfig.Name} quests have a count of 0"); } // Add elite bonus to daily quests if (repeatableConfig.Name.ToLower() == "daily" && _profileHelper.HasEliteSkillLevel(SkillTypes.Charisma, pmcData) ) { // Elite charisma skill gives extra daily quest(s) questCount += _databaseService .GetGlobals() .Configuration .SkillsSettings .Charisma .BonusSettings .EliteBonusSettings .RepeatableQuestExtraCount .GetValueOrDefault(0); } return questCount; } }