From 533a7356fd0df5f131515b2b91be9b7d54260e01 Mon Sep 17 00:00:00 2001 From: Jesse Date: Fri, 11 Jul 2025 14:11:02 +0200 Subject: [PATCH] Add new service to handle profile migrations (#468) * Add new service to handle profile migrations * Handle various null checks * Remove unecessary assignments * Further works on this: - Loads profiles as JObject's initally, so migration can take place on profiles that don't have proper compatability - Adds prerequisite migrations, and sorts them after one another * Throw exception if profile can't be deserialized after migration * Cleanup & use profile version * Further migrations work, support 3.10 & 3.11 profiles upgrading to 4.0 * Update parameter name --- .../Controllers/GameController.cs | 6 - .../Migration/AbstractProfileMigration.cs | 41 +++ .../Migration/IProfileMigration.cs | 32 +++ .../Migration/Migrations/HideoutSeed.cs | 60 ++++ .../Migration/Migrations/TheVoices.cs | 85 ++++++ .../Migrations/ThreeElevenToFourZero.cs | 82 ++++++ .../Migrations/ThreeTenToThreeEleven.cs | 259 ++++++++++++++++++ .../Servers/SaveServer.cs | 10 +- .../Services/CreateProfileService.cs | 2 +- .../Services/ProfileMigratorService.cs | 166 +++++++++++ .../SPTarkov.Server.Core/Utils/JsonUtil.cs | 32 +-- 11 files changed, 751 insertions(+), 24 deletions(-) create mode 100644 Libraries/SPTarkov.Server.Core/Migration/AbstractProfileMigration.cs create mode 100644 Libraries/SPTarkov.Server.Core/Migration/IProfileMigration.cs create mode 100644 Libraries/SPTarkov.Server.Core/Migration/Migrations/HideoutSeed.cs create mode 100644 Libraries/SPTarkov.Server.Core/Migration/Migrations/TheVoices.cs create mode 100644 Libraries/SPTarkov.Server.Core/Migration/Migrations/ThreeElevenToFourZero.cs create mode 100644 Libraries/SPTarkov.Server.Core/Migration/Migrations/ThreeTenToThreeEleven.cs create mode 100644 Libraries/SPTarkov.Server.Core/Services/ProfileMigratorService.cs diff --git a/Libraries/SPTarkov.Server.Core/Controllers/GameController.cs b/Libraries/SPTarkov.Server.Core/Controllers/GameController.cs index dd9ca540..9379eafc 100644 --- a/Libraries/SPTarkov.Server.Core/Controllers/GameController.cs +++ b/Libraries/SPTarkov.Server.Core/Controllers/GameController.cs @@ -70,12 +70,6 @@ public class GameController( return; } - fullProfile.SptData ??= new Spt - { - //TODO: complete - Version = "Replace_me", - }; - fullProfile.SptData.Migrations ??= new Dictionary(); fullProfile.FriendProfileIds ??= []; if (fullProfile.ProfileInfo?.IsWiped is not null && fullProfile.ProfileInfo.IsWiped.Value) diff --git a/Libraries/SPTarkov.Server.Core/Migration/AbstractProfileMigration.cs b/Libraries/SPTarkov.Server.Core/Migration/AbstractProfileMigration.cs new file mode 100644 index 00000000..e7229628 --- /dev/null +++ b/Libraries/SPTarkov.Server.Core/Migration/AbstractProfileMigration.cs @@ -0,0 +1,41 @@ +using System.Text.Json.Nodes; +using SPTarkov.Server.Core.Models.Eft.Profile; + +namespace SPTarkov.Server.Core.Migration +{ + public abstract class AbstractProfileMigration : IProfileMigration + { + public abstract string FromVersion { get; } + public abstract string ToVersion { get; } + public abstract string MigrationName { get; } + + public abstract IEnumerable PrerequisiteMigrations { get; } + + public abstract bool CanMigrate( + JsonObject profile, + IEnumerable previouslyRanMigrations + ); + public abstract JsonObject? Migrate(JsonObject profile); + + public virtual bool PostMigrate(SptProfile profile) + { + return true; + } + + protected SemanticVersioning.Version? GetProfileVersion(JsonObject profile) + { + var versionString = profile["spt"]?["version"]?.GetValue(); + + if (versionString is null) + { + return null; + } + + var versionNumber = versionString.Split(' ')[0]; + + return SemanticVersioning.Version.TryParse(versionNumber, out var version) + ? version + : null; + } + } +} diff --git a/Libraries/SPTarkov.Server.Core/Migration/IProfileMigration.cs b/Libraries/SPTarkov.Server.Core/Migration/IProfileMigration.cs new file mode 100644 index 00000000..25beedc4 --- /dev/null +++ b/Libraries/SPTarkov.Server.Core/Migration/IProfileMigration.cs @@ -0,0 +1,32 @@ +using System.Text.Json.Nodes; +using SPTarkov.Server.Core.Models.Eft.Profile; + +namespace SPTarkov.Server.Core.Migration +{ + public interface IProfileMigration + { + /// + /// Allows for adding checks if the profile in question can migrate + /// + /// The profile to check + /// Returns true if the profile can migrate, returns false if not + public bool CanMigrate( + JsonObject profile, + IEnumerable previouslyRanMigrations + ); + + /// + /// Migrate the profile, this should be used to handle and fix old data that has been removed from the record + /// or a general incompatibility due to different typing + /// + /// The profile to migrate + /// Returns the migrated profile on success, or null if it failed + public JsonObject? Migrate(JsonObject profile); + + /// + /// Handles post migration of the profile, this can be used to fill new types with (old) data gotten from + /// + /// Should return true if successful, should return false if not + public bool PostMigrate(SptProfile profile); + } +} diff --git a/Libraries/SPTarkov.Server.Core/Migration/Migrations/HideoutSeed.cs b/Libraries/SPTarkov.Server.Core/Migration/Migrations/HideoutSeed.cs new file mode 100644 index 00000000..ffa2bb72 --- /dev/null +++ b/Libraries/SPTarkov.Server.Core/Migration/Migrations/HideoutSeed.cs @@ -0,0 +1,60 @@ +using System.Security.Cryptography; +using System.Text.Json.Nodes; +using SPTarkov.DI.Annotations; +using Range = SemanticVersioning.Range; + +namespace SPTarkov.Server.Core.Migration.Migrations +{ + /// + /// In 0.16.1.3.35312 BSG changed this to from an int to a hex64 encoded value. + /// + [Injectable] + public class HideoutSeed : AbstractProfileMigration + { + public override string FromVersion + { + get { return "~3.10"; } + } + + public override string ToVersion + { + get { return "3.11"; } + } + + public override string MigrationName + { + get { return "HideoutSeed311-SPTSharp"; } + } + + public override IEnumerable PrerequisiteMigrations + { + get { return []; } + } + + public override bool CanMigrate( + JsonObject profile, + IEnumerable previouslyRanMigrations + ) + { + var profileVersion = GetProfileVersion(profile); + var fromRange = Range.Parse(FromVersion); + var versionMatches = fromRange.IsSatisfied(profileVersion); + + var seedNode = profile["characters"]?["pmc"]?["Hideout"]?["Seed"]; + + var seedIsNumeric = + seedNode is JsonValue seedValue && seedValue.TryGetValue(out _); + + return versionMatches && seedIsNumeric; + } + + public override JsonObject? Migrate(JsonObject profile) + { + profile["characters"]!["pmc"]!["Hideout"]!["Seed"] = Convert.ToHexStringLower( + RandomNumberGenerator.GetBytes(16) + ); + + return profile; + } + } +} diff --git a/Libraries/SPTarkov.Server.Core/Migration/Migrations/TheVoices.cs b/Libraries/SPTarkov.Server.Core/Migration/Migrations/TheVoices.cs new file mode 100644 index 00000000..01e9d15c --- /dev/null +++ b/Libraries/SPTarkov.Server.Core/Migration/Migrations/TheVoices.cs @@ -0,0 +1,85 @@ +using System.Text.Json.Nodes; +using SPTarkov.DI.Annotations; +using SPTarkov.Server.Core.Services; +using Range = SemanticVersioning.Range; + +namespace SPTarkov.Server.Core.Migration.Migrations +{ + /// + /// In 16.8.0.37972 BSG added customization for voices, technically this only affects BE profiles, but this should fix these. + /// + [Injectable] + public class TheVoices(DatabaseService databaseService) : AbstractProfileMigration + { + public override string FromVersion + { + get { return "~4.0"; } + } + + public override string ToVersion + { + get { return "~4.0"; } + } + + public override string MigrationName + { + get { return "TheVoices400"; } + } + + public override IEnumerable PrerequisiteMigrations + { + // Requires ThreeTenToThreeEleven on legacy profiles, due to that changing customization for the first time + get { return [typeof(ThreeTenToThreeEleven)]; } + } + + public override bool CanMigrate( + JsonObject profile, + IEnumerable previouslyRanMigrations + ) + { + bool voiceIsMissing = profile["characters"]?["pmc"]?["Customization"]?["Voice"] == null; + + return voiceIsMissing; + } + + public override JsonObject? Migrate(JsonObject profile) + { + HandlePmcVoice(profile); + HandleScavVoice(profile); + + return profile; + } + + private void HandlePmcVoice(JsonObject profileObject) + { + var pmcInfo = profileObject["characters"]!["pmc"]!["Info"] as JsonObject; + + var oldVoice = pmcInfo["Voice"]?.ToString() ?? ""; + pmcInfo.Remove("Voice"); + + var voiceMongoId = databaseService + .GetCustomization() + .FirstOrDefault(x => x.Value.Properties.Name == oldVoice) + .Key; + + profileObject["characters"]!["pmc"]!["Customization"]!["Voice"] = + voiceMongoId.ToString(); + } + + private void HandleScavVoice(JsonObject profileObject) + { + var pmcInfo = profileObject["characters"]!["scav"]!["Info"] as JsonObject; + + var oldVoice = pmcInfo["Voice"]?.ToString() ?? ""; + pmcInfo.Remove("Voice"); + + var voiceMongoId = databaseService + .GetCustomization() + .FirstOrDefault(x => x.Value.Properties.Name == oldVoice) + .Key; + + profileObject["characters"]!["scav"]!["Customization"]!["Voice"] = + voiceMongoId.ToString(); + } + } +} diff --git a/Libraries/SPTarkov.Server.Core/Migration/Migrations/ThreeElevenToFourZero.cs b/Libraries/SPTarkov.Server.Core/Migration/Migrations/ThreeElevenToFourZero.cs new file mode 100644 index 00000000..86f157c1 --- /dev/null +++ b/Libraries/SPTarkov.Server.Core/Migration/Migrations/ThreeElevenToFourZero.cs @@ -0,0 +1,82 @@ +using System.Text.Json.Nodes; +using SPTarkov.DI.Annotations; +using SPTarkov.Server.Core.Models.Eft.Profile; +using SPTarkov.Server.Core.Utils; +using Range = SemanticVersioning.Range; + +namespace SPTarkov.Server.Core.Migration.Migrations +{ + [Injectable] + public class ThreeElevenToFourZero(Watermark watermark) : AbstractProfileMigration + { + public override string FromVersion + { + get { return "~3.11"; } + } + + public override string ToVersion + { + get { return "4.0"; } + } + + public override string MigrationName + { + get { return "311x-SPTSharp"; } + } + + public override IEnumerable PrerequisiteMigrations + { + get { return [typeof(ThreeTenToThreeEleven)]; } + } + + public override bool CanMigrate( + JsonObject profile, + IEnumerable previouslyRanMigrations + ) + { + var profileVersion = GetProfileVersion(profile); + + var fromRange = Range.Parse(FromVersion); + + var versionMatches = + fromRange.IsSatisfied(profileVersion) + || PrerequisiteMigrations.All(prereq => + previouslyRanMigrations.Any(r => r.GetType() == prereq) + ); + + return versionMatches; + } + + public override JsonObject? Migrate(JsonObject profile) + { + if (profile["characters"]!["pmc"]!["Hideout"]!["Production"] is JsonObject production) + { + foreach (var entry in production) + { + if ( + entry.Value is JsonObject productionEntry + && productionEntry["StartTimestamp"] is JsonValue startTimestampValue + && startTimestampValue.TryGetValue(out var startTimestampStr) + && long.TryParse(startTimestampStr, out var startTimestampInt) + ) + { + productionEntry["StartTimestamp"] = startTimestampInt; + } + } + } + + //Todo: TaskConditionCounters CounterCrafting? + + return profile; + } + + public override bool PostMigrate(SptProfile profile) + { + profile.SptData.ExtraRepeatableQuests = []; + + profile.SptData.Version = $"{watermark.GetVersionTag()} (Migrated from 3.11)"; + + return base.PostMigrate(profile); + } + } +} diff --git a/Libraries/SPTarkov.Server.Core/Migration/Migrations/ThreeTenToThreeEleven.cs b/Libraries/SPTarkov.Server.Core/Migration/Migrations/ThreeTenToThreeEleven.cs new file mode 100644 index 00000000..0cb81d30 --- /dev/null +++ b/Libraries/SPTarkov.Server.Core/Migration/Migrations/ThreeTenToThreeEleven.cs @@ -0,0 +1,259 @@ +using System.Text.Json.Nodes; +using SPTarkov.DI.Annotations; +using SPTarkov.Server.Core.Extensions; +using SPTarkov.Server.Core.Helpers; +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.Services; +using SPTarkov.Server.Core.Utils; +using Range = SemanticVersioning.Range; + +namespace SPTarkov.Server.Core.Migration.Migrations +{ + [Injectable] + public class ThreeTenToThreeEleven( + DatabaseService databaseService, + // Yes, referencing the helpers directly causes a circular dependency. Too bad! + IServiceProvider serviceProvider + ) : AbstractProfileMigration + { + private List _oldSuiteData = []; + + public override string FromVersion + { + get { return "~3.10"; } + } + + public override string ToVersion + { + get { return "3.11"; } + } + + public override string MigrationName + { + get { return "310x-SPTSharp"; } + } + + public override IEnumerable PrerequisiteMigrations + { + get { return [typeof(HideoutSeed)]; } + } + + public override bool CanMigrate( + JsonObject profile, + IEnumerable previouslyRanMigrations + ) + { + var profileVersion = GetProfileVersion(profile); + + var fromRange = Range.Parse(FromVersion); + + var versionMatches = fromRange.IsSatisfied(profileVersion); + + return versionMatches; + } + + public override JsonObject? Migrate(JsonObject profile) + { + if (profile["suits"] is JsonArray suitsArray) + { + _oldSuiteData = suitsArray + .Select(node => node?.GetValue()) + .Where(suit => suit != null) + .ToList()!; + } + + profile.Remove("suits"); + + return profile; + } + + public override bool PostMigrate(SptProfile profile) + { + if (profile.CustomisationUnlocks is null) + { + profile.CustomisationUnlocks = []; + profile.AddCustomisationUnlocksToProfile(); + } + + if (profile.CharacterData.PmcData.Prestige is null) + { + profile.CharacterData.PmcData.Prestige = []; + } + + if (profile.CharacterData.PmcData.Inventory.HideoutCustomizationStashId is null) + { + profile.CharacterData.PmcData.Inventory.HideoutCustomizationStashId = new( + "676db384777490e23c45b657" + ); + + //Directly injecting CreateProfileService causes a circular dependency which I can't be bothered to fix just for this + serviceProvider + .GetService()! + .AddMissingInternalContainersToProfile(profile.CharacterData.PmcData); + } + + if (profile.CharacterData.PmcData.Hideout.Customization is null) + { + profile.CharacterData.PmcData.Hideout.Customization = new Dictionary< + string, + Models.Common.MongoId + > + { + { "Wall", new("675844bdf94a97cbbe096f1a") }, + { "Floor", new("6758443ff94a97cbbe096f18") }, + { "Light", new("675fe8abbc3deae49a0b947f") }, + { "Ceiling", new("673b3f977038192ee006aa09") }, + { "ShootingRangeMark", new("67585d416c72998cf60ed85a") }, + }; + } + + if (profile.CharacterData.PmcData.Info.Side == "Bear") + { + ProcessBearProfile(profile); + } + + if (profile.CharacterData.PmcData.Info.Side == "Usec") + { + ProcessUsecProfile(profile); + } + + if (profile.CharacterData.PmcData.Achievements.Count > 0) + { + var achievementsDb = databaseService.GetTemplates().Achievements; + + foreach (var achievementId in profile.CharacterData.PmcData.Achievements.Keys) + { + var achievementDb = achievementsDb.FirstOrDefault(a => a.Id == achievementId); + var rewards = achievementDb?.Rewards; + + if (rewards == null) + { + continue; + } + + // Only hand out the new hideout customization rewards. + var filteredRewards = rewards + .Where(r => r.Type == RewardType.CustomizationDirect) + .ToList(); + + //Directly injecting RewardHelper causes a circular dependency which I can't be bothered to fix just for this + serviceProvider + .GetService()! + .ApplyRewards( + filteredRewards, + CustomisationSource.ACHIEVEMENT, + profile, + profile.CharacterData.PmcData, + achievementId + ); + } + } + + return true; + } + + private void ProcessBearProfile(SptProfile profile) + { + // Reset clothing customization back to default as customization changed in 3.11 + profile.CharacterData.PmcData.Customization.Body = new("5cc0858d14c02e000c6bea66"); + profile.CharacterData.PmcData.Customization.Feet = new("5cc085bb14c02e000e67a5c5"); + profile.CharacterData.PmcData.Customization.Hands = new("5cc0876314c02e000c6bea6b"); + profile.CharacterData.PmcData.Customization.DogTag = new("674731c8bafff850080488bb"); + + if (profile.CharacterData.PmcData.Info.GameVersion == "edge_of_darkness") + { + profile.CharacterData.PmcData.Customization.DogTag = new( + "6746fd09bafff85008048838" + ); + } + + if (profile.CharacterData.PmcData.Info.GameVersion == "unheard_edition") + { + profile.CharacterData.PmcData.Customization.DogTag = new( + "67471928d17d6431550563b5" + ); + } + + foreach (var oldSuite in _oldSuiteData) + { + // Default Bear clothing, dont need to add this + if ( + oldSuite == "5cd946231388ce000d572fe3" + || oldSuite == "5cd945d71388ce000a659dfb" + || oldSuite == "666841a02537107dc508b704" + ) + { + continue; + } + + var trader = databaseService.GetTrader("5ac3b934156ae10c4430e83c"); + var traderClothing = trader?.Suits?.FirstOrDefault(s => s.SuiteId == oldSuite); + + if (traderClothing != null) + { + var clothingToAdd = new CustomisationStorage + { + Id = traderClothing.SuiteId, + Source = CustomisationSource.UNLOCKED_IN_GAME, + Type = CustomisationType.SUITE, + }; + + profile.CustomisationUnlocks.Add(clothingToAdd); + } + } + } + + private void ProcessUsecProfile(SptProfile profile) + { + // Reset clothing customization back to default as customization changed in 3.11 + profile.CharacterData.PmcData.Customization.Body = new("5cde95d97d6c8b647a3769b0"); //Usec default clothing + profile.CharacterData.PmcData.Customization.Feet = new("5cde95ef7d6c8b04713c4f2d"); + profile.CharacterData.PmcData.Customization.Hands = new("5cde95fa7d6c8b04737c2d13"); + profile.CharacterData.PmcData.Customization.DogTag = new("674731d1170146228c0d222a"); //Usec base dogtag + + if (profile.CharacterData.PmcData.Info.GameVersion == "edge_of_darkness") + { + profile.CharacterData.PmcData.Customization.DogTag = new( + "67471938bafff850080488b7" + ); + } + + if (profile.CharacterData.PmcData.Info.GameVersion == "unheard_edition") + { + profile.CharacterData.PmcData.Customization.DogTag = new( + "6747193f170146228c0d2226" + ); + } + + foreach (var oldSuite in _oldSuiteData) + { + // Default Usec clothing, dont need to add this + if ( + oldSuite == "5cde9ec17d6c8b04723cf479" + || oldSuite == "5cde9e957d6c8b0474535da7" + || oldSuite == "666841a02537107dc508b704" + ) + { + continue; + } + + var trader = databaseService.GetTrader("5ac3b934156ae10c4430e83c"); + var traderClothing = trader?.Suits?.FirstOrDefault(s => s.SuiteId == oldSuite); + + if (traderClothing != null) + { + var clothingToAdd = new CustomisationStorage + { + Id = traderClothing.SuiteId, + Source = CustomisationSource.UNLOCKED_IN_GAME, + Type = CustomisationType.SUITE, + }; + + profile.CustomisationUnlocks.Add(clothingToAdd); + } + } + } + } +} diff --git a/Libraries/SPTarkov.Server.Core/Servers/SaveServer.cs b/Libraries/SPTarkov.Server.Core/Servers/SaveServer.cs index 293695b9..7a51dfa1 100644 --- a/Libraries/SPTarkov.Server.Core/Servers/SaveServer.cs +++ b/Libraries/SPTarkov.Server.Core/Servers/SaveServer.cs @@ -1,5 +1,7 @@ using System.Collections.Concurrent; using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Nodes; using SPTarkov.DI.Annotations; using SPTarkov.Server.Core.DI; using SPTarkov.Server.Core.Models.Common; @@ -20,6 +22,7 @@ public class SaveServer( JsonUtil jsonUtil, HashUtil hashUtil, ServerLocalisationService serverLocalisationService, + ProfileMigratorService profileMigratorService, ISptLogger logger, ConfigServer configServer ) @@ -220,7 +223,12 @@ public class SaveServer( if (fileUtil.FileExists(filePath)) // File found, store in profiles[] { - profiles[sessionID] = await jsonUtil.DeserializeFromFileAsync(filePath); + var profile = await jsonUtil.DeserializeFromFileAsync(filePath); + + if (profile is not null) + { + profiles[sessionID] = profileMigratorService.HandlePendingMigrations(profile); + } } // Run callbacks diff --git a/Libraries/SPTarkov.Server.Core/Services/CreateProfileService.cs b/Libraries/SPTarkov.Server.Core/Services/CreateProfileService.cs index 1b855a78..98c61486 100644 --- a/Libraries/SPTarkov.Server.Core/Services/CreateProfileService.cs +++ b/Libraries/SPTarkov.Server.Core/Services/CreateProfileService.cs @@ -293,7 +293,7 @@ public class CreateProfileService( /// DOES NOT check that stash exists /// /// Profile to check - protected void AddMissingInternalContainersToProfile(PmcData pmcData) + public void AddMissingInternalContainersToProfile(PmcData pmcData) { if ( !pmcData.Inventory.Items.Any(item => diff --git a/Libraries/SPTarkov.Server.Core/Services/ProfileMigratorService.cs b/Libraries/SPTarkov.Server.Core/Services/ProfileMigratorService.cs new file mode 100644 index 00000000..30733f20 --- /dev/null +++ b/Libraries/SPTarkov.Server.Core/Services/ProfileMigratorService.cs @@ -0,0 +1,166 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using SPTarkov.DI.Annotations; +using SPTarkov.Server.Core.Helpers; +using SPTarkov.Server.Core.Migration; +using SPTarkov.Server.Core.Models.Eft.Profile; +using SPTarkov.Server.Core.Models.Utils; +using SPTarkov.Server.Core.Utils; + +namespace SPTarkov.Server.Core.Services +{ + [Injectable(InjectionType.Singleton)] + public class ProfileMigratorService( + IEnumerable profileMigrations, + TimeUtil timeUtil, + ISptLogger logger + ) + { + private IEnumerable _sortedMigrations = []; + + public SptProfile HandlePendingMigrations(JsonObject profile) + { + // On the initial run, begin sorting our migrations + // This will sort it so that any non prerequisite migrations go first + // And then all of the prerequisite ones. + if (!_sortedMigrations.Any()) + { + _sortedMigrations = SortMigrations(); + } + + var profileId = profile["info"]?["id"]?.GetValue(); + + // Profile is due for a wipe or a reset, do not continue here. + if ( + profile["characters"]?["pmc"]?["Info"] == null + || profile["characters"]?["scav"]?["Info"] == null + || (profile["info"]?["wipe"]?.GetValue() == true) + ) + { + return profile.Deserialize(JsonUtil.JsonSerializerOptionsNoIndent) + ?? throw new InvalidOperationException( + $"Could not deserialize the profile {profileId}" + ); + ; + } + + var ranMigrations = new List(); + + foreach (var profileMigration in _sortedMigrations) + { + if (profileMigration.CanMigrate(profile, ranMigrations)) + { + logger.Warning( + $"{profileId} has a pending profile migration: {profileMigration.MigrationName}" + ); + + var migratedProfile = profileMigration.Migrate(profile); + + if (migratedProfile is not null) + { + profile = migratedProfile; + + ranMigrations.Add(profileMigration); + } + } + } + + var SptReadyProfile = + profile.Deserialize(JsonUtil.JsonSerializerOptionsNoIndent) + ?? throw new InvalidOperationException( + $"Could not deserialize the profile {profileId}" + ); + + foreach (var ranMigration in ranMigrations) + { + if (ranMigration.PostMigrate(SptReadyProfile)) + { + logger.Success( + $"{profileId} successfully ran profile migration: {ranMigration.MigrationName}" + ); + + SptReadyProfile.SptData.Migrations.Add( + ranMigration.MigrationName, + timeUtil.GetTimeStamp() + ); + } + } + + return SptReadyProfile; + } + + protected void SetCompletedMigration(JsonObject profile, string migrationName) + { + var profileMigrations = profile["spt"]["migrations"] as JsonObject; + + profileMigrations[migrationName] = JsonValue.Create(timeUtil.GetTimeStamp()); + } + + protected IEnumerable SortMigrations() + { + var sortedMigrations = new List(); + var visitedMigrations = new Dictionary(); + var migrationDict = profileMigrations + .Cast() + .ToDictionary(m => m.GetType()); + + foreach (var migration in profileMigrations.Cast()) + { + VisitMigrationForSort( + migration, + migrationDict, + visitedMigrations, + sortedMigrations + ); + } + + return sortedMigrations; + } + + protected void VisitMigrationForSort( + AbstractProfileMigration migration, + Dictionary migrationTypeDictionary, + Dictionary visitedTypeDictionary, + List sortedMigrations + ) + { + var migrationType = migration.GetType(); + + if (visitedTypeDictionary.TryGetValue(migrationType, out var isVisited)) + { + if (isVisited) + { + return; + } + + // Big error, two migrations should never depend on one another + throw new InvalidOperationException( + $"Cycle detected in migration prerequisites involving: {migrationType.Name}" + ); + } + + // Mark the current migration type for visiting + visitedTypeDictionary[migrationType] = false; + + foreach (var prerequisiteType in migration.PrerequisiteMigrations) + { + if (!migrationTypeDictionary.TryGetValue(prerequisiteType, out var prereqMigration)) + { + continue; + } + + // Visit the next prerequisite + VisitMigrationForSort( + prereqMigration, + migrationTypeDictionary, + visitedTypeDictionary, + sortedMigrations + ); + } + + // Done visiting, mark it as fully visited and add it to the sorted migrations + visitedTypeDictionary[migrationType] = true; + sortedMigrations.Add(migration); + } + } +} diff --git a/Libraries/SPTarkov.Server.Core/Utils/JsonUtil.cs b/Libraries/SPTarkov.Server.Core/Utils/JsonUtil.cs index 72f55d25..ca9871f5 100644 --- a/Libraries/SPTarkov.Server.Core/Utils/JsonUtil.cs +++ b/Libraries/SPTarkov.Server.Core/Utils/JsonUtil.cs @@ -9,12 +9,12 @@ namespace SPTarkov.Server.Core.Utils; [Injectable(InjectionType.Singleton)] public class JsonUtil { - private static JsonSerializerOptions? _jsonSerializerOptionsIndented; - private static JsonSerializerOptions? _jsonSerializerOptionsNoIndent; + public static JsonSerializerOptions? JsonSerializerOptionsIndented { get; private set; } + public static JsonSerializerOptions? JsonSerializerOptionsNoIndent { get; private set; } public JsonUtil(IEnumerable registrators) { - _jsonSerializerOptionsNoIndent = new JsonSerializerOptions() + JsonSerializerOptionsNoIndent = new JsonSerializerOptions() { WriteIndented = false, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, @@ -26,11 +26,11 @@ public class JsonUtil { foreach (var converter in registrator.GetJsonConverters()) { - _jsonSerializerOptionsNoIndent.Converters.Add(converter); + JsonSerializerOptionsNoIndent.Converters.Add(converter); } } - _jsonSerializerOptionsIndented = new JsonSerializerOptions(_jsonSerializerOptionsNoIndent) + JsonSerializerOptionsIndented = new JsonSerializerOptions(JsonSerializerOptionsNoIndent) { WriteIndented = true, }; @@ -46,7 +46,7 @@ public class JsonUtil { return string.IsNullOrEmpty(json) ? default - : JsonSerializer.Deserialize(json, _jsonSerializerOptionsNoIndent); + : JsonSerializer.Deserialize(json, JsonSerializerOptionsNoIndent); } /// @@ -59,7 +59,7 @@ public class JsonUtil { return string.IsNullOrEmpty(json) ? null - : JsonSerializer.Deserialize(json, type, _jsonSerializerOptionsNoIndent); + : JsonSerializer.Deserialize(json, type, JsonSerializerOptionsNoIndent); } /// @@ -76,7 +76,7 @@ public class JsonUtil using (FileStream fs = new(file, FileMode.Open, FileAccess.Read)) { - return JsonSerializer.Deserialize(fs, _jsonSerializerOptionsNoIndent); + return JsonSerializer.Deserialize(fs, JsonSerializerOptionsNoIndent); } } @@ -101,7 +101,7 @@ public class JsonUtil useAsync: true ); - return await JsonSerializer.DeserializeAsync(fs, _jsonSerializerOptionsNoIndent); + return await JsonSerializer.DeserializeAsync(fs, JsonSerializerOptionsNoIndent); } /// @@ -119,7 +119,7 @@ public class JsonUtil using (FileStream fs = new(file, FileMode.Open, FileAccess.Read)) { - return JsonSerializer.Deserialize(fs, type, _jsonSerializerOptionsNoIndent); + return JsonSerializer.Deserialize(fs, type, JsonSerializerOptionsNoIndent); } } @@ -145,7 +145,7 @@ public class JsonUtil useAsync: true ); - return await JsonSerializer.DeserializeAsync(fs, type, _jsonSerializerOptionsNoIndent); + return await JsonSerializer.DeserializeAsync(fs, type, JsonSerializerOptionsNoIndent); } /// @@ -156,7 +156,7 @@ public class JsonUtil /// public object? DeserializeFromFileStream(FileStream fs, Type type) { - return JsonSerializer.Deserialize(fs, type, _jsonSerializerOptionsNoIndent); + return JsonSerializer.Deserialize(fs, type, JsonSerializerOptionsNoIndent); } /// @@ -167,7 +167,7 @@ public class JsonUtil /// public async Task DeserializeFromFileStreamAsync(FileStream fs, Type type) { - return await JsonSerializer.DeserializeAsync(fs, type, _jsonSerializerOptionsNoIndent); + return await JsonSerializer.DeserializeAsync(fs, type, JsonSerializerOptionsNoIndent); } /// @@ -177,7 +177,7 @@ public class JsonUtil /// T public async Task DeserializeFromMemoryStreamAsync(MemoryStream ms) { - return await JsonSerializer.DeserializeAsync(ms, _jsonSerializerOptionsNoIndent); + return await JsonSerializer.DeserializeAsync(ms, JsonSerializerOptionsNoIndent); } /// @@ -193,7 +193,7 @@ public class JsonUtil ? null : JsonSerializer.Serialize( obj, - indented ? _jsonSerializerOptionsIndented : _jsonSerializerOptionsNoIndent + indented ? JsonSerializerOptionsIndented : JsonSerializerOptionsNoIndent ); } @@ -211,7 +211,7 @@ public class JsonUtil : JsonSerializer.Serialize( obj, type, - indented ? _jsonSerializerOptionsIndented : _jsonSerializerOptionsNoIndent + indented ? JsonSerializerOptionsIndented : JsonSerializerOptionsNoIndent ); } }