using System.Collections.Frozen; using SPTarkov.DI.Annotations; using SPTarkov.Server.Core.Extensions; 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.Profile; using SPTarkov.Server.Core.Models.Enums; using SPTarkov.Server.Core.Models.Spt.Config; using SPTarkov.Server.Core.Models.Spt.Logging; using SPTarkov.Server.Core.Models.Utils; using SPTarkov.Server.Core.Servers; using SPTarkov.Server.Core.Services; using SPTarkov.Server.Core.Utils; using SPTarkov.Server.Core.Utils.Cloners; namespace SPTarkov.Server.Core.Helpers; [Injectable] public class ProfileHelper( ISptLogger logger, ICloner cloner, SaveServer saveServer, DatabaseService databaseService, Watermark watermark, TimeUtil timeUtil, ServerLocalisationService serverLocalisationService, ConfigServer configServer ) { protected static readonly FrozenSet GameEditionsWithFreeRefresh = ["edge_of_darkness", "unheard_edition"]; protected readonly InventoryConfig InventoryConfig = configServer.GetConfig(); /// /// Remove/reset a completed quest condition from players profile quest data /// /// Player profile /// Quest with condition to remove public void RemoveQuestConditionFromProfile(PmcData pmcData, Dictionary questConditionId) { foreach (var questId in questConditionId) { var conditionId = questId.Value; var profileQuest = pmcData.Quests?.FirstOrDefault(q => q.QId == conditionId); // Remove condition profileQuest?.CompletedConditions?.Remove(conditionId); } } /// /// Get all profiles from server /// /// Dictionary of profiles public Dictionary GetProfiles() { return saveServer.GetProfiles(); } /// /// Get the pmc and scav profiles as an array by profile id /// /// Session/Player id /// Array of PmcData objects public List GetCompleteProfile(MongoId sessionId) { var output = new List(); if (IsWiped(sessionId)) { return output; } var fullProfileClone = cloner.Clone(GetFullProfile(sessionId))!; // Sanitize any data the client can not receive SanitizeProfileForClient(fullProfileClone); // PMC must be at array index 0, scav at 1 output.Add(fullProfileClone.CharacterData!.PmcData!); output.Add(fullProfileClone.CharacterData!.ScavData!); return output; } /// /// Sanitize any information from the profile that the client does not expect to receive /// /// A clone of the full player profile protected void SanitizeProfileForClient(SptProfile clonedProfile) { if (clonedProfile.CharacterData?.PmcData?.TradersInfo?.Values is null) { return; } // Remove `loyaltyLevel` from `TradersInfo`, as otherwise it causes the client to not // properly calculate the player's `loyaltyLevel` foreach (var trader in clonedProfile.CharacterData.PmcData.TradersInfo.Values) { trader.LoyaltyLevel = null; } } /// /// Check if a nickname is used by another profile loaded by the server /// /// nickname request object /// Session id /// True if already in use public bool IsNicknameTaken(ValidateNicknameRequestData nicknameRequest, MongoId sessionId) { var allProfiles = saveServer.GetProfiles().Values; // Find a profile that doesn't have same session id but has same name return allProfiles.Any(profile => // Valid profile ProfileHasInfoProperty(profile) && profile.ProfileInfo?.ProfileId != sessionId // SessionIds dont match && StringsMatch( profile.CharacterData?.PmcData?.Info?.LowerNickname?.ToLowerInvariant()!, nicknameRequest.Nickname?.ToLowerInvariant()! ) ); // Nicknames do } protected bool ProfileHasInfoProperty(SptProfile profile) { return profile.CharacterData?.PmcData?.Info != null; } protected bool StringsMatch(string stringA, string stringB) { return stringA == stringB; } /// /// Add experience to a PMC inside the players profile /// /// Session id /// Experience to add to PMC character public void AddExperienceToPmc(MongoId sessionId, int experienceToAdd) { var pmcData = GetPmcProfile(sessionId); if (pmcData?.Info != null) { pmcData.Info.Experience += experienceToAdd; } else { logger.Error($"Profile {sessionId} does not exist"); } } /// /// Iterate all profiles and find matching pmc profile by provided id /// /// Profile id to find /// PmcData public PmcData? GetProfileByPmcId(MongoId pmcId) { return saveServer.GetProfiles().Values.First(p => p.CharacterData?.PmcData?.Id == pmcId).CharacterData?.PmcData; } /// /// Get experience value for given level /// /// Level to get xp for /// Number of xp points for level public int? GetExperience(int level) { var playerLevel = level; var expTable = databaseService.GetGlobals().Configuration.Exp.Level.ExperienceTable; int? exp = 0; if (playerLevel >= expTable.Length) // make sure to not go out of bounds { playerLevel = expTable.Length - 1; } for (var i = 0; i < playerLevel; i++) { exp += expTable[i].Experience; } return exp; } /// /// Get the max level a player can be /// /// Max level public int GetMaxLevel() { return databaseService.GetGlobals().Configuration.Exp.Level.ExperienceTable.Length - 1; } /// /// Get default Spt data object /// /// Spt public Spt GetDefaultSptDataObject() { return new Spt { Version = watermark.GetVersionTag(true), Mods = [], ReceivedGifts = [], BlacklistedItemTemplates = [], FreeRepeatableRefreshUsedCount = [], Migrations = [], CultistRewards = [], PendingPrestige = null, ExtraRepeatableQuests = [], }; } /// /// Get full representation of a players profile json /// /// Profile id to get /// SptProfile object public SptProfile GetFullProfile(MongoId sessionId) { return saveServer.GetProfile(sessionId); } /// /// Get full representation of a players profile JSON by the account ID, or undefined if not found /// /// Account ID to find /// public SptProfile? GetFullProfileByAccountId(string accountId) { var check = int.TryParse(accountId, out var aid); if (!check) { logger.Error($"Account {accountId} does not exist"); } return saveServer.GetProfiles().FirstOrDefault(p => p.Value.ProfileInfo?.Aid == aid).Value; } /// /// Retrieve a ChatRoomMember formatted profile for the given session ID /// /// The session ID to return the profile for /// public SearchFriendResponse? GetChatRoomMemberFromSessionId(MongoId sessionId) { var pmcProfile = GetFullProfile(sessionId).CharacterData?.PmcData; return pmcProfile is null ? null : GetChatRoomMemberFromPmcProfile(pmcProfile); } /// /// Retrieve a ChatRoomMember formatted profile for the given PMC profile data /// /// The PMC profile data to format into a ChatRoomMember structure /// public SearchFriendResponse GetChatRoomMemberFromPmcProfile(PmcData pmcProfile) { return new SearchFriendResponse { Id = pmcProfile.Id!.Value, Aid = pmcProfile.Aid, Info = new UserDialogDetails { Nickname = pmcProfile.Info!.Nickname, Side = pmcProfile.Info.Side, Level = pmcProfile.Info.Level, MemberCategory = pmcProfile.Info.MemberCategory, SelectedMemberCategory = pmcProfile.Info.SelectedMemberCategory, }, }; } /// /// Get a PMC profile by its session id /// /// Profile id to return /// PmcData object public PmcData? GetPmcProfile(MongoId sessionId) { return GetFullProfile(sessionId).CharacterData?.PmcData; } /// /// Is given user id a player /// /// Id to validate /// True is a player /// UNUSED? public bool IsPlayer(MongoId userId) { return saveServer.ProfileExists(userId); } /// /// Get a full profiles scav-specific sub-profile /// /// Profiles id /// IPmcData object public PmcData? GetScavProfile(MongoId sessionId) { return saveServer.GetProfile(sessionId).CharacterData?.ScavData; } /// /// Get baseline counter values for a fresh profile /// /// Default profile Stats object public Stats GetDefaultCounters() { return new Stats { Eft = new EftStats { CarriedQuestItems = [], DamageHistory = new DamageHistory { LethalDamagePart = "Head", LethalDamage = null, BodyParts = new BodyPartsDamageHistory(), }, DroppedItems = [], ExperienceBonusMult = 0, FoundInRaidItems = [], LastPlayerState = null, LastSessionDate = 0, OverallCounters = new OverallCounters { Items = [] }, SessionCounters = new SessionCounters { Items = [] }, SessionExperienceMult = 0, SurvivorClass = "Unknown", TotalInGameTime = 0, TotalSessionExperience = 0, Victims = [], }, }; } /// /// is this profile flagged for data removal /// /// Profile id /// True if profile is to be wiped of data/progress /// TODO: logic doesn't feel right to have IsWiped being nullable protected bool IsWiped(MongoId sessionId) { return saveServer.GetProfile(sessionId).ProfileInfo?.IsWiped ?? false; } /// /// Iterate over player profile inventory items and find the secure container and remove it /// /// Profile to remove secure container from /// profile without secure container public PmcData RemoveSecureContainer(PmcData profile) { var items = profile.Inventory?.Items; var secureContainer = items?.FirstOrDefault(i => i.SlotId == "SecuredContainer"); if (secureContainer is not null) { // Find secure container + children var secureContainerAndChildrenIds = items?.GetItemWithChildrenTpls(secureContainer.Id).ToHashSet(); // Remove secure container + its children items?.RemoveAll(x => (secureContainerAndChildrenIds?.Contains(x.Id) ?? false)); } return profile; } /// /// Flag a profile as having received a gift /// Store giftId in profile spt object /// /// Player to add gift flag to /// Gift player received /// Limit of how many of this gift a player can have public void FlagGiftReceivedInProfile(MongoId playerId, string giftId, int maxCount) { var profileToUpdate = GetFullProfile(playerId); profileToUpdate.SptData!.ReceivedGifts ??= []; var giftData = profileToUpdate.SptData.ReceivedGifts.FirstOrDefault(g => g.GiftId == giftId); if (giftData != null) { // Increment counter giftData.Current++; return; } // Player has never received gift, make a new object profileToUpdate.SptData.ReceivedGifts.Add( new ReceivedGift { GiftId = giftId, TimestampLastAccepted = timeUtil.GetTimeStamp(), Current = 1, } ); } /// /// Check if profile has received a gift by id /// /// Player profile to check for gift /// Gift to check for /// Max times gift can be given to player /// True if player has received gift previously public bool PlayerHasReceivedMaxNumberOfGift(MongoId playerId, string giftId, int maxGiftCount) { var profile = GetFullProfile(playerId); var giftDataFromProfile = profile.SptData?.ReceivedGifts?.FirstOrDefault(g => g.GiftId == giftId); if (giftDataFromProfile == null) { return false; } return giftDataFromProfile.Current >= maxGiftCount; } /// /// Find Stat in profile counters and increment by one. /// /// Counters to search for key /// Key /// Was Includes in Node so might not be exact? public void IncrementStatCounter(CounterKeyValue[] counters, string keyToIncrement) { var stat = counters.FirstOrDefault(c => c.Key != null && c.Key.Contains(keyToIncrement)); if (stat != null) { stat.Value++; } } /// /// Check if player has a skill at elite level /// /// Skill to check /// Profile to find skill in /// True if player has skill at elite level public bool HasEliteSkillLevel(SkillTypes skill, PmcData pmcProfile) { var profileSkills = pmcProfile.Skills?.Common; if (profileSkills == null) { return false; } var profileSkill = profileSkills.FirstOrDefault(s => s.Id == skill); if (profileSkill == null) { logger.Error(serverLocalisationService.GetText("quest-no_skill_found", skill)); return false; } return profileSkill.Progress >= 5100; // 51 } /// /// Add points to a specific skill in player profile /// /// Player profile with skill /// Skill to add points to /// Points to add /// Skills are multiplied by a value in globals, default is off to maintain compatibility with legacy code public void AddSkillPointsToPlayer( PmcData pmcProfile, SkillTypes skill, double pointsToAddToSkill, bool useSkillProgressRateMultiplier = false ) { if (pointsToAddToSkill < 0D) { logger.Warning(serverLocalisationService.GetText("player-attempt_to_increment_skill_with_negative_value", skill)); return; } var profileSkills = pmcProfile.Skills?.Common; if (profileSkills == null) { logger.Warning($"Unable to add: {pointsToAddToSkill} points to {skill}, Profile has no skills"); return; } var profileSkill = profileSkills.FirstOrDefault(s => s.Id == skill); if (profileSkill == null) { logger.Error(serverLocalisationService.GetText("quest-no_skill_found", skill)); return; } // already max level, no need to do any further calculations if (profileSkill.Progress >= 5100) { if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug($"Player already has max level in skill: {skill}, not adding points"); } profileSkill.LastAccess = timeUtil.GetTimeStamp(); return; } if (useSkillProgressRateMultiplier) { var skillProgressRate = databaseService.GetGlobals().Configuration.SkillsSettings.SkillProgressRate; pointsToAddToSkill *= skillProgressRate; } if (InventoryConfig.SkillGainMultipliers.TryGetValue(skill.ToString(), out var multiplier)) { pointsToAddToSkill *= multiplier; } var adjustedSkillProgress = AdjustSkillExpForLowLevels(profileSkill.Progress, pointsToAddToSkill); profileSkill.Progress += adjustedSkillProgress; profileSkill.Progress = Math.Min(profileSkill.Progress, 5100); // Prevent skill from ever going above level 51 (5100) profileSkill.PointsEarnedDuringSession += adjustedSkillProgress; if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug($"Added: {adjustedSkillProgress} points to skill: {skill}, new progress value is: {profileSkill.Progress}"); } profileSkill.LastAccess = timeUtil.GetTimeStamp(); } /// /// This method calculates the adjusted skill progression for lower levels. /// /// Current internal progress value of the skill, used to determine current level /// The amount of visual progress to add /// Scaled skill progress according to level /// /// It expects to be passed on a value as expected per the visual progress on the UI. /// It will return scaled internal progress according to the current skill level, to match Tarkovs skill progression curve. /// So passing on "0.4" will always yield +0.4 progress on the UI for the player. /// public double AdjustSkillExpForLowLevels(double currentProgress, double visualProgressAmount) { var level = Math.Floor(currentProgress / 100d); if (level >= 9) { return visualProgressAmount; } double internalAdded = 0; // See "CalculateExpOnFirstLevels" in client for original logic // loop until all visual progress has been used up while (visualProgressAmount > 0) { // scale to apply for levels 1-10, decreasing as level goes higher var uiMax = 10d * (level + 1d); var factor = 100d / uiMax; // remaining internal points in this level var inLevel = currentProgress % 100d; var internalRemaining = 100d - inLevel; if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug($"currentLevelRemainingProgress: {internalRemaining}"); } // visual needed to fill the rest of this internal level var visualToLevelUp = internalRemaining / factor; var spendVisual = Math.Min(visualProgressAmount, visualToLevelUp); var addInternal = spendVisual * factor; if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug($"Progress To Add Adjusted For Level: {addInternal}"); } internalAdded += addInternal; currentProgress += addInternal; visualProgressAmount -= spendVisual; level = Math.Floor(currentProgress / 100d); } return internalAdded; } /// /// Is the provided session id for a developer account /// /// Profile id to check /// True if account is developer public bool IsDeveloperAccount(MongoId sessionId) { return GetFullProfile(sessionId).ProfileInfo?.Edition?.ToLowerInvariant().StartsWith("spt developer") ?? false; } /// /// Add stash row bonus to profile or increments rows given count if it already exists /// /// Profile id to give rows to /// How many rows to give profile /// The stash rows bonus id, this is needed for ws notification if we send one public MongoId? AddStashRowsBonusToProfile(MongoId sessionId, int rowsToAdd) { var profile = GetPmcProfile(sessionId); if (profile?.Bonuses is null) { // Something is very wrong with profile to lack bonuses array, likely broken profile, exit early return null; } var existingBonus = profile.Bonuses.FirstOrDefault(b => b.Type == BonusType.StashRows); var bonusId = existingBonus?.Id; if (existingBonus is null) { bonusId = new MongoId(); profile.Bonuses.Add( new Bonus { Id = bonusId.Value, Value = rowsToAdd, Type = BonusType.StashRows, IsPassive = true, IsVisible = true, IsProduction = false, } ); } else { existingBonus.Value += rowsToAdd; } return bonusId!.Value; } public bool HasAccessToRepeatableFreeRefreshSystem(PmcData pmcProfile) { return GameEditionsWithFreeRefresh.Contains(pmcProfile.Info?.GameVersion ?? string.Empty); } /// /// Find a profiles "Pockets" item and replace its tpl with passed in value /// /// Player profile /// New tpl to set profiles Pockets to public void ReplaceProfilePocketTpl(PmcData pmcProfile, string newPocketTpl) { // Find all pockets in profile, may be multiple as they could have equipment stand // (1 pocket for each upgrade level of equipment stand) var pockets = pmcProfile.Inventory?.Items?.Where(i => i.SlotId == "Pockets"); if (pockets is null || !pockets.Any()) { logger.Error($"Unable to replace profile: {pmcProfile.Id} pocket tpl with: {newPocketTpl} as Pocket item could not be found."); return; } foreach (var pocket in pockets) { pocket.Template = newPocketTpl; } } /// /// Return a favorites list in the format expected by the GetOtherProfile call /// /// /// A list of Item objects representing the favorited data public List GetOtherProfileFavorites(PmcData profile) { var fullFavorites = new List(); foreach (var itemId in profile.Inventory?.FavoriteItems ?? []) { // When viewing another users profile, the client expects a full item with children, so get that var itemAndChildren = profile.Inventory?.Items?.GetItemWithChildren(itemId); if (itemAndChildren?.Count > 0) { // To get the client to actually see the items, we set the main item's parent to null, so it's treated as a root item var clonedItems = cloner.Clone(itemAndChildren)!; clonedItems.First().ParentId = null; fullFavorites.AddRange(clonedItems); } } return fullFavorites; } public void AddHideoutCustomisationUnlock(SptProfile fullProfile, Reward reward, string source) { if (reward.Target is null) { logger.Error("Unable to add hideout customisation unlock, reward.Target is null."); return; } fullProfile.CustomisationUnlocks ??= []; if (fullProfile.CustomisationUnlocks?.Any(u => u.Id == reward.Target) ?? false) { logger.Warning( $"Profile: {fullProfile.ProfileInfo?.ProfileId ?? "`ProfileId is null`"} already has hideout customisation reward: {reward.Target}, skipping" ); return; } var customisationTemplateDb = databaseService.GetTemplates().Customization; if (!customisationTemplateDb.TryGetValue(reward.Target, out var template)) { logger.Error("Unable to find customisation reward template"); return; } var rewardToStore = new CustomisationStorage { Id = new MongoId(reward.Target), Source = source, Type = null, }; switch (template.Parent) { case CustomisationTypeId.MANNEQUIN_POSE: rewardToStore.Type = CustomisationType.MANNEQUIN_POSE; break; case CustomisationTypeId.GESTURES: rewardToStore.Type = CustomisationType.GESTURE; break; case CustomisationTypeId.FLOOR: rewardToStore.Type = CustomisationType.FLOOR; break; case CustomisationTypeId.DOG_TAGS: rewardToStore.Type = CustomisationType.DOG_TAG; break; case CustomisationTypeId.CEILING: rewardToStore.Type = CustomisationType.CEILING; break; case CustomisationTypeId.WALL: rewardToStore.Type = CustomisationType.WALL; break; case CustomisationTypeId.ENVIRONMENT_UI: rewardToStore.Type = CustomisationType.ENVIRONMENT; break; case CustomisationTypeId.SHOOTING_RANGE_MARK: rewardToStore.Type = CustomisationType.SHOOTING_RANGE_MARK; break; case CustomisationTypeId.VOICE: rewardToStore.Type = CustomisationType.VOICE; break; case CustomisationTypeId.LIGHT: rewardToStore.Type = CustomisationType.LIGHT; break; case CustomisationTypeId.UPPER: rewardToStore.Type = CustomisationType.UPPER; break; case CustomisationTypeId.HEAD: rewardToStore.Type = CustomisationType.HEAD; break; default: logger.Error($"Unhandled customisation unlock type: {template.Parent} not added to profile"); return; } fullProfile.CustomisationUnlocks?.Add(rewardToStore); } /// /// Get a profile template by the account and side /// /// Edition of profile desired, e.g. "Standard" /// Side of profile desired, e.g. "Bear" /// public TemplateSide? GetProfileTemplateForSide(string accountEdition, string side) { var profileTemplates = databaseService.GetProfileTemplates(); // Get matching profile 'type' e.g. 'standard' if (!profileTemplates.TryGetValue(accountEdition, out var matchingProfileTemplate)) { logger.Error($"Unable to find profile template for account edition: {accountEdition} and side: {side}"); return null; } // Get matching profile by 'side' e.g. USEC return string.Equals(side, "bear", StringComparison.OrdinalIgnoreCase) ? matchingProfileTemplate.Bear : matchingProfileTemplate.Usec; } /// /// Look up a key inside the `CustomFlags` property from a profile template /// /// Edition of profile desired, e.g. "Standard" /// key stored in CustomFlags dictionary /// public bool GetProfileTemplateFlagValue(string accountEdition, string flagKey) { var profileTemplates = databaseService.GetProfileTemplates(); // Get matching profile 'type' e.g. 'standard' if (!profileTemplates.TryGetValue(accountEdition, out var matchingProfileTemplate)) { logger.Error($"Unable to find profile template for account edition: {accountEdition}"); return false; } return matchingProfileTemplate.CustomFlags.GetValueOrDefault(flagKey, false); } }