Files
SPT-Server-Build/Core/Services/ProfileFixerService.cs
T
2025-01-13 12:17:25 +00:00

437 lines
17 KiB
C#

using Core.Annotations;
using Core.Helpers;
using Core.Models.Eft.Common;
using Core.Models.Eft.Common.Tables;
using Core.Models.Eft.Hideout;
using Core.Models.Eft.Profile;
using Core.Models.Enums;
using Core.Utils;
using System.Text.RegularExpressions;
using ILogger = Core.Models.Utils.ILogger;
namespace Core.Services;
[Injectable(InjectionType.Singleton)]
public class ProfileFixerService
{
private readonly ILogger _logger;
private readonly HashUtil _hashUtil;
private readonly JsonUtil _jsonUtil;
private readonly ItemHelper _itemHelper;
private readonly DatabaseService _databaseService;
public ProfileFixerService(
ILogger logger,
HashUtil hashUtil,
JsonUtil jsonUtil,
ItemHelper itemHelper,
DatabaseService databaseService)
{
_logger = logger;
_hashUtil = hashUtil;
_jsonUtil = jsonUtil;
_itemHelper = itemHelper;
_databaseService = databaseService;
}
/// <summary>
/// Find issues in the pmc profile data that may cause issues and fix them
/// </summary>
/// <param name="pmcProfile">profile to check and fix</param>
public void CheckForAndFixPmcProfileIssues(PmcData pmcProfile)
{
RemoveDanglingConditionCounters(pmcProfile);
RemoveDanglingTaskConditionCounters(pmcProfile);
RemoveOrphanedQuests(pmcProfile);
VerifyQuestProductionUnlocks(pmcProfile);
FixFavorites(pmcProfile);
if (pmcProfile.Hideout is not null)
{
AddHideoutEliteSlots(pmcProfile);
}
if (pmcProfile.Skills is not null)
{
CheckForSkillsOverMaxLevel(pmcProfile);
}
}
/// <summary>
/// Resolve any dialogue attachments that were accidentally created using the player's equipment ID as
/// the stash root object ID
/// </summary>
/// <param name="fullProfile"></param>
public void CheckForAndFixDialogueAttachments(SptProfile fullProfile)
{
foreach (var traderDialoguesKvP in fullProfile.DialogueRecords)
{
if (traderDialoguesKvP.Value.Messages is null)
{
continue;
}
var traderDialogues = traderDialoguesKvP.Value;
foreach (var message in traderDialogues.Messages) {
// Skip any messages without attached items
if (message.Items?.Data is null || message.Items?.Stash is null)
{
continue;
}
// Skip any messages that don't have a stashId collision with the player's equipment ID
if (message.Items?.Stash != fullProfile.CharacterData?.PmcData?.Inventory?.Equipment)
{
continue;
}
// Otherwise we need to generate a new unique stash ID for this message's attachments
message.Items.Stash = _hashUtil.Generate();
message.Items.Data = _itemHelper.AdoptOrphanedItems(message.Items.Stash, message.Items.Data);
// Because `adoptOrphanedItems` sets the slotId to `hideout`, we need to re-set it to `main` to work with mail
foreach (var item in message.Items.Data.Where(item => item.SlotId == "hideout"))
{
item.SlotId = "main";
}
}
}
}
/// <summary>
/// Find issues in the scav profile data that may cause issues
/// </summary>
/// <param name="scavProfile">profile to check and fix</param>
public void CheckForAndFixScavProfileIssues(PmcData scavProfile)
{
return;
}
/// <summary>
/// Attempt to fix common item issues that corrupt profiles
/// </summary>
/// <param name="pmcProfile">Profile to check items of</param>
public void FixProfileBreakingInventoryItemIssues(PmcData pmcProfile)
{
// Create a mapping of all inventory items, keyed by _id value
var itemMapping = pmcProfile.Inventory.Items
.GroupBy(item => item.Id)
.ToDictionary(x => x.Key, x => x.ToList());
foreach (var mappingKvP in itemMapping) {
// Only one item for this id, not a dupe
if (mappingKvP.Value.Count == 1)
{
continue;
}
_logger.Warning($"{ mappingKvP.Value.Count - 1} duplicate(s) found for item: {mappingKvP.Key}");
var itemAJson = _jsonUtil.Serialize(mappingKvP.Value[0]);
var itemBJson = _jsonUtil.Serialize(mappingKvP.Value[1]);
if (itemAJson == itemBJson)
{
// Both items match, we can safely delete one (A)
var indexOfItemToRemove = pmcProfile.Inventory.Items.IndexOf(mappingKvP.Value[0]);
pmcProfile.Inventory.Items.RemoveAt(indexOfItemToRemove);
_logger.Warning($"Deleted duplicate item: {mappingKvP.Key}");
}
else
{
// Items are different, replace ID with unique value
// Only replace ID if items have no children, we dont want orphaned children
var itemsHaveChildren = pmcProfile.Inventory.Items.Any((x) => x.ParentId == mappingKvP.Key);
if (!itemsHaveChildren)
{
var itemToAdjust = pmcProfile.Inventory.Items.FirstOrDefault((x) => x.Id == mappingKvP.Key);
itemToAdjust.Id = _hashUtil.Generate();
_logger.Warning($"Replace duplicate item Id: {mappingKvP.Key} with {itemToAdjust.Id}");
}
}
}
// Iterate over all inventory items
foreach (var item in pmcProfile.Inventory.Items.Where((x) => x.SlotId is not null)) {
if (item.Upd is null)
{
// Ignore items without a upd object
continue;
}
// Check items with a tags for non-alphanumeric characters and remove
Regex regxp = new Regex("[^a-zA-Z0-9 -]");
if (item.Upd.Tag?.Name is not null && !regxp.IsMatch(item.Upd.Tag.Name))
{
_logger.Warning($"Fixed item: { item.Id}s Tag value, removed invalid characters");
item.Upd.Tag.Name = regxp.Replace(item.Upd.Tag.Name, "");
}
// Check items with StackObjectsCount (undefined)
if (item.Upd.StackObjectsCount is null)
{
_logger.Warning($"Fixed item: {item.Id}s undefined StackObjectsCount value, now set to 1");
item.Upd.StackObjectsCount = 1;
}
}
// Iterate over clothing
var customizationDb = _databaseService.GetTemplates().Customization;
var customizationDbArray = customizationDb.Values;
var playerIsUsec = pmcProfile.Info.Side.ToLower() == "usec";
// Check Head
if (customizationDb[pmcProfile.Customization.Head] is null)
{
var defaultHead = playerIsUsec
? customizationDbArray.FirstOrDefault((x) => x.Name == "DefaultUsecHead")
: customizationDbArray.FirstOrDefault((x) => x.Name == "DefaultBearHead");
pmcProfile.Customization.Head = defaultHead.Id;
}
// check Body
if (customizationDb[pmcProfile.Customization.Body] is null)
{
var defaultBody = playerIsUsec
? customizationDbArray.FirstOrDefault((x) => x.Name == "DefaultUsecBody")
: customizationDbArray.FirstOrDefault((x) => x.Name == "DefaultBearBody");
pmcProfile.Customization.Body = defaultBody.Id;
}
// check Hands
if (customizationDb[pmcProfile.Customization.Hands] is null)
{
var defaultHands = playerIsUsec
? customizationDbArray.FirstOrDefault((x) => x.Name == "DefaultUsecHands")
: customizationDbArray.FirstOrDefault((x) => x.Name == "DefaultBearHands");
pmcProfile.Customization.Hands = defaultHands.Id;
}
// check Hands
if (customizationDb[pmcProfile.Customization.Feet] is null)
{
var defaultFeet = playerIsUsec
? customizationDbArray.FirstOrDefault((x) => x.Name == "DefaultUsecFeet")
: customizationDbArray.FirstOrDefault((x) => x.Name == "DefaultBearFeet");
pmcProfile.Customization.Feet = defaultFeet.Id;
}
}
/// <summary>
/// TODO - make this non-public - currently used by RepeatableQuestController
/// Remove unused condition counters
/// </summary>
/// <param name="pmcProfile">profile to remove old counters from</param>
public void RemoveDanglingConditionCounters(PmcData pmcProfile)
{
if (pmcProfile.TaskConditionCounters is null)
{
return;
}
foreach (var counterKvP in pmcProfile.TaskConditionCounters
.Where(counterKvP => counterKvP.Value.SourceId is null))
{
pmcProfile.TaskConditionCounters.Remove(counterKvP.Key);
}
}
/// <summary>
/// Repeatable quests leave behind TaskConditionCounter objects that make the profile bloat with time, remove them
/// </summary>
/// <param name="pmcProfile">Player profile to check</param>
protected void RemoveDanglingTaskConditionCounters(PmcData pmcProfile)
{
if (pmcProfile.TaskConditionCounters is null)
{
return;
}
var taskConditionKeysToRemove = new List<string>();
var activeRepeatableQuests = GetActiveRepeatableQuests(pmcProfile.RepeatableQuests);
var achievements = _databaseService.GetAchievements();
// Loop over TaskConditionCounters objects and add once we want to remove to counterKeysToRemove
foreach (var TaskConditionCounterKvP in pmcProfile.TaskConditionCounters) {
// Only check if profile has repeatable quests
if (pmcProfile.RepeatableQuests is not null && activeRepeatableQuests.Count > 0)
{
var existsInActiveRepeatableQuests = activeRepeatableQuests.Any(
(quest) => quest.Id == TaskConditionCounterKvP.Value.SourceId);
var existsInQuests = pmcProfile.Quests.Any(
(quest) => quest.QId == TaskConditionCounterKvP.Value.SourceId);
var isAchievementTracker = achievements.Any(
(quest) => quest.Id == TaskConditionCounterKvP.Value.SourceId);
// If task conditions id is neither in activeQuests, quests or achievements - it's stale and should be cleaned up
if (!(existsInActiveRepeatableQuests || existsInQuests || isAchievementTracker))
{
taskConditionKeysToRemove.Add(TaskConditionCounterKvP.Key);
}
}
}
foreach (var counterKeyToRemove in taskConditionKeysToRemove) {
_logger.Debug($"Removed: {counterKeyToRemove} TaskConditionCounter object");
pmcProfile.TaskConditionCounters.Remove(counterKeyToRemove);
}
}
protected List<RepeatableQuest> GetActiveRepeatableQuests(List<PmcDataRepeatableQuest> repeatableQuests)
{
var activeQuests = new List<RepeatableQuest>();
foreach (var repeatableQuest in repeatableQuests.Where(questType => questType.ActiveQuests?.Count > 0)) {
// daily/weekly collection has active quests in them, add to array and return
activeQuests.AddRange(repeatableQuest.ActiveQuests);
}
return activeQuests;
}
/// <summary>
/// After removing mods that add quests, the quest panel will break without removing these
/// </summary>
/// <param name="pmcProfile">Profile to remove dead quests from</param>
protected void RemoveOrphanedQuests(PmcData pmcProfile)
{
var quests = _databaseService.GetQuests();
var profileQuests = pmcProfile.Quests;
var activeRepeatableQuests = GetActiveRepeatableQuests(pmcProfile.RepeatableQuests);
for (var i = profileQuests.Count - 1; i >= 0; i--)
{
if (!quests.ContainsKey(profileQuests[i].QId) || activeRepeatableQuests.Any((x) => x.Id == profileQuests[i].QId))
{
profileQuests.RemoveAt(i);
_logger.Success("Successfully removed orphaned quest that doesn't exist in quest data");
}
}
}
/// <summary>
/// Verify that all quest production unlocks have been applied to the PMC Profile
/// </summary>
/// <param name="pmcProfile">The profile to validate quest productions for</param>
protected void VerifyQuestProductionUnlocks(PmcData pmcProfile)
{
throw new NotImplementedException();
}
/// <summary>
/// Validate that the given profile has the given quest reward production scheme unlocked, and add it if not
/// </summary>
/// <param name="pmcProfile">Profile to check</param>
/// <param name="productionUnlockReward">The quest reward to validate</param>
/// <param name="questDetails">The quest the reward belongs to</param>
protected void VerifyQuestProductionUnlock(PmcData pmcProfile, QuestReward productionUnlockReward, Quest questDetails)
{
throw new NotImplementedException();
}
/// <summary>
/// Initial release of SPT 3.10 used an incorrect favorites structure, reformat
/// the structure to the correct MongoID array structure
/// </summary>
/// <param name="pmcProfile"></param>
protected void FixFavorites(PmcData pmcProfile)
{
throw new NotImplementedException();
}
/// <summary>
/// If the profile has elite Hideout Managment skill, add the additional slots from globals
/// NOTE: This seems redundant, but we will leave it here just in case.
/// </summary>
/// <param name="pmcProfile">profile to add slots to</param>
protected void AddHideoutEliteSlots(PmcData pmcProfile)
{
throw new NotImplementedException();
}
/// <summary>
/// add in objects equal to the number of slots
/// </summary>
/// <param name="areaType">area to check</param>
/// <param name="emptyItemCount">area to update</param>
/// <param name="pmcProfile">profile to update</param>
protected void AddEmptyObjectsToHideoutAreaSlots(HideoutAreas areaType, int emptyItemCount, PmcData pmcProfile)
{
throw new NotImplementedException();
}
protected IList<HideoutSlot> AddObjectsToList(int count, List<HideoutSlot> slots)
{
throw new NotImplementedException();
}
/**
* Check for and cap profile skills at 5100.
* @param pmcProfile profile to check and fix
*/
protected void CheckForSkillsOverMaxLevel(PmcData pmcProfile)
{
throw new NotImplementedException();
}
/**
* Checks profile inventory for items that do not exist inside the items db
* @param sessionId Session id
* @param pmcProfile Profile to check inventory of
*/
public void CheckForOrphanedModdedItems(string sessionId, SptProfile fullProfile)
{
throw new NotImplementedException();
}
/**
* @param buildType The type of build, used for logging only
* @param build The build to check for invalid items
* @param itemsDb The items database to use for item lookup
* @returns True if the build should be removed from the build list, false otherwise
*/
protected bool ShouldRemoveWeaponEquipmentBuild(
string buildType,
WeaponBuild equipmentBuild,
Dictionary<string, TemplateItem> itemsDb)
{
throw new NotImplementedException();
}
/**
* @param magazineBuild The magazine build to check for validity
* @param itemsDb The items database to use for item lookup
* @returns True if the build should be removed from the build list, false otherwise
*/
protected bool ShouldRemoveMagazineBuild(
MagazineBuild magazineBuild,
Dictionary<string, TemplateItem> itemsDb)
{
throw new NotImplementedException();
}
/**
* 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
* @param pmcProfile Profile to update
*/
public void AddMissingHideoutBonusesToProfile(PmcData pmcProfile)
{
throw new NotImplementedException();
}
/**
* @param profileBonuses bonuses from profile
* @param bonus bonus to find
* @returns matching bonus
*/
protected Bonus GetBonusFromProfile(List<Bonus> profileBonuses, StageBonus bonus)
{
throw new NotImplementedException();
}
public void CheckForAndRemoveInvalidTraders(SptProfile fullProfile)
{
throw new NotImplementedException();
}
}