using System; using System.Collections.Generic; using System.IO; using System.Linq; using Dalamud.Utility; using Newtonsoft.Json.Linq; using OtterGui.Log; using OtterGui.Filesystem; using Penumbra.GameData.Actors; using CustomizePlus.Core.Services; using CustomizePlus.Core.Helpers; using CustomizePlus.Armatures.Events; using CustomizePlus.Configuration.Data; using CustomizePlus.Armatures.Data; using CustomizePlus.Core.Events; using CustomizePlus.Templates; using CustomizePlus.Profiles.Data; using CustomizePlus.Templates.Events; using CustomizePlus.Profiles.Events; using CustomizePlus.Templates.Data; using CustomizePlus.GameData.Data; using CustomizePlus.GameData.Services; using CustomizePlus.GameData.Extensions; using CustomizePlus.Profiles.Enums; using CustomizePlus.Profiles.Exceptions; using Penumbra.GameData.Enums; using Penumbra.GameData.Interop; using System.Runtime.Serialization; using CustomizePlus.Game.Services; using ObjectManager = CustomizePlus.GameData.Services.ObjectManager; namespace CustomizePlus.Profiles; /// /// Container class for administrating s during runtime. /// public partial class ProfileManager : IDisposable { private readonly TemplateManager _templateManager; private readonly TemplateEditorManager _templateEditorManager; private readonly SaveService _saveService; private readonly Logger _logger; private readonly PluginConfiguration _configuration; private readonly ActorManager _actorManager; private readonly GameObjectService _gameObjectService; private readonly ObjectManager _objectManager; private readonly ProfileChanged _event; private readonly TemplateChanged _templateChangedEvent; private readonly ReloadEvent _reloadEvent; private readonly ArmatureChanged _armatureChangedEvent; public readonly List Profiles = new(); public Profile? DefaultProfile { get; private set; } public ProfileManager( TemplateManager templateManager, TemplateEditorManager templateEditorManager, SaveService saveService, Logger logger, PluginConfiguration configuration, ActorManager actorManager, GameObjectService gameObjectService, ObjectManager objectManager, ProfileChanged @event, TemplateChanged templateChangedEvent, ReloadEvent reloadEvent, ArmatureChanged armatureChangedEvent) { _templateManager = templateManager; _templateEditorManager = templateEditorManager; _saveService = saveService; _logger = logger; _configuration = configuration; _actorManager = actorManager; _gameObjectService = gameObjectService; _objectManager = objectManager; _event = @event; _templateChangedEvent = templateChangedEvent; _templateChangedEvent.Subscribe(OnTemplateChange, TemplateChanged.Priority.ProfileManager); _reloadEvent = reloadEvent; _reloadEvent.Subscribe(OnReload, ReloadEvent.Priority.ProfileManager); _armatureChangedEvent = armatureChangedEvent; _armatureChangedEvent.Subscribe(OnArmatureChange, ArmatureChanged.Priority.ProfileManager); CreateProfileFolder(saveService); LoadProfiles(); } public void Dispose() { _templateChangedEvent.Unsubscribe(OnTemplateChange); } /// /// Main rendering function, called from rendering hook after calling ArmatureManager.OnRender /// public void OnRender() { } public Profile Create(string name, bool handlePath) { var (actualName, path) = NameParsingHelper.ParseName(name, handlePath); var profile = new Profile { CreationDate = DateTimeOffset.UtcNow, ModifiedDate = DateTimeOffset.UtcNow, UniqueId = CreateNewGuid(), Name = actualName }; Profiles.Add(profile); _logger.Debug($"Added new profile {profile.UniqueId}."); _saveService.ImmediateSave(profile); _event.Invoke(ProfileChanged.Type.Created, profile, path); return profile; } /// /// Create a new profile by cloning passed profile /// /// public Profile Clone(Profile clone, string name, bool handlePath) { var (actualName, path) = NameParsingHelper.ParseName(name, handlePath); var profile = new Profile(clone) { CreationDate = DateTimeOffset.UtcNow, ModifiedDate = DateTimeOffset.UtcNow, UniqueId = CreateNewGuid(), Name = actualName, Enabled = false }; Profiles.Add(profile); _logger.Debug($"Added new profile {profile.UniqueId} by cloning."); _saveService.ImmediateSave(profile); _event.Invoke(ProfileChanged.Type.Created, profile, path); return profile; } /// /// Rename profile /// public void Rename(Profile profile, string newName) { newName = newName.Trim(); var oldName = profile.Name.Text; if (oldName == newName) return; profile.Name = newName; SaveProfile(profile); _logger.Debug($"Renamed profile {profile.UniqueId}."); _event.Invoke(ProfileChanged.Type.Renamed, profile, oldName); } /// /// Change character associated with profile /// public void ChangeCharacter(Profile profile, ActorIdentifier actorIdentifier) { if (!actorIdentifier.IsValid || actorIdentifier.MatchesIgnoringOwnership(profile.Character)) return; var oldCharacter = profile.Character; profile.Character = actorIdentifier; //Called so all other active profiles for new character name get disabled //saving is performed there //SetEnabled(profile, profile.Enabled, true); //todo SaveProfile(profile); _logger.Debug($"Changed character for profile {profile.UniqueId}."); _event.Invoke(ProfileChanged.Type.ChangedCharacter, profile, oldCharacter); } /// /// Delete profile /// /// public void Delete(Profile profile) { Profiles.Remove(profile); _saveService.ImmediateDelete(profile); _event.Invoke(ProfileChanged.Type.Deleted, profile, null); } /// /// Set write protection state for profile /// public void SetWriteProtection(Profile profile, bool value) { if (profile.IsWriteProtected == value) return; profile.IsWriteProtected = value; SaveProfile(profile); _logger.Debug($"Set profile {profile.UniqueId} to {(value ? string.Empty : "no longer be ")} write-protected."); _event.Invoke(ProfileChanged.Type.WriteProtection, profile, value); } public void SetEnabled(Profile profile, bool value, bool force = false) { if (profile.Enabled == value && !force) return; var oldValue = profile.Enabled; if (value) { _logger.Debug($"Setting {profile} as enabled..."); foreach (var otherProfile in Profiles .Where(x => x.Character.MatchesIgnoringOwnership(profile.Character) && x != profile && x.Enabled && !x.IsTemporary)) { _logger.Debug($"\t-> {otherProfile} disabled"); SetEnabled(otherProfile, false); } } if (oldValue != value) { profile.Enabled = value; SaveProfile(profile); _event.Invoke(ProfileChanged.Type.Toggled, profile, value); } } public void SetEnabled(Guid guid, bool value) { var profile = Profiles.FirstOrDefault(x => x.UniqueId == guid && x.ProfileType == ProfileType.Normal); if (profile != null) { SetEnabled(profile, value); } else throw new ProfileNotFoundException(); } public void SetApplyToCurrentlyActiveCharacter(Profile profile, bool value) { //todo: only one profile is allowed to be active for that setting if (profile.ApplyToCurrentlyActiveCharacter != value) { profile.ApplyToCurrentlyActiveCharacter = value; SaveProfile(profile); _event.Invoke(ProfileChanged.Type.ApplyToCurrentlyActiveCharacterChanged, profile, value); } } public void DeleteTemplate(Profile profile, int templateIndex) { _logger.Debug($"Deleting template #{templateIndex} from {profile}..."); var template = profile.Templates[templateIndex]; profile.Templates.RemoveAt(templateIndex); SaveProfile(profile); _event.Invoke(ProfileChanged.Type.RemovedTemplate, profile, template); } public void AddTemplate(Profile profile, Template template) { if (profile.Templates.Contains(template)) return; profile.Templates.Add(template); SaveProfile(profile); _logger.Debug($"Added template: {template.UniqueId} to {profile.UniqueId}"); _event.Invoke(ProfileChanged.Type.AddedTemplate, profile, template); } public void ChangeTemplate(Profile profile, int index, Template newTemplate) { if (index >= profile.Templates.Count || index < 0) return; if (profile.Templates[index] == newTemplate) return; var oldTemplate = profile.Templates[index]; profile.Templates[index] = newTemplate; SaveProfile(profile); _logger.Debug($"Changed template on profile {profile.UniqueId} from {oldTemplate.UniqueId} to {newTemplate.UniqueId}"); _event.Invoke(ProfileChanged.Type.ChangedTemplate, profile, (index, oldTemplate, newTemplate)); } public void MoveTemplate(Profile profile, int fromIndex, int toIndex) { if (!profile.Templates.Move(fromIndex, toIndex)) return; SaveProfile(profile); _logger.Debug($"Moved template {fromIndex + 1} to position {toIndex + 1}."); _event.Invoke(ProfileChanged.Type.MovedTemplate, profile, (fromIndex, toIndex)); } public void SetDefaultProfile(Profile? profile) { if (profile == null) { if (DefaultProfile == null) return; } else if (!Profiles.Contains(profile)) return; var previousProfile = DefaultProfile; DefaultProfile = profile; _configuration.DefaultProfile = profile?.UniqueId ?? Guid.Empty; _configuration.Save(); _logger.Debug($"Set profile {profile?.Incognito ?? "no profile"} as default"); _event.Invoke(ProfileChanged.Type.ChangedDefaultProfile, profile, previousProfile); } //warn: temporary profile system does not support any world identifiers public void AddTemporaryProfile(Profile profile, Actor actor) { if (!actor.Identifier(_actorManager, out var identifier)) throw new ActorNotFoundException(); profile.Enabled = true; profile.ProfileType = ProfileType.Temporary; profile.Character = identifier.CreatePermanent(); //warn: identifier must not be AnyWorld or stuff will break! var existingProfile = Profiles.FirstOrDefault(x => x.Character.MatchesIgnoringOwnership(profile.Character) && x.IsTemporary); if (existingProfile != null) { _logger.Debug($"Temporary profile for {existingProfile.Character.Incognito(null)} already exists, removing..."); Profiles.Remove(existingProfile); _event.Invoke(ProfileChanged.Type.TemporaryProfileDeleted, existingProfile, null); } Profiles.Add(profile); //Make sure temporary profiles come first, so they are returned by all other methods first Profiles.Sort((x, y) => y.IsTemporary.CompareTo(x.IsTemporary)); _logger.Debug($"Added temporary profile for {profile.Character}"); _event.Invoke(ProfileChanged.Type.TemporaryProfileAdded, profile, null); } public void RemoveTemporaryProfile(Profile profile) { if (!Profiles.Remove(profile)) throw new ProfileNotFoundException(); _logger.Debug($"Removed temporary profile for {profile.Character.Incognito(null)}"); _event.Invoke(ProfileChanged.Type.TemporaryProfileDeleted, profile, null); } public void RemoveTemporaryProfile(Guid profileId) { var profile = Profiles.FirstOrDefault(x => x.UniqueId == profileId && x.IsTemporary); if (profile == null) throw new ProfileNotFoundException(); RemoveTemporaryProfile(profile); } public void RemoveTemporaryProfile(Actor actor) { if (!actor.Identifier(_actorManager, out var identifier)) throw new ActorNotFoundException(); var profile = Profiles.FirstOrDefault(x => x.Character == identifier && x.IsTemporary); if (profile == null) throw new ProfileNotFoundException(); RemoveTemporaryProfile(profile); } /// /// Return profile by actor identifier, does not return temporary profiles. /// public Profile? GetProfileByActor(Actor actor, bool enabledOnly = false) { var actorIdentifier = actor.GetIdentifier(_actorManager); if (!actorIdentifier.IsValid) return null; var query = Profiles.Where(x => x.Character.MatchesIgnoringOwnership(actorIdentifier) && !x.IsTemporary); if (enabledOnly) query = query.Where(x => x.Enabled); var profile = query.FirstOrDefault(); if (profile == null) return null; if (actorIdentifier.Type == IdentifierType.Owned && !actorIdentifier.IsOwnedByLocalPlayer()) return null; return profile; } //todo: replace with dictionary /// /// Returns all enabled profiles which might apply to the given object, prioritizing temporary profiles and editor profile. /// public IEnumerable GetEnabledProfilesByActor(ActorIdentifier actorIdentifier) { //performance: using textual override for ProfileAppliesTo here to not call //GetGameObjectName every time we are trying to check object against profiles if (_objectManager.IsInLobby && !_configuration.ProfileApplicationSettings.ApplyInLobby) yield break; (actorIdentifier, _) = _gameObjectService.GetTrueActorForSpecialTypeActor(actorIdentifier); if (!actorIdentifier.IsValid) yield break; var name = actorIdentifier.ToNameWithoutOwnerName(); if (name.IsNullOrWhitespace()) yield break; bool IsProfileAppliesToCurrentActor(Profile profile) { //default profile check is done later if (profile == DefaultProfile) return false; if (profile.ApplyToCurrentlyActiveCharacter) { if (_objectManager.IsInLobby) return true; var currentPlayer = _actorManager.GetCurrentPlayer(); return currentPlayer.IsValid && currentPlayer.MatchesIgnoringOwnership(actorIdentifier); } if (actorIdentifier.Type == IdentifierType.Owned && !actorIdentifier.IsOwnedByLocalPlayer()) return false; return profile.CharacterName.Text == name || profile.Character.MatchesIgnoringOwnership(actorIdentifier); } if (_templateEditorManager.IsEditorActive && _templateEditorManager.EditorProfile.Enabled && IsProfileAppliesToCurrentActor(_templateEditorManager.EditorProfile)) yield return _templateEditorManager.EditorProfile; foreach (var profile in Profiles) { if(IsProfileAppliesToCurrentActor(profile)) { //todo: temp for migrations to v5 //todo: make sure this works for minions and stuff if (!profile.Character.IsValid) { _logger.Warning($"No character for profile {profile}, but character has been found as: {actorIdentifier}, will set."); profile.Character = actorIdentifier; _saveService.QueueSave(profile); } if (profile.Enabled) yield return profile; } } if (DefaultProfile != null && DefaultProfile.Enabled && (actorIdentifier.Type == IdentifierType.Player || actorIdentifier.Type == IdentifierType.Retainer)) yield return DefaultProfile; } public IEnumerable GetProfilesUsingTemplate(Template template) { if (template == null) yield break; foreach (var profile in Profiles) if (profile.Templates.Contains(template)) yield return profile; if (_templateEditorManager.EditorProfile.Templates.Contains(template)) yield return _templateEditorManager.EditorProfile; } private void SaveProfile(Profile profile) { //disallow saving special profiles if (profile.ProfileType != ProfileType.Normal) return; profile.ModifiedDate = DateTimeOffset.UtcNow; _saveService.QueueSave(profile); } private void OnTemplateChange(TemplateChanged.Type type, Template? template, object? arg3) { if (type is not TemplateChanged.Type.Deleted) return; foreach (var profile in Profiles) { for (var i = 0; i < profile.Templates.Count; ++i) { if (profile.Templates[i] != template) continue; profile.Templates.RemoveAt(i--); _event.Invoke(ProfileChanged.Type.RemovedTemplate, profile, template); SaveProfile(profile); _logger.Debug($"Removed template {template.UniqueId} from {profile.UniqueId} because template was deleted"); } } return; } private void OnReload(ReloadEvent.Type type) { if (type != ReloadEvent.Type.ReloadProfiles && type != ReloadEvent.Type.ReloadAll) return; _logger.Debug("Reload event received"); LoadProfiles(); } private void OnArmatureChange(ArmatureChanged.Type type, Armature armature, object? arg3) { if (type == ArmatureChanged.Type.Deleted) { //hack: sending TemporaryProfileDeleted will result in OnArmatureChange being sent //so we need to make sure that we do not end up with endless loop here //the whole reason DeletionReason exists is this if ((ArmatureChanged.DeletionReason)arg3 != ArmatureChanged.DeletionReason.Gone) return; var profile = armature!.Profile; if (!profile.IsTemporary) return; //Do not proceed unless there are no armatures left //because this might be the case of examine window actor being gone. //Profiles for those are shared with the original actor. if (profile.Armatures.Count > 0) return; //todo: TemporaryProfileDeleted ends up calling this again, fix this. //Profiles.Remove check won't allow for infinite loop but this isn't good anyway if (!Profiles.Remove(profile)) return; _logger.Debug($"ProfileManager.OnArmatureChange: Removed unused temporary profile for {profile.Character.Incognito(null)}"); _event.Invoke(ProfileChanged.Type.TemporaryProfileDeleted, profile, null); } } private static void CreateProfileFolder(SaveService service) { var ret = service.FileNames.ProfileDirectory; if (Directory.Exists(ret)) return; try { Directory.CreateDirectory(ret); } catch (Exception ex) { Plugin.Logger.Error($"Could not create profile directory {ret}:\n{ex}"); } } /// Move all files that were discovered to have names not corresponding to their identifier to correct names, if possible. /// The number of files that could not be moved. private int MoveInvalidNames(IEnumerable<(Profile, string)> invalidNames) { var failed = 0; foreach (var (profile, name) in invalidNames) { try { var correctName = _saveService.FileNames.ProfileFile(profile); File.Move(name, correctName, false); _logger.Information($"Moved invalid profile file from {Path.GetFileName(name)} to {Path.GetFileName(correctName)}."); } catch (Exception ex) { ++failed; _logger.Error($"Failed to move invalid profile file from {Path.GetFileName(name)}:\n{ex}"); } } return failed; } /// /// Create new guid until we find one which isn't used by existing profile /// /// private Guid CreateNewGuid() { while (true) { var guid = Guid.NewGuid(); if (Profiles.All(d => d.UniqueId != guid)) return guid; } } }