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)