From c7e40deb90ab0857525def0d8f82f2fd9fb7a242 Mon Sep 17 00:00:00 2001 From: Jesse Date: Tue, 5 Aug 2025 16:25:47 +0200 Subject: [PATCH] Add support for partially loading invalid profiles (#533) * Add support for partially loading invalid profiles * Return early in exception --- .../Controllers/GameController.cs | 1 - .../Controllers/InsuranceController.cs | 6 + .../Items/InvalidModdedClothingException.cs | 15 + .../Items/InvalidModdedItemException.cs | 15 + .../Items/InvalidModdedTraderException.cs | 15 + .../Extensions/ProfileExtensions.cs | 52 +++- .../Helpers/ProfileValidatorHelper.cs | 288 ++++++++++++++++++ .../Models/Eft/Profile/SptProfile.cs | 3 + .../Servers/SaveServer.cs | 16 +- .../Services/ProfileFixerService.cs | 256 +--------------- ...rService.cs => ProfileValidatorService.cs} | 27 +- 11 files changed, 429 insertions(+), 265 deletions(-) create mode 100644 Libraries/SPTarkov.Server.Core/Exceptions/Items/InvalidModdedClothingException.cs create mode 100644 Libraries/SPTarkov.Server.Core/Exceptions/Items/InvalidModdedItemException.cs create mode 100644 Libraries/SPTarkov.Server.Core/Exceptions/Items/InvalidModdedTraderException.cs create mode 100644 Libraries/SPTarkov.Server.Core/Helpers/ProfileValidatorHelper.cs rename Libraries/SPTarkov.Server.Core/Services/{ProfileMigratorService.cs => ProfileValidatorService.cs} (73%) diff --git a/Libraries/SPTarkov.Server.Core/Controllers/GameController.cs b/Libraries/SPTarkov.Server.Core/Controllers/GameController.cs index 28f15055..ac82bc50 100644 --- a/Libraries/SPTarkov.Server.Core/Controllers/GameController.cs +++ b/Libraries/SPTarkov.Server.Core/Controllers/GameController.cs @@ -106,7 +106,6 @@ public class GameController( { SendPraporGiftsToNewProfiles(pmcProfile); SendMechanicGiftsToNewProfile(pmcProfile); - profileFixerService.CheckForOrphanedModdedItems(sessionId, fullProfile); } profileFixerService.CheckForAndRemoveInvalidTraders(fullProfile); diff --git a/Libraries/SPTarkov.Server.Core/Controllers/InsuranceController.cs b/Libraries/SPTarkov.Server.Core/Controllers/InsuranceController.cs index 572bf61f..9c02bfce 100644 --- a/Libraries/SPTarkov.Server.Core/Controllers/InsuranceController.cs +++ b/Libraries/SPTarkov.Server.Core/Controllers/InsuranceController.cs @@ -86,6 +86,12 @@ public class InsuranceController( var insuranceTime = time ?? timeUtil.GetTimeStamp(); var profileInsuranceDetails = saveServer.GetProfile(sessionId).InsuranceList; + + if (profileInsuranceDetails is null) + { + return []; + } + if (profileInsuranceDetails.Count > 0) { if (logger.IsLogEnabled(LogLevel.Debug)) diff --git a/Libraries/SPTarkov.Server.Core/Exceptions/Items/InvalidModdedClothingException.cs b/Libraries/SPTarkov.Server.Core/Exceptions/Items/InvalidModdedClothingException.cs new file mode 100644 index 00000000..a3a682b6 --- /dev/null +++ b/Libraries/SPTarkov.Server.Core/Exceptions/Items/InvalidModdedClothingException.cs @@ -0,0 +1,15 @@ +namespace SPTarkov.Server.Core.Exceptions.Items; + +public class InvalidModdedClothingException : Exception +{ + public InvalidModdedClothingException(string message) + : base(message) { } + + public InvalidModdedClothingException(string message, Exception innerException) + : base(message, innerException) { } + + public override string? StackTrace + { + get { return null; } + } +} diff --git a/Libraries/SPTarkov.Server.Core/Exceptions/Items/InvalidModdedItemException.cs b/Libraries/SPTarkov.Server.Core/Exceptions/Items/InvalidModdedItemException.cs new file mode 100644 index 00000000..1f84e67e --- /dev/null +++ b/Libraries/SPTarkov.Server.Core/Exceptions/Items/InvalidModdedItemException.cs @@ -0,0 +1,15 @@ +namespace SPTarkov.Server.Core.Exceptions.Items; + +public class InvalidModdedItemException : Exception +{ + public InvalidModdedItemException(string message) + : base(message) { } + + public InvalidModdedItemException(string message, Exception innerException) + : base(message, innerException) { } + + public override string? StackTrace + { + get { return null; } + } +} diff --git a/Libraries/SPTarkov.Server.Core/Exceptions/Items/InvalidModdedTraderException.cs b/Libraries/SPTarkov.Server.Core/Exceptions/Items/InvalidModdedTraderException.cs new file mode 100644 index 00000000..5d8e6498 --- /dev/null +++ b/Libraries/SPTarkov.Server.Core/Exceptions/Items/InvalidModdedTraderException.cs @@ -0,0 +1,15 @@ +namespace SPTarkov.Server.Core.Exceptions.Items; + +public class InvalidModdedTraderException : Exception +{ + public InvalidModdedTraderException(string message) + : base(message) { } + + public InvalidModdedTraderException(string message, Exception innerException) + : base(message, innerException) { } + + public override string? StackTrace + { + get { return null; } + } +} diff --git a/Libraries/SPTarkov.Server.Core/Extensions/ProfileExtensions.cs b/Libraries/SPTarkov.Server.Core/Extensions/ProfileExtensions.cs index d8d1222d..3d0908cf 100644 --- a/Libraries/SPTarkov.Server.Core/Extensions/ProfileExtensions.cs +++ b/Libraries/SPTarkov.Server.Core/Extensions/ProfileExtensions.cs @@ -1,7 +1,10 @@ -using SPTarkov.Server.Core.Models.Common; +using Microsoft.Extensions.Logging; +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.Enums; +using SPTarkov.Server.Core.Services; namespace SPTarkov.Server.Core.Extensions; @@ -239,4 +242,51 @@ public static class ProfileExtensions bodyPart.Health.Maximum = profileTemplate.Character.Health.BodyParts[partKey].Health.Maximum; } } + + /// + /// Handle Remove event + /// Remove item from player inventory + insured items array + /// Also deletes child items + /// + /// Profile to remove item from (pmc or scav) + /// Items id to remove + /// Session id + /// OPTIONAL - ItemEventRouterResponse + public static void RemoveItem(this PmcData profile, MongoId itemId, MongoId sessionId, ItemEventRouterResponse? output = null) + { + if (itemId.IsEmpty()) + { + return; + } + + // Get children of item, they get deleted too + var itemAndChildrenToRemove = profile.Inventory.Items.GetItemWithChildren(itemId); + if (!itemAndChildrenToRemove.Any()) + { + return; + } + + var inventoryItems = profile.Inventory.Items; + var insuredItems = profile.InsuredItems; + + // We have output object, inform client of root item deletion, not children + output?.ProfileChanges[sessionId].Items.DeletedItems.Add(new DeletedItem { Id = itemId }); + + foreach (var item in itemAndChildrenToRemove) + { + // We expect that each inventory item and each insured item has unique "_id", respective "itemId". + // Therefore, we want to use a NON-Greedy function and escape the iteration as soon as we find requested item. + var inventoryIndex = inventoryItems.FindIndex(inventoryItem => inventoryItem.Id == item.Id); + if (inventoryIndex != -1) + { + inventoryItems.RemoveAt(inventoryIndex); + } + + var insuredItemIndex = insuredItems.FindIndex(insuredItem => insuredItem.ItemId == item.Id); + if (insuredItemIndex != -1) + { + insuredItems.RemoveAt(insuredItemIndex); + } + } + } } diff --git a/Libraries/SPTarkov.Server.Core/Helpers/ProfileValidatorHelper.cs b/Libraries/SPTarkov.Server.Core/Helpers/ProfileValidatorHelper.cs new file mode 100644 index 00000000..d27460eb --- /dev/null +++ b/Libraries/SPTarkov.Server.Core/Helpers/ProfileValidatorHelper.cs @@ -0,0 +1,288 @@ +using SPTarkov.DI.Annotations; +using SPTarkov.Server.Core.Exceptions.Items; +using SPTarkov.Server.Core.Extensions; +using SPTarkov.Server.Core.Models.Common; +using SPTarkov.Server.Core.Models.Eft.Common.Tables; +using SPTarkov.Server.Core.Models.Eft.Profile; +using SPTarkov.Server.Core.Models.Enums; +using SPTarkov.Server.Core.Models.Spt.Config; +using SPTarkov.Server.Core.Models.Utils; +using SPTarkov.Server.Core.Servers; +using SPTarkov.Server.Core.Services; + +namespace SPTarkov.Server.Core.Helpers; + +[Injectable] +public class ProfileValidatorHelper( + ConfigServer configServer, + DatabaseService databaseService, + ISptLogger logger, + ServerLocalisationService serverLocalisationService, + TraderStore traderStore +) +{ + protected readonly CoreConfig _coreConfig = configServer.GetConfig(); + + /// + /// Checks profile inventory for items that do not exist inside the items DB + /// + /// Session ID + /// Profile to check inventory of + public void CheckForOrphanedModdedItems(MongoId sessionId, SptProfile fullProfile) + { + var itemsDb = databaseService.GetItems(); + var pmcProfile = fullProfile.CharacterData.PmcData; + + var invalidItemIds = pmcProfile.Inventory.Items.Where(item => !itemsDb.ContainsKey(item.Template)).Select(item => item.Id).ToList(); + foreach (var invalidItemId in invalidItemIds) + { + if (_coreConfig.Fixes.RemoveModItemsFromProfile) + { + logger.Warning($"Deleting item id: {invalidItemId} from inventory and insurance"); + + // Add here so we can remove below + pmcProfile.RemoveItem(invalidItemId, sessionId); + } + else + { + throw new InvalidModdedItemException(serverLocalisationService.GetText("fixer-mod_item_found", invalidItemId.ToString())); + } + } + + if (fullProfile.UserBuildData is not null) + { + // Remove invalid builds from weapon, equipment and magazine build lists + var weaponBuilds = fullProfile.UserBuildData?.WeaponBuilds ?? []; + fullProfile.UserBuildData.WeaponBuilds = weaponBuilds + .Where(build => !ShouldRemoveWeaponEquipmentBuild("weapon", build, itemsDb)) + .ToList(); + + var equipmentBuilds = fullProfile.UserBuildData.EquipmentBuilds ?? []; + fullProfile.UserBuildData.EquipmentBuilds = equipmentBuilds + .Where(build => !ShouldRemoveWeaponEquipmentBuild("equipment", build, itemsDb)) + .ToList(); + + var magazineBuild = fullProfile.UserBuildData.MagazineBuilds ?? []; + fullProfile.UserBuildData.MagazineBuilds = magazineBuild.Where(build => !ShouldRemoveMagazineBuild(build, itemsDb)).ToList(); + } + + // Iterate over dialogs, looking for messages with items not found in item db, remove message if item found + foreach (var dialog in fullProfile.DialogueRecords) + { + if (dialog.Value.Messages is null) + { + continue; // Skip dialog with no messages + } + + foreach (var message in dialog.Value.Messages) + { + if (message.Items?.Data is null) + { + continue; // skip messages with no items + } + + // Fix message with no items but have the flags to indicate items to collect + if (message.Items.Data.Count == 0 && message.HasRewards.GetValueOrDefault(false)) + { + message.HasRewards = false; + message.RewardCollected = true; + continue; + } + + // Find invalid items and remove from message + var itemsToRemove = message.Items.Data.Where(item => !itemsDb.ContainsKey(item.Template)).ToList(); + foreach (var itemToRemove in itemsToRemove) + { + if (_coreConfig.Fixes.RemoveModItemsFromProfile) + { + message.Items.Data.Remove(itemToRemove); + logger.Warning( + $"Item: {itemToRemove.Template} has resulted in the deletion of message: {message.Id} from dialog: {dialog.Key}" + ); + } + else + { + throw new InvalidModdedItemException( + serverLocalisationService.GetText("fixer-mod_item_found", itemToRemove.Template.ToString()) + ); + } + } + } + } + + var clothingDb = databaseService.GetTemplates().Customization; + foreach ( + var clothingItem in fullProfile + .CustomisationUnlocks.Where(customisation => customisation.Type == CustomisationType.SUITE) + .ToList() // We're removing element, ToList to allow that to occur + ) + { + if (!clothingDb.ContainsKey(clothingItem.Id)) + { + if (_coreConfig.Fixes.RemoveModItemsFromProfile) + { + fullProfile.CustomisationUnlocks.Remove(clothingItem); + logger.Warning($"Non-default clothing purchase: {clothingItem} removed from profile"); + } + else + { + throw new InvalidModdedClothingException( + serverLocalisationService.GetText("fixer-clothing_item_found", clothingItem.ToString()) + ); + } + } + } + + foreach (var repeatable in fullProfile.CharacterData.PmcData.RepeatableQuests ?? []) + { + if (repeatable.ActiveQuests is null) + { + continue; + } + + // ToList to prevent `Collection was modified` exception + foreach (var activeQuest in repeatable.ActiveQuests.ToList()) + { + if (!DoesTraderExist(activeQuest.TraderId)) + { + if (_coreConfig.Fixes.RemoveModItemsFromProfile) + { + logger.Warning( + $"Non-default quest: {activeQuest.Id} from trader: {activeQuest.TraderId} removed from RepeatableQuests list in profile" + ); + repeatable.ActiveQuests.Remove(activeQuest); + } + else + { + throw new InvalidModdedTraderException( + serverLocalisationService.GetText("fixer-trader_found", activeQuest.TraderId.ToString()) + ); + } + + continue; + } + + if (activeQuest.Rewards?["Success"] is null) + { + continue; + } + + // Get Item rewards only + foreach (var successReward in activeQuest.Rewards["Success"].Where(reward => reward.Type == RewardType.Item)) + { + if (successReward.Items.Any(item => !itemsDb.ContainsKey(item.Template))) + { + logger.Warning( + $"Non-default repeatable quest: {activeQuest.Id} from trader: {activeQuest.TraderId} removed from RepeatableQuests list in profile" + ); + repeatable.ActiveQuests.Remove(activeQuest); + } + } + } + } + + foreach (var (traderId, _) in fullProfile.TraderPurchases.Where(traderPurchase => !DoesTraderExist(traderPurchase.Key))) + { + if (_coreConfig.Fixes.RemoveModItemsFromProfile) + { + logger.Warning($"Non-default trader: {traderId} purchase removed from traderPurchases list in profile"); + fullProfile.TraderPurchases.Remove(traderId); + } + else + { + throw new InvalidModdedTraderException(serverLocalisationService.GetText("fixer-trader_found", traderId.ToString())); + } + } + } + + /// + /// Check whether a weapon build should be removed from the equipment list. + /// + /// The type of build, used for logging only + /// The build to check for invalid items + /// The items database to use for item lookup + /// True if the build should be removed from the build list, false otherwise + protected bool ShouldRemoveWeaponEquipmentBuild(string buildType, UserBuild build, Dictionary itemsDb) + { + if (buildType == "weapon") + // Get items not found in items db + { + foreach (var item in (build as WeaponBuild).Items.Where(item => !itemsDb.ContainsKey(item.Template))) + { + logger.Error(serverLocalisationService.GetText("fixer-mod_item_found", item.Template.ToString())); + + if (_coreConfig.Fixes.RemoveModItemsFromProfile) + { + logger.Warning($"Item: {item.Template} has resulted in the deletion of {buildType} build: {build.Name}"); + + return true; + } + + break; + } + } + + // TODO: refactor to be generic + + if (buildType == "equipment") + // Get items not found in items db + { + foreach (var item in (build as EquipmentBuild).Items.Where(item => !itemsDb.ContainsKey(item.Template))) + { + logger.Error(serverLocalisationService.GetText("fixer-mod_item_found", item.Template.ToString())); + + if (_coreConfig.Fixes.RemoveModItemsFromProfile) + { + logger.Warning($"Item: {item.Template} has resulted in the deletion of {buildType} build: {build.Name}"); + + return true; + } + + // Found a broken item + break; + } + } + + return false; + } + + /// + /// Checks whether magazine build shou8ld be removed form the build list. + /// + /// The magazine build to check for validity + /// The items database to use for item lookup + /// True if the build should be removed from the build list, false otherwise + protected bool ShouldRemoveMagazineBuild(MagazineBuild magazineBuild, Dictionary itemsDb) + { + foreach (var item in magazineBuild.Items) + { + // Magazine builds can have undefined items in them, skip those + if (item is null) + { + continue; + } + + // Check item exists in itemsDb + if (!itemsDb.ContainsKey(item.TemplateId)) + { + logger.Error(serverLocalisationService.GetText("fixer-mod_item_found", item.TemplateId.ToString())); + + if (_coreConfig.Fixes.RemoveModItemsFromProfile) + { + logger.Warning($"Item: {item.TemplateId} has resulted in the deletion of magazine build: {magazineBuild.Name}"); + + return true; + } + + break; + } + } + + return false; + } + + protected bool DoesTraderExist(MongoId traderId) + { + return traderStore.GetTraderById(traderId) != null; + } +} diff --git a/Libraries/SPTarkov.Server.Core/Models/Eft/Profile/SptProfile.cs b/Libraries/SPTarkov.Server.Core/Models/Eft/Profile/SptProfile.cs index aa3f86a9..b91b1b2a 100644 --- a/Libraries/SPTarkov.Server.Core/Models/Eft/Profile/SptProfile.cs +++ b/Libraries/SPTarkov.Server.Core/Models/Eft/Profile/SptProfile.cs @@ -103,6 +103,9 @@ public record Info [JsonPropertyName("edition")] public string? Edition { get; set; } + + [JsonIgnore] + public bool InvalidOrUnloadableProfile { get; internal set; } = false; } public record Characters diff --git a/Libraries/SPTarkov.Server.Core/Servers/SaveServer.cs b/Libraries/SPTarkov.Server.Core/Servers/SaveServer.cs index bf1d5ab7..6b0cb91d 100644 --- a/Libraries/SPTarkov.Server.Core/Servers/SaveServer.cs +++ b/Libraries/SPTarkov.Server.Core/Servers/SaveServer.cs @@ -21,7 +21,7 @@ public class SaveServer( JsonUtil jsonUtil, HashUtil hashUtil, ServerLocalisationService serverLocalisationService, - ProfileMigratorService profileMigratorService, + ProfileValidatorService profileValidatorService, ISptLogger logger, ConfigServer configServer ) @@ -210,10 +210,16 @@ public class SaveServer( if (profile is not null) { - profiles[sessionID] = profileMigratorService.HandlePendingMigrations(profile); + profiles[sessionID] = profileValidatorService.MigrateAndValidateProfile(profile); } } + // We don't proceed further here as only one object in the profile has data in it. + if (profiles[sessionID].ProfileInfo!.InvalidOrUnloadableProfile) + { + return; + } + // Run callbacks foreach (var callback in saveLoadRouters) // HealthSaveLoadRouter, InraidSaveLoadRouter, InsuranceSaveLoadRouter, ProfileSaveLoadRouter. THESE SHOULD EXIST IN HERE { @@ -229,6 +235,12 @@ public class SaveServer( /// Time taken to save the profile in seconds public async Task SaveProfileAsync(MongoId sessionID) { + // No need to save profiles that have been marked as invalid + if (profiles[sessionID].ProfileInfo!.InvalidOrUnloadableProfile) + { + return 0; + } + var filePath = $"{profileFilepath}{sessionID.ToString()}.json"; // Run pre-save callbacks before we save into json diff --git a/Libraries/SPTarkov.Server.Core/Services/ProfileFixerService.cs b/Libraries/SPTarkov.Server.Core/Services/ProfileFixerService.cs index 3fc81d46..c982a10a 100644 --- a/Libraries/SPTarkov.Server.Core/Services/ProfileFixerService.cs +++ b/Libraries/SPTarkov.Server.Core/Services/ProfileFixerService.cs @@ -1,5 +1,6 @@ using System.Text.RegularExpressions; using SPTarkov.DI.Annotations; +using SPTarkov.Server.Core.Exceptions; using SPTarkov.Server.Core.Extensions; using SPTarkov.Server.Core.Helpers; using SPTarkov.Server.Core.Models.Common; @@ -512,261 +513,6 @@ public class ProfileFixerService( } } - /// - /// Checks profile inventory for items that do not exist inside the items DB - /// - /// Session ID - /// Profile to check inventory of - public void CheckForOrphanedModdedItems(MongoId sessionId, SptProfile fullProfile) - { - var itemsDb = databaseService.GetItems(); - var pmcProfile = fullProfile.CharacterData.PmcData; - - var invalidItemIds = pmcProfile.Inventory.Items.Where(item => !itemsDb.ContainsKey(item.Template)).Select(item => item.Id).ToList(); - foreach (var invalidItemId in invalidItemIds) - { - if (_coreConfig.Fixes.RemoveModItemsFromProfile) - { - logger.Warning($"Deleting item id: {invalidItemId} from inventory and insurance"); - - // Add here so we can remove below - inventoryHelper.RemoveItem(pmcProfile, invalidItemId, sessionId); - } - else - { - logger.Error(serverLocalisationService.GetText("fixer-mod_item_found", invalidItemId.ToString())); - break; - } - } - - if (fullProfile.UserBuildData is not null) - { - // Remove invalid builds from weapon, equipment and magazine build lists - var weaponBuilds = fullProfile.UserBuildData?.WeaponBuilds ?? []; - fullProfile.UserBuildData.WeaponBuilds = weaponBuilds - .Where(build => !ShouldRemoveWeaponEquipmentBuild("weapon", build, itemsDb)) - .ToList(); - - var equipmentBuilds = fullProfile.UserBuildData.EquipmentBuilds ?? []; - fullProfile.UserBuildData.EquipmentBuilds = equipmentBuilds - .Where(build => !ShouldRemoveWeaponEquipmentBuild("equipment", build, itemsDb)) - .ToList(); - - var magazineBuild = fullProfile.UserBuildData.MagazineBuilds ?? []; - fullProfile.UserBuildData.MagazineBuilds = magazineBuild.Where(build => !ShouldRemoveMagazineBuild(build, itemsDb)).ToList(); - } - - // Iterate over dialogs, looking for messages with items not found in item db, remove message if item found - foreach (var dialog in fullProfile.DialogueRecords) - { - if (dialog.Value.Messages is null) - { - continue; // Skip dialog with no messages - } - - foreach (var message in dialog.Value.Messages) - { - if (message.Items?.Data is null) - { - continue; // skip messages with no items - } - - // Fix message with no items but have the flags to indicate items to collect - if (message.Items.Data.Count == 0 && message.HasRewards.GetValueOrDefault(false)) - { - message.HasRewards = false; - message.RewardCollected = true; - continue; - } - - // Find invalid items and remove from message - var itemsToRemove = message.Items.Data.Where(item => !itemsDb.ContainsKey(item.Template)).ToList(); - foreach (var itemToRemove in itemsToRemove) - { - if (_coreConfig.Fixes.RemoveModItemsFromProfile) - { - message.Items.Data.Remove(itemToRemove); - logger.Warning( - $"Item: {itemToRemove.Template} has resulted in the deletion of message: {message.Id} from dialog: {dialog.Key}" - ); - } - else - { - logger.Error(serverLocalisationService.GetText("fixer-mod_item_found", itemToRemove.Template.ToString())); - break; - } - } - } - } - - var clothingDb = databaseService.GetTemplates().Customization; - foreach ( - var clothingItem in fullProfile - .CustomisationUnlocks.Where(customisation => customisation.Type == CustomisationType.SUITE) - .ToList() // We're removing element, ToList to allow that to occur - ) - { - if (!clothingDb.ContainsKey(clothingItem.Id)) - { - // Item in profile not found in db, not good - logger.Error(serverLocalisationService.GetText("fixer-clothing_item_found", clothingItem.ToString())); - - if (_coreConfig.Fixes.RemoveModItemsFromProfile) - { - fullProfile.CustomisationUnlocks.Remove(clothingItem); - logger.Warning($"Non-default clothing purchase: {clothingItem} removed from profile"); - } - } - } - - foreach (var repeatable in fullProfile.CharacterData.PmcData.RepeatableQuests ?? []) - { - if (repeatable.ActiveQuests is null) - { - continue; - } - - // ToList to prevent `Collection was modified` exception - foreach (var activeQuest in repeatable.ActiveQuests.ToList()) - { - if (!traderHelper.TraderExists(activeQuest.TraderId)) - { - if (_coreConfig.Fixes.RemoveModItemsFromProfile) - { - logger.Warning( - $"Non-default quest: {activeQuest.Id} from trader: {activeQuest.TraderId} removed from RepeatableQuests list in profile" - ); - repeatable.ActiveQuests.Remove(activeQuest); - } - else - { - logger.Error(serverLocalisationService.GetText("fixer-trader_found", activeQuest.TraderId.ToString())); - break; - } - - continue; - } - - if (activeQuest.Rewards?["Success"] is null) - { - continue; - } - - // Get Item rewards only - foreach (var successReward in activeQuest.Rewards["Success"].Where(reward => reward.Type == RewardType.Item)) - { - if (successReward.Items.Any(item => !itemsDb.ContainsKey(item.Template))) - { - logger.Warning( - $"Non-default repeatable quest: {activeQuest.Id} from trader: {activeQuest.TraderId} removed from RepeatableQuests list in profile" - ); - repeatable.ActiveQuests.Remove(activeQuest); - } - } - } - } - - foreach (var (traderId, _) in fullProfile.TraderPurchases.Where(traderPurchase => !traderHelper.TraderExists(traderPurchase.Key))) - { - if (_coreConfig.Fixes.RemoveModItemsFromProfile) - { - logger.Warning($"Non-default trader: {traderId} purchase removed from traderPurchases list in profile"); - fullProfile.TraderPurchases.Remove(traderId); - } - else - { - logger.Error(serverLocalisationService.GetText("fixer-trader_found", traderId.ToString())); - break; - } - } - } - - /// - /// Check whether a weapon build should be removed from the equipment list. - /// - /// The type of build, used for logging only - /// The build to check for invalid items - /// The items database to use for item lookup - /// True if the build should be removed from the build list, false otherwise - protected bool ShouldRemoveWeaponEquipmentBuild(string buildType, UserBuild build, Dictionary itemsDb) - { - if (buildType == "weapon") - // Get items not found in items db - { - foreach (var item in (build as WeaponBuild).Items.Where(item => !itemsDb.ContainsKey(item.Template))) - { - logger.Error(serverLocalisationService.GetText("fixer-mod_item_found", item.Template.ToString())); - - if (_coreConfig.Fixes.RemoveModItemsFromProfile) - { - logger.Warning($"Item: {item.Template} has resulted in the deletion of {buildType} build: {build.Name}"); - - return true; - } - - break; - } - } - - // TODO: refactor to be generic - - if (buildType == "equipment") - // Get items not found in items db - { - foreach (var item in (build as EquipmentBuild).Items.Where(item => !itemsDb.ContainsKey(item.Template))) - { - logger.Error(serverLocalisationService.GetText("fixer-mod_item_found", item.Template.ToString())); - - if (_coreConfig.Fixes.RemoveModItemsFromProfile) - { - logger.Warning($"Item: {item.Template} has resulted in the deletion of {buildType} build: {build.Name}"); - - return true; - } - - // Found a broken item - break; - } - } - - return false; - } - - /// - /// Checks whether magazine build shou8ld be removed form the build list. - /// - /// The magazine build to check for validity - /// The items database to use for item lookup - /// True if the build should be removed from the build list, false otherwise - protected bool ShouldRemoveMagazineBuild(MagazineBuild magazineBuild, Dictionary itemsDb) - { - foreach (var item in magazineBuild.Items) - { - // Magazine builds can have undefined items in them, skip those - if (item is null) - { - continue; - } - - // Check item exists in itemsDb - if (!itemsDb.ContainsKey(item.TemplateId)) - { - logger.Error(serverLocalisationService.GetText("fixer-mod_item_found", item.TemplateId.ToString())); - - if (_coreConfig.Fixes.RemoveModItemsFromProfile) - { - logger.Warning($"Item: {item.TemplateId} has resulted in the deletion of magazine build: {magazineBuild.Name}"); - - return true; - } - - break; - } - } - - return false; - } - /// /// 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 diff --git a/Libraries/SPTarkov.Server.Core/Services/ProfileMigratorService.cs b/Libraries/SPTarkov.Server.Core/Services/ProfileValidatorService.cs similarity index 73% rename from Libraries/SPTarkov.Server.Core/Services/ProfileMigratorService.cs rename to Libraries/SPTarkov.Server.Core/Services/ProfileValidatorService.cs index 623b1497..be80ac8c 100644 --- a/Libraries/SPTarkov.Server.Core/Services/ProfileMigratorService.cs +++ b/Libraries/SPTarkov.Server.Core/Services/ProfileValidatorService.cs @@ -2,6 +2,7 @@ using System.Text.Json.Nodes; using SPTarkov.DI.Annotations; using SPTarkov.Server.Core.Extensions; +using SPTarkov.Server.Core.Helpers; using SPTarkov.Server.Core.Migration; using SPTarkov.Server.Core.Models.Eft.Profile; using SPTarkov.Server.Core.Models.Utils; @@ -10,15 +11,16 @@ using SPTarkov.Server.Core.Utils; namespace SPTarkov.Server.Core.Services; [Injectable(InjectionType.Singleton)] -public class ProfileMigratorService( +public class ProfileValidatorService( IEnumerable profileMigrations, + ProfileValidatorHelper profileValidatorHelper, TimeUtil timeUtil, - ISptLogger logger + ISptLogger logger ) { private readonly IEnumerable _sortedMigrations = profileMigrations.Sort(); - public SptProfile HandlePendingMigrations(JsonObject profile) + public SptProfile MigrateAndValidateProfile(JsonObject profile) { var profileId = profile["info"]?["id"]?.GetValue(); @@ -35,6 +37,8 @@ public class ProfileMigratorService( var ranMigrations = new List(); + // The initial part of the profile migrations, this allows for fixing up + // Any incorrect typing that might not allow the profile to load foreach (var profileMigration in _sortedMigrations) { if (profileMigration.CanMigrate(profile, ranMigrations)) @@ -59,10 +63,12 @@ public class ProfileMigratorService( sptReadyProfile = profile.Deserialize(JsonUtil.JsonSerializerOptionsNoIndent) ?? throw new InvalidOperationException($"Could not deserialize the profile."); + + profileValidatorHelper.CheckForOrphanedModdedItems(new Models.Common.MongoId(profileId), sptReadyProfile); } catch (Exception ex) { - logger.Critical($"Could not load profile: {profileId}"); + logger.Critical($"Failed to load profile with ID '{profileId}'. The profile will be marked as invalid."); logger.Critical(ex.ToString()); if (ex.StackTrace is not null) @@ -70,8 +76,17 @@ public class ProfileMigratorService( logger.Critical(ex.StackTrace); } - // Throw here, immediately stops execution of the server upon detecting a messed up profile - throw; + sptReadyProfile = new() + { + ProfileInfo = new Info + { + ProfileId = new Models.Common.MongoId(profileId), + Username = profile["info"]?["username"]?.GetValue() ?? "", + InvalidOrUnloadableProfile = true, + }, + }; + + return sptReadyProfile; } foreach (var ranMigration in ranMigrations)