using System.Collections.Frozen; using System.Globalization; using SPTarkov.DI.Annotations; using SPTarkov.Server.Core.Extensions; 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.Quests; using SPTarkov.Server.Core.Models.Eft.Trade; using SPTarkov.Server.Core.Models.Enums; using SPTarkov.Server.Core.Models.Spt.Config; 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 LogLevel = SPTarkov.Server.Core.Models.Spt.Logging.LogLevel; namespace SPTarkov.Server.Core.Helpers; [Injectable(InjectionType.Singleton)] public class QuestHelper( ISptLogger logger, TimeUtil timeUtil, DatabaseService databaseService, EventOutputHolder eventOutputHolder, LocaleService localeService, ProfileHelper profileHelper, QuestRewardHelper questRewardHelper, RewardHelper rewardHelper, ServerLocalisationService serverLocalisationService, SeasonalEventService seasonalEventService, MailSendService mailSendService, ConfigServer configServer, ICloner cloner ) { protected readonly FrozenSet StartedOrAvailToFinish = [QuestStatusEnum.Started, QuestStatusEnum.AvailableForFinish]; protected readonly QuestConfig QuestConfig = configServer.GetConfig(); private Dictionary>? _sellToTraderQuestConditionCache; /// /// List of conditions that require trader sales be tracked and incremented, keyed by /// We need to keep track of quests with `SellItemToTrader` finish conditions to avoid expensive lookups during trading. /// protected virtual Dictionary> SellToTraderQuestConditionCache { get { return _sellToTraderQuestConditionCache ??= GetSellToTraderQuests(GetQuestsFromDb()); } } /// /// returns true if the level condition is satisfied /// /// Players level /// Quest condition /// true if player level is greater than or equal to quest public bool DoesPlayerLevelFulfilCondition(double playerLevel, QuestCondition condition) { if (condition.ConditionType != "Level") { return true; } var conditionValue = double.Parse(condition.Value.ToString(), CultureInfo.InvariantCulture); switch (condition.CompareMethod) { case ">=": return playerLevel >= conditionValue; case ">": return playerLevel > conditionValue; case "<": return playerLevel < conditionValue; case "<=": return playerLevel <= conditionValue; case "=": return playerLevel == conditionValue; default: logger.Error(serverLocalisationService.GetText("quest-unable_to_find_compare_condition", condition.CompareMethod)); return false; } } /// /// Get new quests in `after` that are not in `before` /// /// List of quests #1 /// List of quests #2 /// quests not in before public IEnumerable GetDeltaQuests(IEnumerable before, IEnumerable after) { // Nothing to compare against, return after if (!before.Any()) { return after; } // Get quests from before as a hashset for fast lookups var beforeQuests = before.Select(quest => quest.Id).ToHashSet(); // Return quests found in after but not before return after.Where(quest => !beforeQuests.Contains(quest.Id)); } /// /// Adjust skill experience for low skill levels, mimicking the official client /// /// the skill experience is being added to /// the amount of experience being added to the skill /// the adjusted skill progress gain public int AdjustSkillExpForLowLevels(CommonSkill profileSkill, int progressAmount) { // TODO: what used this? can't find any uses in node var currentLevel = Math.Floor((double)(profileSkill.Progress / 100)); // Only run this if the current level is under 9 if (currentLevel >= 9) { return progressAmount; } // This calculates how much progress we have in the skill's starting level var startingLevelProgress = profileSkill.Progress % 100 * ((currentLevel + 1) / 10); // The code below assumes a 1/10th progress skill amount var remainingProgress = progressAmount / 10; // We have to do this loop to handle edge cases where the provided XP bumps your level up // See "CalculateExpOnFirstLevels" in client for original logic var adjustedSkillProgress = 0; while (remainingProgress > 0 && currentLevel < 9) { // Calculate how much progress to add, limiting it to the current level max progress var currentLevelRemainingProgress = (currentLevel + 1) * 10 - startingLevelProgress; if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug($"currentLevelRemainingProgress: {currentLevelRemainingProgress}"); } var progressToAdd = Math.Min(remainingProgress, currentLevelRemainingProgress); var adjustedProgressToAdd = 10 / (currentLevel + 1) * progressToAdd; if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug($"Progress To Add: {progressToAdd} Adjusted for level: {adjustedProgressToAdd}"); } // Add the progress amount adjusted by level adjustedSkillProgress += (int)adjustedProgressToAdd; remainingProgress -= (int)progressToAdd; startingLevelProgress = 0; currentLevel++; } // If there's any remaining progress, add it. This handles if you go from level 8 -> 9 if (remainingProgress > 0) { adjustedSkillProgress += remainingProgress; } return adjustedSkillProgress; } /// /// Get quest name by quest id /// /// id to get /// public string GetQuestNameFromLocale(string questId) { var questNameKey = $"{questId} name"; return localeService.GetLocaleDb().GetValueOrDefault(questNameKey, "UNKNOWN"); } /// /// Check if trader has sufficient loyalty to fulfill quest requirement /// /// Quest props /// Player profile /// true if loyalty is high enough to fulfill quest requirement public bool TraderLoyaltyLevelRequirementCheck(QuestCondition questProperties, PmcData profile) { if ( !profile.TradersInfo.TryGetValue( questProperties.Target.IsItem ? questProperties.Target.Item : questProperties.Target.List.FirstOrDefault(), out var trader ) ) { logger.Error(serverLocalisationService.GetText("quest-unable_to_find_trader_in_profile", questProperties.Target)); } return CompareAvailableForValues(trader.LoyaltyLevel.Value, questProperties.Value.Value, questProperties.CompareMethod); } /// /// Check if trader has sufficient standing to fulfill quest requirement /// /// Quest props /// Player profile /// true if standing is high enough to fulfill quest requirement public bool TraderStandingRequirementCheck(QuestCondition questProperties, PmcData profile) { var requiredLoyaltyLevel = int.Parse(questProperties.Value.ToString()); if ( !profile.TradersInfo.TryGetValue( questProperties.Target.IsItem ? questProperties.Target.Item : questProperties.Target.List.FirstOrDefault(), out var trader ) ) { logger.Error(serverLocalisationService.GetText("quest-unable_to_find_trader_in_profile", questProperties.Target)); } return CompareAvailableForValues(trader.Standing ?? 1, requiredLoyaltyLevel, questProperties.CompareMethod); } /// /// Helper to map symbols to actions /// /// First value /// Second value /// Symbol to compare two values with e.g. ">=" /// Outcome of comparison protected bool CompareAvailableForValues(double current, double required, string compareMethod) { switch (compareMethod) { case ">=": return current >= required; case ">": return current > required; case "<=": return current <= required; case "<": return current < required; case "!=": return current != required; case "==": return current == required; default: logger.Error(serverLocalisationService.GetText("quest-compare_operator_unhandled", compareMethod)); return false; } } /// /// Look up quest in db by accepted quest id and construct a profile-ready object ready to store in profile /// /// Player profile /// State the new quest should be in when returned /// Details of accepted quest from client /// quest status object for storage in profile public QuestStatus GetQuestReadyForProfile(PmcData pmcData, QuestStatusEnum newState, AcceptQuestRequestData acceptedQuest) { var currentTimestamp = timeUtil.GetTimeStamp(); var existingQuest = pmcData.Quests.FirstOrDefault(q => q.QId == acceptedQuest.QuestId); if (existingQuest is not null) { // Quest exists, update what's there existingQuest.StartTime = currentTimestamp; existingQuest.Status = newState; existingQuest.StatusTimers[newState] = currentTimestamp; existingQuest.CompletedConditions = []; if (existingQuest.AvailableAfter is not null) { existingQuest.AvailableAfter = null; } return existingQuest; } // Quest doesn't exist, add it var newQuest = new QuestStatus { QId = acceptedQuest.QuestId, StartTime = currentTimestamp, Status = newState, StatusTimers = new Dictionary(), }; // Check if quest has a prereq to be placed in a 'pending' state, otherwise set status timers value var questDbData = GetQuestFromDb(acceptedQuest.QuestId, pmcData); if (questDbData is null) { logger.Error( serverLocalisationService.GetText( "quest-unable_to_find_quest_in_db", new { questId = acceptedQuest.QuestId, questType = acceptedQuest.Type } ) ); } var waitTime = questDbData?.Conditions.AvailableForStart.FirstOrDefault(x => x.AvailableAfter > 0); if (waitTime is not null && acceptedQuest.Type != "repeatable") { // Quest should be put into 'pending' state newQuest.StartTime = 0; newQuest.Status = QuestStatusEnum.AvailableAfter; // 9 newQuest.AvailableAfter = currentTimestamp + waitTime.AvailableAfter; } else { newQuest.StatusTimers[newState] = currentTimestamp; newQuest.CompletedConditions = []; } return newQuest; } /// /// Get quests that can be shown to player after starting a quest /// /// Quest started by player /// Session/Player id /// Quests accessible to player including newly unlocked quests now quest (startedQuestId) was started public List GetNewlyAccessibleQuestsWhenStartingQuest(MongoId startedQuestId, MongoId sessionID) { // Get quest acceptance data from profile var profile = profileHelper.GetPmcProfile(sessionID); var startedQuestInProfile = profile.Quests.FirstOrDefault(profileQuest => profileQuest.QId == startedQuestId); // Get quests that var eligibleQuests = GetQuestsFromDb() .Where(quest => { // Quest is accessible to player when the accepted quest passed into param is started // e.g. Quest A passed in, quest B is looped over and has requirement of A to be started, include it var matchingQuestCondition = quest.Conditions.AvailableForStart.FirstOrDefault(condition => condition.ConditionType == "Quest" && ( (condition.Target?.Item?.Contains(startedQuestId) ?? false) || (condition.Target?.List?.Contains(startedQuestId) ?? false) ) && (condition.Status?.Contains(QuestStatusEnum.Started) ?? false) ); // Has a matching quest condition in another quest (Accepting this quest gives access to found quest too) check if it also has a level requirement that passes if (matchingQuestCondition is not null) { var matchingLevelRequirement = quest.Conditions.AvailableForStart.FirstOrDefault(condition => condition.ConditionType == "Level" ); if (matchingLevelRequirement is not null && profile.Info.Level < matchingLevelRequirement.Value) { // Player doesn't fulfil level requirement for quest, don't show it to player return false; } } // Not found, skip quest if (matchingQuestCondition is null) { return false; } // Skip locked event quests if (!ShowEventQuestToPlayer(quest.Id)) { return false; } // Skip quest if it's flagged as for other side if (QuestIsForOtherSide(profile.Info.Side, quest.Id)) { return false; } if (QuestIsProfileBlacklisted(profile.Info.GameVersion, quest.Id)) { return false; } if (QuestIsProfileWhitelisted(profile.Info.GameVersion, quest.Id)) { return false; } var standingRequirements = quest.Conditions.AvailableForStart.GetStandingConditions(); foreach (var condition in standingRequirements) { if (!TraderStandingRequirementCheck(condition, profile)) { return false; } } var loyaltyRequirements = quest.Conditions.AvailableForStart.GetLoyaltyConditions(); foreach (var condition in loyaltyRequirements) { if (!TraderLoyaltyLevelRequirementCheck(condition, profile)) { return false; } } // Include if quest found in profile and is started or ready to hand in return startedQuestInProfile is not null && StartedOrAvailToFinish.Contains(startedQuestInProfile.Status); }); return GetQuestsWithOnlyLevelRequirementStartCondition(eligibleQuests).ToList(); } /// /// Should a seasonal/event quest be shown to the player /// /// Quest to check /// true = show to player public bool ShowEventQuestToPlayer(MongoId questId) { var isChristmasEventActive = seasonalEventService.ChristmasEventEnabled(); var isHalloweenEventActive = seasonalEventService.HalloweenEventEnabled(); // Not christmas + quest is for christmas if (!isChristmasEventActive && seasonalEventService.IsQuestRelatedToEvent(questId, SeasonalEventType.Christmas)) { return false; } // Not halloween + quest is for halloween if (!isHalloweenEventActive && seasonalEventService.IsQuestRelatedToEvent(questId, SeasonalEventType.Halloween)) { return false; } // Should non-season event quests be shown to player if (!QuestConfig.ShowNonSeasonalEventQuests && seasonalEventService.IsQuestRelatedToEvent(questId, SeasonalEventType.None)) { return false; } return true; } /// /// Is the quest for the opposite side the player is on /// /// Player side (usec/bear) /// QuestId to check /// true = quest isn't for player public bool QuestIsForOtherSide(string playerSide, MongoId questId) { var isUsec = string.Equals(playerSide, "usec", StringComparison.OrdinalIgnoreCase); if (isUsec && QuestConfig.BearOnlyQuests.Contains(questId)) // Player is usec and quest is bear only, skip { return true; } if (!isUsec && QuestConfig.UsecOnlyQuests.Contains(questId)) // Player is bear and quest is usec only, skip { return true; } // player is bear + quest is usec OR player is usec + quest is bear return false; } /// /// Is the provided quest prevented from being viewed by the provided game version /// (Inclusive filter) /// /// Game version to check against /// Quest id to check /// True = Quest should not be visible to game version protected bool QuestIsProfileBlacklisted(string gameVersion, MongoId questId) { var questBlacklist = QuestConfig.ProfileBlacklist.GetValueOrDefault(gameVersion); if (questBlacklist is null) { // Not blacklisted return false; } return questBlacklist.Contains(questId); } /// /// Is the provided quest able to be seen by the provided game version /// (Exclusive filter) /// /// Game version to check against /// Quest id to check /// True = Quest should be visible to game version protected bool QuestIsProfileWhitelisted(string gameVersion, MongoId questId) { var questBlacklist = QuestConfig.ProfileBlacklist.GetValueOrDefault(gameVersion); if (questBlacklist is null) // Not blacklisted { return false; } return questBlacklist.Contains(questId); } /// /// Get quests that can be shown to player after failing a quest /// /// Id of the quest failed by player /// Session/Player id /// List of Quest public List FailedUnlocked(MongoId failedQuestId, MongoId sessionId) { var profile = profileHelper.GetPmcProfile(sessionId); var profileQuest = profile.Quests.FirstOrDefault(x => x.QId == failedQuestId); var quests = GetQuestsFromDb() .Where(q => { var acceptedQuestCondition = q.Conditions.AvailableForStart.FirstOrDefault(c => c.ConditionType == "Quest" && (c.Target.IsList ? c.Target.List : [c.Target.Item]).Contains(failedQuestId) && c.Status.First() == QuestStatusEnum.Fail ); if (acceptedQuestCondition is null) { return false; } return profileQuest is not null && profileQuest.Status == QuestStatusEnum.Fail; }) .ToList(); if (quests.Any()) { return quests; } return GetQuestsWithOnlyLevelRequirementStartCondition(quests).ToList(); } /// /// Sets the item stack to new value, or delete the item if value is less than or equal 0 /// /// Profile /// Id of item to adjust stack size of /// Stack size to adjust to /// Session id /// ItemEvent router response public void ChangeItemStack(PmcData pmcData, MongoId itemId, int newStackSize, MongoId sessionID, ItemEventRouterResponse output) { //TODO: maybe merge this function and the one from customization var inventoryItemIndex = pmcData.Inventory.Items.FindIndex(item => item.Id == itemId); if (inventoryItemIndex < 0) { logger.Error(serverLocalisationService.GetText("quest-item_not_found_in_inventory", itemId)); return; } if (newStackSize > 0) { var item = pmcData.Inventory.Items[inventoryItemIndex]; item.AddUpd(); item.Upd.StackObjectsCount = newStackSize; output.AddItemStackSizeChangeIntoEventResponse(sessionID, item); } else { // this case is probably dead Code right now, since the only calling function // checks explicitly for Value > 0. output.ProfileChanges[sessionID].Items.DeletedItems.Add(new DeletedItem { Id = itemId }); pmcData.Inventory.Items.RemoveAt(inventoryItemIndex); } } /// /// Get quests, strip all requirement conditions except level /// /// quests to process /// quest list without conditions protected IEnumerable GetQuestsWithOnlyLevelRequirementStartCondition(IEnumerable quests) { return quests.Select(RemoveQuestConditionsExceptLevel); } /// /// Remove all quest conditions except for level requirement /// /// quest to clean /// Quest public Quest RemoveQuestConditionsExceptLevel(Quest quest) { var updatedQuest = cloner.Clone(quest); updatedQuest.Conditions.AvailableForStart = updatedQuest .Conditions.AvailableForStart.Where(q => q.ConditionType == "Level") .ToList(); return updatedQuest; } /// /// Get all quests with finish condition `SellItemToTrader`. /// The first time this method is called it will cache the conditions by quest id in ` and return that thereafter. /// /// Quests to process /// List of quests with `SellItemToTrader` finish condition(s) protected Dictionary> GetSellToTraderQuests(IEnumerable quests) { // Create cache var result = new Dictionary>(); foreach (var quest in quests) { foreach (var cond in quest.Conditions.AvailableForFinish) { if (cond.ConditionType != "SellItemToTrader") { continue; } if (!result.TryGetValue(quest.Id, out var questConditions)) { questConditions ??= []; questConditions.Add(cond); result.Add(quest.Id, questConditions); continue; } questConditions.Add(cond); } } if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug($"GetSellToTraderQuests found: {result.Count} quests"); } return result; } /// /// Get all active condition counters for `SellItemToTrader` conditions /// /// Profile to check /// List of active TaskConditionCounters protected List? GetActiveSellToTraderConditionCounters(PmcData pmcData) { return pmcData .TaskConditionCounters?.Values.Where(condition => SellToTraderQuestConditionCache.ContainsKey(condition.SourceId.Value) && condition.Type == "SellItemToTrader" ) .ToList(); } /// /// Look over all active conditions and increment them as needed /// /// profile selling the items /// profile to receive the money /// request with items to sell public void IncrementSoldToTraderCounters( PmcData profileWithItemsToSell, PmcData profileToReceiveMoney, ProcessSellTradeRequestData sellRequest ) { var activeConditionCounters = GetActiveSellToTraderConditionCounters(profileToReceiveMoney); // No active conditions, exit if (activeConditionCounters is null || activeConditionCounters.Count == 0) { return; } foreach (var counter in activeConditionCounters) { // Condition is in profile, but quest doesn't exist in database if (!SellToTraderQuestConditionCache.TryGetValue(counter.SourceId.Value, out var conditions)) { logger.Error(serverLocalisationService.GetText("quest_unable_to_find_quest_in_db_no_type", counter.SourceId)); continue; } foreach (var condition in conditions) { IncrementSoldToTraderCounter(profileWithItemsToSell, counter, condition, sellRequest); } } } /// /// Increment an individual condition counter /// /// Profile selling the items /// condition counter to increment /// quest condtion to check for valid items on /// sell request of items sold protected void IncrementSoldToTraderCounter( PmcData profileWithItemsToSell, TaskConditionCounter taskCounter, QuestCondition questCondition, ProcessSellTradeRequestData sellRequest ) { var itemsTplsThatIncrement = questCondition.Target; foreach (var itemSoldToTrader in sellRequest.Items) { // Get sold items' details from profile var itemDetails = profileWithItemsToSell.Inventory?.Items?.FirstOrDefault(inventoryItem => inventoryItem.Id == itemSoldToTrader.Id ); if (itemDetails is null) { logger.Error( serverLocalisationService.GetText("trader-unable_to_find_inventory_item_for_selltotrader_counter", taskCounter.SourceId) ); continue; } // Is sold item on the increment list if (itemsTplsThatIncrement.List.Contains(itemDetails.Template)) { taskCounter.Value += itemSoldToTrader.Count; } } } /// /// Fail a quest in a player profile /// /// Player profile /// Fail quest request data /// Player/Session id /// Client output public void FailQuest(PmcData pmcData, FailQuestRequestData failRequest, MongoId sessionID, ItemEventRouterResponse? output = null) { // Prepare response to send back to client var updatedOutput = output ?? eventOutputHolder.GetOutput(sessionID); UpdateQuestState(pmcData, QuestStatusEnum.Fail, failRequest.QuestId); var questRewards = questRewardHelper.ApplyQuestReward(pmcData, failRequest.QuestId, QuestStatusEnum.Fail, sessionID, updatedOutput); // Create a dialog message for completing the quest. var quest = GetQuestFromDb(failRequest.QuestId, pmcData); // Merge all daily/weekly/scav daily quests into one array and look for the matching quest by id var matchingRepeatableQuest = pmcData .RepeatableQuests.SelectMany(repeatableType => repeatableType.ActiveQuests) .FirstOrDefault(activeQuest => activeQuest.Id == failRequest.QuestId); // Quest found and no repeatable found if (quest is not null && matchingRepeatableQuest is null) { if (quest.FailMessageText.Trim().Any()) { mailSendService.SendLocalisedNpcMessageToPlayer( sessionID, quest?.TraderId ?? matchingRepeatableQuest?.TraderId, MessageType.QuestFail, quest.FailMessageText, questRewards.ToList(), timeUtil.GetHoursAsSeconds((int)GetMailItemRedeemTimeHoursForProfile(pmcData)) ); } } updatedOutput.ProfileChanges[sessionID].Quests.AddRange(FailedUnlocked(failRequest.QuestId, sessionID)); } /// /// Get collection of All Quests from db /// /// NOT CLONED /// List of Quest objects public List GetQuestsFromDb() { return databaseService.GetQuests().Values.ToList(); } /// /// Get quest by id from database (repeatables are stored in profile, check there if questId not found) /// /// Id of quest to find /// Player profile /// Found quest public Quest? GetQuestFromDb(MongoId questId, PmcData pmcData) { // Maybe a repeatable quest? if (databaseService.GetQuests().TryGetValue(questId, out var quest)) { return quest; } // Check daily/weekly objects return pmcData.RepeatableQuests.SelectMany(x => x.ActiveQuests).FirstOrDefault(x => x.Id == questId); } /// /// Get a quests startedMessageText key from db, if no startedMessageText key found, use description key instead /// /// startedMessageText property from Quest /// description property from Quest /// message id public string GetMessageIdForQuestStart(string startedMessageTextId, string questDescriptionId) { // Blank or is a guid, use description instead var startedMessageText = GetQuestLocaleIdFromDb(startedMessageTextId); if ( startedMessageText is null || startedMessageText.Trim() == "" || string.Equals(startedMessageText, "test", StringComparison.OrdinalIgnoreCase) || startedMessageText.Length == 24 ) { return questDescriptionId; } return startedMessageTextId; } /// /// Get the locale Id from locale db for a quest message /// /// Quest message id to look up /// Locale Id from locale db public string GetQuestLocaleIdFromDb(string questMessageId) { var locale = localeService.GetLocaleDb(); return locale.GetValueOrDefault(questMessageId, null); } /// /// Alter a quests state + Add a record to its status timers object /// /// Profile to update /// New state the quest should be in /// Id of the quest to alter the status of protected void UpdateQuestState(PmcData pmcData, QuestStatusEnum newQuestState, MongoId questId) { // Find quest in profile, update status to desired status var questToUpdate = pmcData.Quests.FirstOrDefault(quest => quest.QId == questId); if (questToUpdate is not null) { questToUpdate.Status = newQuestState; questToUpdate.StatusTimers[newQuestState] = timeUtil.GetTimeStamp(); } } /// /// Resets a quests values back to its chosen state /// /// Profile to update /// New state the quest should be in /// Id of the quest to alter the status of public void ResetQuestState(PmcData pmcData, QuestStatusEnum newQuestState, MongoId questId) { var questToUpdate = pmcData.Quests?.FirstOrDefault(quest => quest.QId == questId); if (questToUpdate is not null) { var currentTimestamp = timeUtil.GetTimeStamp(); questToUpdate.Status = newQuestState; // Only set start time when quest is being started if (newQuestState == QuestStatusEnum.Started) { questToUpdate.StartTime = currentTimestamp; } questToUpdate.StatusTimers[newQuestState] = currentTimestamp; // Delete all status timers after applying new status foreach (var statusKey in questToUpdate.StatusTimers) { if (statusKey.Key > newQuestState) { questToUpdate.StatusTimers.Remove(statusKey.Key); } } // Remove all completed conditions questToUpdate.CompletedConditions = []; } } /// /// Find quest with 'findItem' condition that needs the item tpl be handed in /// /// item tpl to look for /// Quests to search through for the findItem condition /// All quests to check /// quest id with 'FindItem' condition id public Dictionary GetFindItemConditionByQuestItem(MongoId itemTpl, MongoId[] questIds, List allQuests) { Dictionary result = new(); foreach (var questId in questIds) { var questInDb = allQuests.FirstOrDefault(x => x.Id == questId); if (questInDb is null) { if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug($"Unable to find quest: {questId} in db, cannot get 'FindItem' condition, skipping"); } continue; } var condition = questInDb.Conditions.AvailableForFinish.FirstOrDefault(c => c.ConditionType == "FindItem" && ((c.Target.IsList ? c.Target.List : [c.Target.Item])?.Contains(itemTpl) ?? false) ); if (condition is not null) { result[questId] = condition.Id; break; } } return result; } /// /// Add all quests to a profile with the provided statuses /// /// profile to update /// statuses quests should have added to profile public void AddAllQuestsToProfile(PmcData pmcProfile, IEnumerable statuses) { // Iterate over all quests in db var quests = databaseService.GetQuests(); foreach (var (key, questData) in quests) { // Quest from db matches quests in profile, skip if (pmcProfile.Quests.Any(x => x.QId == questData.Id)) { continue; } // Create dict of status to add to quest in profile var statusesDict = new Dictionary(); foreach (var status in statuses) { statusesDict.Add(status, timeUtil.GetTimeStamp()); } var questRecordToAdd = new QuestStatus { QId = key, StartTime = timeUtil.GetTimeStamp(), Status = statuses.Last(), // Get last status in list as currently active status StatusTimers = statusesDict, CompletedConditions = [], AvailableAfter = 0, }; // Check if the quest already exists in the profile var existingQuest = pmcProfile.Quests.FirstOrDefault(x => x.QId == key); if (existingQuest != null) { // Update existing quest existingQuest.Status = questRecordToAdd.Status; existingQuest.StatusTimers = questRecordToAdd.StatusTimers; } else { // Add new quest to the profile pmcProfile.Quests.Add(questRecordToAdd); } } } /// /// Find and remove the provided quest id from the provided collection of quests /// /// Id of quest to remove /// Collection of quests to remove id from public void FindAndRemoveQuestFromArrayIfExists(MongoId questId, List quests) { quests.RemoveAll(quest => quest.QId == questId); } /// /// Return a list of quests that would fail when supplied quest is completed /// /// quest completed id /// Collection of Quest objects public List GetQuestsFailedByCompletingQuest(MongoId completedQuestId) { var questsInDb = GetQuestsFromDb(); return questsInDb .Where(quest => { // No fail conditions, exit early if (quest.Conditions.Fail is null || quest.Conditions.Fail.Count == 0) { return false; } return quest.Conditions.Fail.Any(condition => (condition.Target.IsList ? condition.Target.List : [condition.Target.Item])?.Contains(completedQuestId) ?? false ); }) .ToList(); } /// /// Get the hours a mails items can be collected for by profile type /// /// Profile to get hours for /// Hours item will be available for public double GetMailItemRedeemTimeHoursForProfile(PmcData pmcData) { if (!QuestConfig.MailRedeemTimeHours.TryGetValue(pmcData.Info.GameVersion, out var hours)) { return QuestConfig.MailRedeemTimeHours["default"]; } return hours; } /// /// Handle player completing a quest /// Flag quest as complete in their profile /// Look for and flag any quests that fail when completing quest /// Show completed dialog on screen /// Add time locked quests unlocked by completing quest /// handle specific actions needed when quest is a repeatable /// /// Player profile /// Client request /// Player/session id /// Client response public ItemEventRouterResponse CompleteQuest(PmcData pmcData, CompleteQuestRequestData request, MongoId sessionID) { var completeQuestResponse = eventOutputHolder.GetOutput(sessionID); if (!completeQuestResponse.ProfileChanges.TryGetValue(sessionID, out var profileChanges)) { logger.Error($"Unable to get profile changes for {sessionID}"); return completeQuestResponse; } // Clone of players quest status prior to any changes var preCompleteProfileQuestsClone = cloner.Clone(pmcData.Quests); // Id of quest player just completed var completedQuestId = request.QuestId; // Keep a copy of player quest statuses from their profile (Must be gathered prior to applyQuestReward() & failQuests()) var clientQuestsClone = cloner.Clone(GetClientQuests(sessionID)); const QuestStatusEnum newQuestState = QuestStatusEnum.Success; UpdateQuestState(pmcData, newQuestState, completedQuestId); var questRewards = questRewardHelper.ApplyQuestReward(pmcData, request.QuestId, newQuestState, sessionID, completeQuestResponse); // Check for linked failed + unrestartable quests (only get quests not already failed var questsToFail = GetQuestsFromProfileFailedByCompletingQuest(completedQuestId, pmcData); if (questsToFail?.Count > 0) { FailQuests(sessionID, pmcData, questsToFail, completeQuestResponse); } // Show success modal on player screen SendSuccessDialogMessageOnQuestComplete(sessionID, pmcData, completedQuestId, questRewards.ToList()); // Add diff of quests before completion vs after for client response var questDelta = GetDeltaQuests(clientQuestsClone, GetClientQuests(sessionID)); // Check newly available + failed quests for timegates and add them to profile AddTimeLockedQuestsToProfile(pmcData, questDelta, request.QuestId); // Inform client of quest changes profileChanges.Quests.AddRange(questDelta); // If a repeatable quest. Remove from scav profile quests array foreach (var currentRepeatable in pmcData.RepeatableQuests) { var repeatableQuest = currentRepeatable.ActiveQuests?.FirstOrDefault(activeRepeatable => activeRepeatable.Id == completedQuestId ); if (repeatableQuest is not null) // Need to remove redundant scav quest object as its no longer necessary, is tracked in pmc profile { if (repeatableQuest.Side == "Scav") { RemoveQuestFromScavProfile(sessionID, repeatableQuest.Id); } } } // Hydrate client response questsStatus array with data var questStatusChanges = GetQuestsWithDifferentStatuses(preCompleteProfileQuestsClone, pmcData.Quests); profileChanges.QuestsStatus.AddRange(questStatusChanges); return completeQuestResponse; } /// /// Handle client/quest/list /// Get all quests visible to player /// Exclude quests with incomplete preconditions (level/loyalty) /// /// session/player id /// Collection of quests public List GetClientQuests(MongoId sessionID) { List questsToShowPlayer = []; var profile = profileHelper.GetPmcProfile(sessionID); if (profile is null) { logger.Error($"Profile {sessionID} not found, unable to return quests"); return []; } var allQuests = GetQuestsFromDb(); foreach (var quest in allQuests) { // Player already accepted the quest, show it regardless of status var questInProfile = profile.Quests.FirstOrDefault(x => x.QId == quest.Id); if (questInProfile is not null) { quest.SptStatus = questInProfile.Status; questsToShowPlayer.Add(quest); continue; } // Filter out bear quests for USEC and vice versa if (QuestIsForOtherSide(profile.Info.Side, quest.Id)) { continue; } if (!ShowEventQuestToPlayer(quest.Id)) { continue; } // Don't add quests that have a level higher than the user's if (!PlayerLevelFulfillsQuestRequirement(quest, profile.Info.Level.Value)) { continue; } // Player can use trader mods then remove them, leaving quests behind if (!profile.TradersInfo.ContainsKey(quest.TraderId)) { if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug($"Unable to show quest: {quest.QuestName} as its for a trader: {quest.TraderId} that no longer exists."); } continue; } var questRequirements = quest.Conditions.AvailableForStart.GetQuestConditions(); var loyaltyRequirements = quest.Conditions.AvailableForStart.GetLoyaltyConditions(); var standingRequirements = quest.Conditions.AvailableForStart.GetStandingConditions(); // Quest has no conditions, standing or loyalty conditions, add to visible quest list if (questRequirements.Count == 0 && loyaltyRequirements.Count == 0 && standingRequirements.Count == 0) { quest.SptStatus = QuestStatusEnum.AvailableForStart; questsToShowPlayer.Add(quest); continue; } // Check the status of each quest condition, if any are not completed // then this quest should not be visible var haveCompletedPreviousQuest = true; foreach (var conditionToFulfil in questRequirements) { // If the previous quest isn't in the user profile, it hasn't been completed or started var questIdsToFulfil = ( conditionToFulfil.Target.IsList ? conditionToFulfil.Target.List : conditionToFulfil.Target.Item == null ? null : [conditionToFulfil.Target.Item] ) ?? []; var prerequisiteQuest = profile.Quests.FirstOrDefault(profileQuest => questIdsToFulfil.Contains(profileQuest.QId)); if (prerequisiteQuest is null) { haveCompletedPreviousQuest = false; break; } // Prereq does not have its status requirement fulfilled // Some bsg status ids are strings, MUST convert to number before doing includes check if (!conditionToFulfil.Status.Contains(prerequisiteQuest.Status)) { haveCompletedPreviousQuest = false; break; } // Has a wait timer if (conditionToFulfil.AvailableAfter > 0) { // Compare current time to unlock time for previous quest prerequisiteQuest.StatusTimers.TryGetValue(prerequisiteQuest.Status, out var previousQuestCompleteTime); var unlockTime = previousQuestCompleteTime + conditionToFulfil.AvailableAfter; if (unlockTime > timeUtil.GetTimeStamp()) { logger.Debug($"Quest {quest.QuestName} is locked for another: {unlockTime - timeUtil.GetTimeStamp()} seconds"); } } } // Previous quest not completed, skip if (!haveCompletedPreviousQuest) { continue; } var passesLoyaltyRequirements = true; foreach (var condition in loyaltyRequirements) { if (!TraderLoyaltyLevelRequirementCheck(condition, profile)) { passesLoyaltyRequirements = false; break; } } var passesStandingRequirements = true; foreach (var condition in standingRequirements) { if (!TraderStandingRequirementCheck(condition, profile)) { passesStandingRequirements = false; break; } } if (haveCompletedPreviousQuest && passesLoyaltyRequirements && passesStandingRequirements) { quest.SptStatus = QuestStatusEnum.AvailableForStart; questsToShowPlayer.Add(quest); } } return UpdateQuestsForGameEdition(cloner.Clone(questsToShowPlayer), profile.Info.GameVersion); } /// /// Remove rewards from quests that do not fulfil the gameversion requirement /// /// List of quests to check /// Game version of the profile /// Collection of Quest objects with the rewards filtered correctly for the game version protected List UpdateQuestsForGameEdition(List quests, string gameVersion) { foreach (var quest in quests) { // Remove any reward that doesn't pass the game edition check foreach (var rewardType in quest.Rewards) { if (rewardType.Value is null) { continue; } quest.Rewards[rewardType.Key] = quest .Rewards[rewardType.Key] .Where(reward => rewardHelper.RewardIsForGameEdition(reward, gameVersion)) .ToList(); } } return quests; } /// /// Return a list of quests that would fail when supplied quest is completed /// /// Quest completed id /// /// Collection of Quest objects protected List GetQuestsFromProfileFailedByCompletingQuest(MongoId completedQuestId, PmcData pmcProfile) { var questsInDb = GetQuestsFromDb(); return questsInDb .Where(quest => { // No fail conditions, skip if (quest.Conditions?.Fail is null || quest.Conditions.Fail.Count == 0) { return false; } // Quest already exists in profile and is failed, skip if (pmcProfile.Quests.Any(profileQuest => profileQuest.QId == quest.Id && profileQuest.Status == QuestStatusEnum.Fail)) { return false; } // Check if completed quest is inside iterated quests fail conditions foreach (var condition in quest.Conditions.Fail) { // No target, cant be failed by our completed quest if (condition?.Target is null) { continue; } // 'Target' property can be Collection or string, handle each differently if (condition.Target.IsList && condition.Target.List.Contains(completedQuestId)) { // Check if completed quest id exists in fail condition return true; } if (condition.Target.IsItem && condition.Target.Item == completedQuestId) { // Not a list, plain string return true; } } return false; }) .ToList(); } /// /// Fail the provided quests - Update quest in profile, otherwise add fresh quest object with failed status /// /// session id /// player profile /// quests to fail /// Client output protected void FailQuests(MongoId sessionID, PmcData pmcData, List questsToFail, ItemEventRouterResponse output) { foreach (var questToFail in questsToFail) { // Skip failing a quest that has a fail status of something other than success if (questToFail.Conditions.Fail?.Any(x => x.Status?.Any(status => status != QuestStatusEnum.Success) ?? false) ?? false) { continue; } var isActiveQuestInPlayerProfile = pmcData.Quests.FirstOrDefault(quest => quest.QId == questToFail.Id); if (isActiveQuestInPlayerProfile is not null) { if (isActiveQuestInPlayerProfile.Status != QuestStatusEnum.Fail) { var failBody = new FailQuestRequestData { Action = "QuestFail", QuestId = questToFail.Id, RemoveExcessItems = true, }; FailQuest(pmcData, failBody, sessionID, output); } } else { // Failing an entirely new quest that doesn't exist in profile var statusTimers = new Dictionary(); if (!statusTimers.TryGetValue(QuestStatusEnum.Fail, out _)) { statusTimers.Add(QuestStatusEnum.Fail, 0); } statusTimers[QuestStatusEnum.Fail] = timeUtil.GetTimeStamp(); var questData = new QuestStatus { QId = questToFail.Id, StartTime = timeUtil.GetTimeStamp(), StatusTimers = statusTimers, Status = QuestStatusEnum.Fail, }; pmcData.Quests.Add(questData); } } } /// /// Send a popup to player on successful completion of a quest /// /// session id /// Player profile /// Completed quest id /// Rewards given to player protected void SendSuccessDialogMessageOnQuestComplete( MongoId sessionID, PmcData pmcData, MongoId completedQuestId, List questRewards ) { var quest = GetQuestFromDb(completedQuestId, pmcData); mailSendService.SendLocalisedNpcMessageToPlayer( sessionID, quest.TraderId, MessageType.QuestSuccess, quest.SuccessMessageText, questRewards, timeUtil.GetHoursAsSeconds((int)GetMailItemRedeemTimeHoursForProfile(pmcData)) ); } /// /// Look for newly available quests after completing a quest with a requirement to wait x minutes (time-locked) before being available and add data to profile /// /// Player profile to update /// Quests to look for wait conditions in /// Quest just completed protected void AddTimeLockedQuestsToProfile(PmcData pmcData, IEnumerable quests, MongoId completedQuestId) { // Iterate over quests, look for quests with right criteria foreach (var quest in quests) { // If quest has prereq of completed quest + availableAfter value > 0 (quest has wait time) var nextQuestWaitCondition = quest.Conditions?.AvailableForStart?.FirstOrDefault(x => ((x.Target?.List?.Contains(completedQuestId) ?? false) || (x.Target?.Item?.Contains(completedQuestId) ?? false)) && x.AvailableAfter > 0 ); // as we have to use the ListOrT type now, check both List and Item for the above checks if (nextQuestWaitCondition is not null) { // Now + wait time var availableAfterTimestamp = timeUtil.GetTimeStamp() + nextQuestWaitCondition.AvailableAfter; // Update quest in profile with status of AvailableAfter var existingQuestInProfile = pmcData.Quests.FirstOrDefault(x => x.QId == quest.Id); if (existingQuestInProfile is not null) { existingQuestInProfile.AvailableAfter = availableAfterTimestamp; existingQuestInProfile.Status = QuestStatusEnum.AvailableAfter; existingQuestInProfile.StartTime = 0; existingQuestInProfile.StatusTimers = new Dictionary(); continue; } pmcData.Quests.Add( new QuestStatus { QId = quest.Id, StartTime = 0, Status = QuestStatusEnum.AvailableAfter, StatusTimers = new Dictionary { { QuestStatusEnum.AvailableAfter, timeUtil.GetTimeStamp() }, }, AvailableAfter = availableAfterTimestamp, } ); } } } /// /// Remove a quest entirely from a profile /// /// Player id /// Qid of quest to remove protected void RemoveQuestFromScavProfile(MongoId sessionId, MongoId questIdToRemove) { var fullProfile = profileHelper.GetFullProfile(sessionId); var repeatableInScavProfile = fullProfile.CharacterData.ScavData.Quests?.FirstOrDefault(x => x.QId == questIdToRemove); if (repeatableInScavProfile is null) { logger.Warning( serverLocalisationService.GetText( "quest-unable_to_remove_scav_quest_from_profile", new { scavQuestId = questIdToRemove, profileId = sessionId } ) ); return; } fullProfile.CharacterData.ScavData.Quests.Remove(repeatableInScavProfile); } /// /// Get quests that have different statuses /// /// Quests before /// Quests after /// QuestStatusChange array protected List GetQuestsWithDifferentStatuses(List preQuestStatuses, List postQuestStatuses) { List result = []; foreach (var quest in postQuestStatuses) { // Add quest if status differs or quest not found var preQuest = preQuestStatuses.FirstOrDefault(x => x.QId == quest.QId); if (preQuest is null || preQuest.Status != quest.Status) { result.Add(quest); } } return result; } /// /// Does a provided quest have a level requirement equal to or below defined level /// /// Quest to check /// level of player to test against quest /// true if quest can be seen/accepted by player of defined level protected bool PlayerLevelFulfillsQuestRequirement(Quest quest, double playerLevel) { if (quest.Conditions is null) // No conditions { return true; } var levelConditions = quest.Conditions.AvailableForStart.GetLevelConditions(); if (levelConditions is not null) { foreach (var levelCondition in levelConditions) { if (!DoesPlayerLevelFulfilCondition(playerLevel, levelCondition)) // Not valid, exit out { return false; } } } // All conditions passed / has no level requirement, valid return true; } }