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
This commit is contained in:
Jesse
2025-07-11 14:11:02 +02:00
committed by GitHub
parent 12699d799f
commit 533a7356fd
11 changed files with 751 additions and 24 deletions
@@ -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<Type> PrerequisiteMigrations { get; }
public abstract bool CanMigrate(
JsonObject profile,
IEnumerable<IProfileMigration> 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<string>();
if (versionString is null)
{
return null;
}
var versionNumber = versionString.Split(' ')[0];
return SemanticVersioning.Version.TryParse(versionNumber, out var version)
? version
: null;
}
}
}
@@ -0,0 +1,32 @@
using System.Text.Json.Nodes;
using SPTarkov.Server.Core.Models.Eft.Profile;
namespace SPTarkov.Server.Core.Migration
{
public interface IProfileMigration
{
/// <summary>
/// Allows for adding checks if the profile in question can migrate
/// </summary>
/// <param name="profile">The profile to check</param>
/// <returns>Returns true if the profile can migrate, returns false if not</returns>
public bool CanMigrate(
JsonObject profile,
IEnumerable<IProfileMigration> previouslyRanMigrations
);
/// <summary>
/// Migrate the profile, this should be used to handle and fix old data that has been removed from the <see cref="SptProfile"/> record
/// or a general incompatibility due to different typing
/// </summary>
/// <param name="profile">The profile to migrate</param>
/// <returns>Returns the migrated profile on success, or null if it failed</returns>
public JsonObject? Migrate(JsonObject profile);
/// <summary>
/// Handles post migration of the profile, this can be used to fill new types with (old) data gotten from <see cref="Migrate"/>
/// </summary>
/// <returns>Should return true if successful, should return false if not</returns>
public bool PostMigrate(SptProfile profile);
}
}
@@ -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
{
/// <summary>
/// In 0.16.1.3.35312 BSG changed this to from an int to a hex64 encoded value.
/// </summary>
[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<Type> PrerequisiteMigrations
{
get { return []; }
}
public override bool CanMigrate(
JsonObject profile,
IEnumerable<IProfileMigration> 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<long>(out _);
return versionMatches && seedIsNumeric;
}
public override JsonObject? Migrate(JsonObject profile)
{
profile["characters"]!["pmc"]!["Hideout"]!["Seed"] = Convert.ToHexStringLower(
RandomNumberGenerator.GetBytes(16)
);
return profile;
}
}
}
@@ -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
{
/// <summary>
/// In 16.8.0.37972 BSG added customization for voices, technically this only affects BE profiles, but this should fix these.
/// </summary>
[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<Type> 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<IProfileMigration> 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();
}
}
}
@@ -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<Type> PrerequisiteMigrations
{
get { return [typeof(ThreeTenToThreeEleven)]; }
}
public override bool CanMigrate(
JsonObject profile,
IEnumerable<IProfileMigration> 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<string>(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);
}
}
}
@@ -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<string> _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<Type> PrerequisiteMigrations
{
get { return [typeof(HideoutSeed)]; }
}
public override bool CanMigrate(
JsonObject profile,
IEnumerable<IProfileMigration> 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<string>())
.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<CreateProfileService>()!
.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<RewardHelper>()!
.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);
}
}
}
}
}