using System.Text.RegularExpressions; using SPTarkov.DI.Annotations; using SPTarkov.Server.Core.Extensions; 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.Hideout; using SPTarkov.Server.Core.Models.Eft.Profile; using SPTarkov.Server.Core.Models.Enums; using SPTarkov.Server.Core.Models.Enums.Hideout; using SPTarkov.Server.Core.Models.Spt.Config; using SPTarkov.Server.Core.Models.Utils; using SPTarkov.Server.Core.Servers; using SPTarkov.Server.Core.Utils; using LogLevel = SPTarkov.Server.Core.Models.Spt.Logging.LogLevel; namespace SPTarkov.Server.Core.Services; [Injectable(InjectionType.Singleton)] public class ProfileFixerService( ISptLogger logger, JsonUtil jsonUtil, RewardHelper rewardHelper, TraderHelper traderHelper, HideoutHelper hideoutHelper, DatabaseService databaseService, ServerLocalisationService serverLocalisationService, ConfigServer configServer ) { protected readonly CoreConfig CoreConfig = configServer.GetConfig(); /// /// Find issues in the pmc profile data that may cause issues and fix them /// /// profile to check and fix public void CheckForAndFixPmcProfileIssues(PmcData pmcProfile) { RemoveDanglingConditionCounters(pmcProfile); RemoveDanglingTaskConditionCounters(pmcProfile); RemoveOrphanedQuests(pmcProfile); VerifyQuestProductionUnlocks(pmcProfile); FixOrphanedInsurance(pmcProfile); if (pmcProfile.Hideout is not null) { AddHideoutEliteSlots(pmcProfile); } if (pmcProfile.Skills is not null) { CheckForSkillsOverMaxLevel(pmcProfile); } } /// /// Resolve any dialogue attachments that were accidentally created using the player's equipment ID as /// the stash root object ID /// /// public void CheckForAndFixDialogueAttachments(SptProfile fullProfile) { foreach (var traderDialoguesKvP in fullProfile.DialogueRecords) { if (traderDialoguesKvP.Value.Messages is null) { continue; } var traderDialogues = traderDialoguesKvP.Value; foreach (var message in traderDialogues.Messages) { // Skip any messages without attached items if (message.Items?.Data is null || message.Items?.Stash is null) { continue; } // Skip any messages that don't have a stashId collision with the player's equipment ID if (message.Items?.Stash != fullProfile.CharacterData?.PmcData?.Inventory?.Equipment) { continue; } // Otherwise we need to generate a new unique stash ID for this message's attachments message.Items.Stash = new MongoId(); message.Items.Data = message.Items.Data.AdoptOrphanedItems(message.Items.Stash); // Because `adoptOrphanedItems` sets the slotId to `hideout`, we need to re-set it to `main` to work with mail foreach (var item in message.Items.Data.Where(item => item.SlotId == "hideout")) { item.SlotId = "main"; } } } } /// /// Attempt to fix common item issues that corrupt profiles /// /// Profile to check items of public void FixProfileBreakingInventoryItemIssues(PmcData pmcProfile) { // Create a mapping of all inventory items, keyed by _id value var itemMapping = pmcProfile.Inventory.Items.GroupBy(item => item.Id).ToDictionary(x => x.Key, x => x.ToList()); foreach (var mappingKvP in itemMapping) { // Only one item for this id, not a dupe if (mappingKvP.Value.Count == 1) { continue; } logger.Warning($"{mappingKvP.Value.Count - 1} duplicate(s) found for item: {mappingKvP.Key}"); var itemAJson = jsonUtil.Serialize(mappingKvP.Value[0]); var itemBJson = jsonUtil.Serialize(mappingKvP.Value[1]); if (itemAJson == itemBJson) { // Both items match, we can safely delete one (A) var indexOfItemToRemove = pmcProfile.Inventory.Items.IndexOf(mappingKvP.Value[0]); pmcProfile.Inventory.Items.RemoveAt(indexOfItemToRemove); logger.Warning($"Deleted duplicate item: {mappingKvP.Key}"); } else { // Items are different, replace ID with unique value // Only replace ID if items have no children, we don't want orphaned children var itemsHaveChildren = pmcProfile.Inventory.Items.Any(x => x.ParentId == mappingKvP.Key); if (!itemsHaveChildren) { var itemToAdjust = pmcProfile.Inventory.Items.FirstOrDefault(x => x.Id == mappingKvP.Key); itemToAdjust.Id = new MongoId(); logger.Warning($"Replace duplicate item Id: {mappingKvP.Key} with {itemToAdjust.Id}"); } } } // Iterate over all inventory items foreach (var item in pmcProfile.Inventory.Items.Where(x => x.SlotId is not null)) { if (item.Upd is null) // Ignore items without a upd object { continue; } // Check items with a tags for non-alphanumeric characters and remove var regxp = new Regex("[^a-zA-Z0-9 -]"); if (item.Upd.Tag?.Name is not null && !regxp.IsMatch(item.Upd.Tag.Name)) { logger.Warning($"Fixed item: {item.Id}s Tag value, removed invalid characters"); item.Upd.Tag.Name = regxp.Replace(item.Upd.Tag.Name, ""); } // Check items with StackObjectsCount (undefined) if (item.Upd.StackObjectsCount is null) { logger.Warning($"Fixed item: {item.Id}s undefined StackObjectsCount value, now set to 1"); item.Upd.StackObjectsCount = 1; } } // Iterate over clothing var customizationDb = databaseService.GetTemplates().Customization; var customizationDbArray = customizationDb.Values; var playerIsUsec = string.Equals(pmcProfile.Info.Side, "usec", StringComparison.OrdinalIgnoreCase); // Check Head if (!customizationDb.ContainsKey(pmcProfile.Customization.Head.Value)) { var defaultHead = playerIsUsec ? customizationDbArray.FirstOrDefault(x => x.Name == "DefaultUsecHead") : customizationDbArray.FirstOrDefault(x => x.Name == "DefaultBearHead"); pmcProfile.Customization.Head = defaultHead.Id; } // check Body if (customizationDb.ContainsKey(pmcProfile.Customization.Body.Value)) { var defaultBody = playerIsUsec ? customizationDbArray.FirstOrDefault(x => x.Name == "DefaultUsecBody") : customizationDbArray.FirstOrDefault(x => x.Name == "DefaultBearBody"); pmcProfile.Customization.Body = defaultBody.Id; } // check Hands if (customizationDb.ContainsKey(pmcProfile.Customization.Hands.Value)) { var defaultHands = playerIsUsec ? customizationDbArray.FirstOrDefault(x => x.Name == "DefaultUsecHands") : customizationDbArray.FirstOrDefault(x => x.Name == "DefaultBearHands"); pmcProfile.Customization.Hands = defaultHands.Id; } // check Feet if (customizationDb.ContainsKey(pmcProfile.Customization.Feet.Value)) { var defaultFeet = playerIsUsec ? customizationDbArray.FirstOrDefault(x => x.Name == "DefaulUsecFeet") : customizationDbArray.FirstOrDefault(x => x.Name == "DefaultBearFeet"); pmcProfile.Customization.Feet = defaultFeet.Id; } } /// /// TODO - make this non-public - currently used by RepeatableQuestController /// Remove unused condition counters /// /// profile to remove old counters from public void RemoveDanglingConditionCounters(PmcData pmcProfile) { if (pmcProfile.TaskConditionCounters is null) { return; } foreach (var counterKvP in pmcProfile.TaskConditionCounters.Where(counterKvP => counterKvP.Value.SourceId is null)) { pmcProfile.TaskConditionCounters.Remove(counterKvP.Key); } } /// /// Repeatable quests leave behind TaskConditionCounter objects that make the profile bloat with time, remove them /// /// Player profile to check protected void RemoveDanglingTaskConditionCounters(PmcData pmcProfile) { if (pmcProfile.TaskConditionCounters is null) { return; } var taskConditionKeysToRemove = new List(); var activeRepeatableQuests = GetActiveRepeatableQuests(pmcProfile.RepeatableQuests); var achievements = databaseService.GetAchievements(); // Loop over TaskConditionCounters objects and add once we want to remove to counterKeysToRemove foreach (var TaskConditionCounterKvP in pmcProfile.TaskConditionCounters) // Only check if profile has repeatable quests { if (pmcProfile.RepeatableQuests is not null && activeRepeatableQuests.Count > 0) { var existsInActiveRepeatableQuests = activeRepeatableQuests.Any(quest => quest.Id == TaskConditionCounterKvP.Value.SourceId ); var existsInQuests = pmcProfile.Quests.Any(quest => quest.QId == TaskConditionCounterKvP.Value.SourceId); var isAchievementTracker = achievements.Any(quest => quest.Id == TaskConditionCounterKvP.Value.SourceId); // If task conditions id is neither in activeQuests, quests or achievements - it's stale and should be cleaned up if (!(existsInActiveRepeatableQuests || existsInQuests || isAchievementTracker)) { taskConditionKeysToRemove.Add(TaskConditionCounterKvP.Key); } } } foreach (var counterKeyToRemove in taskConditionKeysToRemove) { if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug($"Removed: {counterKeyToRemove} TaskConditionCounter object"); } pmcProfile.TaskConditionCounters.Remove(counterKeyToRemove); } } protected List GetActiveRepeatableQuests(List repeatableQuests) { var activeQuests = new List(); foreach (var repeatableQuest in repeatableQuests.Where(questType => questType.ActiveQuests?.Count > 0)) // daily/weekly collection has active quests in them, add to array and return { activeQuests.AddRange(repeatableQuest.ActiveQuests); } return activeQuests; } /// /// After removing mods that add quests, the quest panel will break without removing these /// /// Profile to remove dead quests from protected void RemoveOrphanedQuests(PmcData pmcProfile) { var quests = databaseService.GetQuests(); var profileQuests = pmcProfile.Quests; var activeRepeatableQuests = GetActiveRepeatableQuests(pmcProfile.RepeatableQuests); for (var i = profileQuests.Count - 1; i >= 0; i--) { if (!(quests.ContainsKey(profileQuests[i].QId) || activeRepeatableQuests.Any(x => x.Id == profileQuests[i].QId))) { logger.Info($"Successfully removed orphaned quest: {profileQuests[i].QId} that doesn't exist in quest data"); profileQuests.RemoveAt(i); } } } /// /// Verify that all quest production unlocks have been applied to the PMC Profile /// /// The profile to validate quest productions for protected void VerifyQuestProductionUnlocks(PmcData pmcProfile) { var quests = databaseService.GetQuests(); var profileQuests = pmcProfile.Quests; foreach (var profileQuest in profileQuests) { var quest = quests.GetValueOrDefault(profileQuest.QId, null); if (quest is null) { continue; } // For started or successful quests, check for unlocks in the `Started` rewards if (profileQuest.Status is QuestStatusEnum.Started or QuestStatusEnum.Success) { var productionRewards = quest.Rewards["Started"]?.Where(reward => reward.Type == RewardType.ProductionScheme); if (productionRewards is not null) { foreach (var reward in productionRewards) { VerifyQuestProductionUnlock(pmcProfile, reward, quest); } } } // For successful quests, check for unlocks in the `Success` rewards if (profileQuest.Status is QuestStatusEnum.Success) { var productionRewards = quest.Rewards["Success"]?.Where(reward => reward.Type == RewardType.ProductionScheme); if (productionRewards is not null) { foreach (var reward in productionRewards) { VerifyQuestProductionUnlock(pmcProfile, reward, quest); } } } } } /// /// Validate that the given profile has the given quest reward production scheme unlocked, and add it if not /// /// Profile to check /// The quest reward to validate /// The quest the reward belongs to protected void VerifyQuestProductionUnlock(PmcData pmcProfile, Reward productionUnlockReward, Quest questDetails) { var matchingProductions = rewardHelper.GetRewardProductionMatch(productionUnlockReward, questDetails.Id); if (matchingProductions.Count != 1) { logger.Error( serverLocalisationService.GetText( "quest-unable_to_find_matching_hideout_production", new { questName = questDetails.QuestName, matchCount = matchingProductions.Count } ) ); return; } // Add above match to pmc profile var matchingProductionId = matchingProductions[0].Id; if (pmcProfile.UnlockedInfo.UnlockedProductionRecipe.Add(matchingProductionId)) { if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug($"Added production: {matchingProductionId} to unlocked production recipes for: {questDetails.QuestName}"); } } } /// /// Remove any entries from `pmcProfile.InsuredItems` that do not have a corresponding /// `pmcProfile.Inventory.items` entry /// /// PMC Profile to fix protected void FixOrphanedInsurance(PmcData pmcProfile) { // Check if the player inventory contains this item pmcProfile.InsuredItems = pmcProfile .InsuredItems.Where(insuredItem => pmcProfile.Inventory.Items.Any(item => item.Id == insuredItem.ItemId)) .ToList(); } /// /// If the profile has elite Hideout Management skill, add the additional slots from globals /// NOTE: This seems redundant, but we will leave it here just in case. /// /// profile to add slots to protected void AddHideoutEliteSlots(PmcData pmcProfile) { var globals = databaseService.GetGlobals(); var generator = pmcProfile.Hideout.Areas.FirstOrDefault(area => area.Type == HideoutAreas.Generator); if (generator is not null) { var fuelSlots = generator.Slots.Count; var extraGenSlots = globals.Configuration.SkillsSettings.HideoutManagement.EliteSlots.Generator.Slots; if (fuelSlots < 6 + extraGenSlots) { if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug("Updating generator area slots to a size of 6 + hideout management skill"); } AddEmptyObjectsToHideoutAreaSlots(HideoutAreas.Generator, (int)(6 + extraGenSlots), pmcProfile); } } var restArea = pmcProfile.Hideout.Areas.FirstOrDefault(area => area.Type == HideoutAreas.RestSpace); if (restArea is not null) { var slots = restArea.Slots.Count; if (slots < 1) { if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug("Updating restArea slots to a size of 1"); } AddEmptyObjectsToHideoutAreaSlots(HideoutAreas.RestSpace, 1, pmcProfile); } } var waterCollSlots = pmcProfile.Hideout.Areas.FirstOrDefault(x => x.Type == HideoutAreas.WaterCollector)?.Slots?.Count; var extraWaterCollSlots = globals.Configuration.SkillsSettings.HideoutManagement.EliteSlots.WaterCollector.Slots; if (waterCollSlots.GetValueOrDefault(0) < 1 + extraWaterCollSlots) { if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug("Updating water collector area slots to a size of 1 + hideout management skill"); } AddEmptyObjectsToHideoutAreaSlots(HideoutAreas.WaterCollector, (int)(1 + extraWaterCollSlots), pmcProfile); } var filterSlots = pmcProfile.Hideout.Areas.FirstOrDefault(x => x.Type == HideoutAreas.AirFilteringUnit)?.Slots?.Count; var extraFilterSlots = globals.Configuration.SkillsSettings.HideoutManagement.EliteSlots.AirFilteringUnit.Slots; if (filterSlots.GetValueOrDefault(0) < 3 + extraFilterSlots) { if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug("Updating air filter area slots to a size of 3 + hideout management skill"); } AddEmptyObjectsToHideoutAreaSlots(HideoutAreas.AirFilteringUnit, (int)(3 + extraFilterSlots), pmcProfile); } var btcFarmSlots = pmcProfile.Hideout.Areas.FirstOrDefault(x => x.Type == HideoutAreas.BitcoinFarm).Slots.Count; var extraBtcSlots = globals.Configuration.SkillsSettings.HideoutManagement.EliteSlots.BitcoinFarm.Slots; // BTC Farm doesn't have extra slots for hideout management, but we still check for modded stuff!! if (btcFarmSlots < 50 + extraBtcSlots) { if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug("Updating bitcoin farm area slots to a size of 50 + hideout management skill"); } AddEmptyObjectsToHideoutAreaSlots(HideoutAreas.BitcoinFarm, (int)(50 + extraBtcSlots), pmcProfile); } var cultistAreaSlots = pmcProfile.Hideout.Areas.FirstOrDefault(x => x.Type == HideoutAreas.CircleOfCultists).Slots.Count; if (cultistAreaSlots < 1) { if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug("Updating cultist area slots to a size of 1"); } AddEmptyObjectsToHideoutAreaSlots(HideoutAreas.CircleOfCultists, 1, pmcProfile); } } /// /// add in objects equal to the number of slots /// /// area to check /// area to update /// profile to update protected void AddEmptyObjectsToHideoutAreaSlots(HideoutAreas areaType, int emptyItemCount, PmcData pmcProfile) { var area = pmcProfile.Hideout.Areas.FirstOrDefault(x => x.Type == areaType); area.Slots = AddObjectsToList(emptyItemCount, area.Slots); } protected List AddObjectsToList(int count, List slots) { for (var i = 0; i < count; i++) { // No slots have this location index if (slots.All(x => x.LocationIndex != i)) { slots.Add(new HideoutSlot { LocationIndex = i }); } } return slots; } /// /// Check for and cap profile skills at 5100. /// /// Profile to check and fix public void CheckForSkillsOverMaxLevel(PmcData pmcProfile) { var skills = pmcProfile.Skills.Common; foreach (var skill in skills.Where(skill => skill.Progress > 5100)) { skill.Progress = 5100; } } /// /// REQUIRED for dev profiles
/// Iterate over players hideout areas and find what's built, look for missing bonuses those areas give and add them if missing ///
/// Profile to update /// public void AddMissingHideoutBonusesToProfile(PmcData pmcProfile, List? dbHideoutAreas) { foreach (var profileArea in pmcProfile.Hideout?.Areas ?? []) { var areaType = profileArea.Type; var currentLevel = profileArea.Level; if (currentLevel.GetValueOrDefault(0) == 0) { continue; } // Create array of hideout area upgrade levels player has installed // Zero indexed var areaLevelsToCheck = new List(); for (var index = 0; index < currentLevel + 1; index++) { areaLevelsToCheck.Add(index.ToString()); // Convert to string as hideout stage key is saved as string in db } // Get hideout area data from db var dbArea = dbHideoutAreas?.FirstOrDefault(area => area.Type == areaType); if (dbArea is null || dbArea.Stages is null) { continue; } // Check if profile is missing any bonuses from each area level foreach (var areaLevel in areaLevelsToCheck) { // Get areas level from db if (!dbArea.Stages.TryGetValue(areaLevel, out var stage)) { continue; } // Get the bonuses for this upgrade stage var levelBonuses = stage.Bonuses; if (levelBonuses is null || levelBonuses.Count == 0) { continue; } // Iterate over each bonus for the areas level foreach (var bonus in levelBonuses) { // Check if profile has bonus var profileBonus = GetBonusFromProfile(pmcProfile.Bonuses, bonus); if (profileBonus is null) { // No bonus in profile, add it logger.Debug( $"Profile has level: {currentLevel} area: {profileArea.Type} but no bonus found, adding: {bonus.Type}" ); hideoutHelper.ApplyPlayerUpgradesBonus(pmcProfile, bonus); } } } } } /// /// Finds a bonus in a profile /// /// Bonuses from profile /// Bonus to find /// Matching bonus protected Bonus? GetBonusFromProfile(IEnumerable? profileBonuses, Bonus bonus) { // match by id first, used by "TextBonus" bonuses if (!bonus.Id.IsEmpty) { return profileBonuses?.FirstOrDefault(x => x.Id == bonus.Id); } return bonus.Type switch { BonusType.StashSize => profileBonuses?.FirstOrDefault(x => x.Type == bonus.Type && x.TemplateId == bonus.TemplateId), BonusType.AdditionalSlots => profileBonuses?.FirstOrDefault(x => x.Type == bonus.Type && x?.Value == bonus?.Value && x?.IsVisible == bonus?.IsVisible ), _ => profileBonuses?.FirstOrDefault(x => x.Type == bonus.Type && x.Value == bonus.Value), }; } public void CheckForAndRemoveInvalidTraders(SptProfile fullProfile) { foreach (var (traderId, _) in fullProfile.CharacterData?.PmcData?.TradersInfo) { if (!traderHelper.TraderExists(traderId)) { if (CoreConfig.Fixes.RemoveInvalidTradersFromProfile) { logger.Warning( $"Non - default trader: {traderId} removed from PMC TradersInfo in: {fullProfile.ProfileInfo?.ProfileId} profile" ); fullProfile.CharacterData.PmcData.TradersInfo.Remove(traderId); } else { logger.Error(serverLocalisationService.GetText("fixer-trader_found", traderId.ToString())); } } } foreach (var (traderId, _) in fullProfile.CharacterData.ScavData?.TradersInfo) { if (!traderHelper.TraderExists(traderId)) { if (CoreConfig.Fixes.RemoveInvalidTradersFromProfile) { logger.Warning( $"Non - default trader: {traderId} removed from Scav TradersInfo in: {fullProfile.ProfileInfo?.ProfileId} profile" ); fullProfile.CharacterData.ScavData.TradersInfo.Remove(traderId); } else { logger.Error(serverLocalisationService.GetText("fixer-trader_found", traderId.ToString())); } } } } }