diff --git a/CustomizePlus.GameData/CustomizePlus.GameData.csproj b/CustomizePlus.GameData/CustomizePlus.GameData.csproj index f10e52c..53b705d 100644 --- a/CustomizePlus.GameData/CustomizePlus.GameData.csproj +++ b/CustomizePlus.GameData/CustomizePlus.GameData.csproj @@ -1,4 +1,4 @@ - + net8.0-windows diff --git a/CustomizePlus.GameData/Data/ReverseNameDicts.cs b/CustomizePlus.GameData/Data/ReverseNameDicts.cs new file mode 100644 index 0000000..ff6dc88 --- /dev/null +++ b/CustomizePlus.GameData/Data/ReverseNameDicts.cs @@ -0,0 +1,65 @@ +using CustomizePlus.GameData.ReverseSearchDictionaries; +using Dalamud.Game.ClientState.Objects.Enums; +using OtterGui.Services; +using Penumbra.GameData.DataContainers; +using Penumbra.GameData.Structs; +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading.Tasks; + +namespace CustomizePlus.GameData.Data; + +/// A collection service for all the name dictionaries required for reverse name search. +/// note: this is mvp for profile upgrading purposes, not intended to be used for anything else. +public sealed class ReverseNameDicts( + ReverseSearchDictMount _mounts, + ReverseSearchDictCompanion _companions, + ReverseSearchDictBNpc _bNpcs, + ReverseSearchDictENpc _eNpcs) + : IAsyncService +{ + /// Valid Mount ids by name in title case. + public readonly ReverseSearchDictMount Mounts = _mounts; + + /// Valid Companion ids by name in title case. + public readonly ReverseSearchDictCompanion Companions = _companions; + + /// Valid BNPC ids by name in title case. + public readonly ReverseSearchDictBNpc BNpcs = _bNpcs; + + /// Valid ENPC ids by name in title case. + public readonly ReverseSearchDictENpc ENpcs = _eNpcs; + + /// Finished when all name dictionaries are finished. + public Task Awaiter { get; } = + Task.WhenAll(_mounts.Awaiter, _companions.Awaiter, _bNpcs.Awaiter, _eNpcs.Awaiter); + + /// + public bool Finished + => Awaiter.IsCompletedSuccessfully; + + /// Convert a given name for a certain ObjectKind to an ID. + /// default or a valid id. + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + public uint ToID(ObjectKind kind, string name) + => TryGetID(kind, name, out var ret) ? ret : default; + + /// Convert a given ID for a certain ObjectKind to a name. + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + public bool TryGetID(ObjectKind kind, string name, [NotNullWhen(true)] out uint npcId) + { + npcId = default; + return kind switch + { + ObjectKind.MountType => Mounts.TryGetValue(name, out npcId), + ObjectKind.Companion => Companions.TryGetValue(name, out npcId), + ObjectKind.BattleNpc => BNpcs.TryGetValue(name, out npcId), + ObjectKind.EventNpc => ENpcs.TryGetValue(name, out npcId), + _ => false, + }; + } +} \ No newline at end of file diff --git a/CustomizePlus.GameData/Extensions/ActorIdentifierExtensions.cs b/CustomizePlus.GameData/Extensions/ActorIdentifierExtensions.cs index 122d125..dd5a713 100644 --- a/CustomizePlus.GameData/Extensions/ActorIdentifierExtensions.cs +++ b/CustomizePlus.GameData/Extensions/ActorIdentifierExtensions.cs @@ -28,6 +28,35 @@ public static class ActorIdentifierExtensions return PenumbraExtensions.Manager.Data.ToName(identifier.Kind, identifier.DataId); } + /// + /// Matches() method but ignoring ownership for owned objects. + /// + public static bool MatchesIgnoringOwnership(this ActorIdentifier identifier, ActorIdentifier other) + { + if (identifier.Type != other.Type) + return false; + + return identifier.Type switch + { + IdentifierType.Owned => PenumbraExtensions.Manager.DataIdEquals(identifier, other), + _ => identifier.Matches(other) + }; + } + + /// + /// Check if owned actor is owned by local player. Will return false if Type is not Owned. + /// + public static bool IsOwnedByLocalPlayer(this ActorIdentifier identifier) + { + if (identifier.Type != IdentifierType.Owned) + return false; + + if (PenumbraExtensions.Manager == null) + return false; + + return identifier.PlayerName == PenumbraExtensions.Manager.GetCurrentPlayer().PlayerName; + } + /// /// Wrapper around Incognito which returns non-incognito name in debug builds /// @@ -56,6 +85,23 @@ public static class ActorIdentifierExtensions } } + public static string TypeToString(this ActorIdentifier identifier) + { + return identifier.Type switch + { + IdentifierType.Player => $" ({PenumbraExtensions.Manager?.Data.ToWorldName(identifier.HomeWorld) ?? "Unknown"})", + IdentifierType.Retainer => $"{identifier.Retainer switch + { + ActorIdentifier.RetainerType.Bell => " (Bell)", + ActorIdentifier.RetainerType.Mannequin => " (Mannequin)", + _ => " (Retainer)", + }}", + IdentifierType.Owned => " (Companion/Mount)", + IdentifierType.Npc => " (NPC)", + _ => "", + }; + } + /// /// For now used to determine if root scaling should be allowed or not /// diff --git a/CustomizePlus.GameData/ReverseSearchDictionaries/Bases/ReverseNameDictionary.cs b/CustomizePlus.GameData/ReverseSearchDictionaries/Bases/ReverseNameDictionary.cs new file mode 100644 index 0000000..d9d3bb2 --- /dev/null +++ b/CustomizePlus.GameData/ReverseSearchDictionaries/Bases/ReverseNameDictionary.cs @@ -0,0 +1,83 @@ +using Dalamud.Plugin.Services; +using Dalamud.Plugin; +using FFXIVClientStructs.FFXIV.Common.Lua; +using OtterGui.Log; +using Penumbra.GameData.Data; +using Penumbra.GameData.DataContainers.Bases; +using Penumbra.GameData.Structs; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace CustomizePlus.GameData.ReverseSearchDictionaries.Bases; + +/// A base class for dictionaries from NPC names to their IDs. +/// The plugin interface. +/// A logger. +/// The data manger to fetch the data from. +/// The name of the data share. +/// The version of the data share. +/// The factory function to create the data from. +public abstract class ReverseNameDictionary( + IDalamudPluginInterface pluginInterface, + Logger log, + IDataManager gameData, + string name, + int version, + Func> factory) + : DataSharer>(pluginInterface, log, name, gameData.Language, version, factory), + IReadOnlyDictionary +{ + /// + public IEnumerator> GetEnumerator() + => Value.Select(kvp => new KeyValuePair(kvp.Key, new NpcId(kvp.Value))).GetEnumerator(); + + /// + IEnumerator IEnumerable.GetEnumerator() + => GetEnumerator(); + + /// + public int Count + => Value.Count; + + /// + public bool ContainsKey(string key) + => Value.ContainsKey(key); + + /// + public bool TryGetValue(string key, [NotNullWhen(true)] out NpcId value) + { + if (!Value.TryGetValue(key, out var uintVal)) + { + value = default; + return false; + } + + value = new NpcId(uintVal); + return true; + } + + /// + public NpcId this[string key] + => new NpcId(Value[key]); + + /// + public IEnumerable Keys + => Value.Keys; + + /// + public IEnumerable Values + => Value.Values.Select(k => new NpcId(k)); + + /// + protected override long ComputeMemory() + => DataUtility.DictionaryMemory(16, Count) + Keys.Sum(v => v.Length * 2); //this seems to be only used by diagnostics stuff so I don't particularly care for this to be correct. + + /// + protected override int ComputeTotalCount() + => Count; +} diff --git a/CustomizePlus.GameData/ReverseSearchDictionaries/ReverseSearchDictBNpc.cs b/CustomizePlus.GameData/ReverseSearchDictionaries/ReverseSearchDictBNpc.cs new file mode 100644 index 0000000..3a22e54 --- /dev/null +++ b/CustomizePlus.GameData/ReverseSearchDictionaries/ReverseSearchDictBNpc.cs @@ -0,0 +1,33 @@ +using Dalamud.Plugin.Services; +using Dalamud.Plugin; +using OtterGui.Log; +using Penumbra.GameData.Data; +using System.Collections.Frozen; +using System.Diagnostics.CodeAnalysis; +using Lumina.Excel.GeneratedSheets; +using CustomizePlus.GameData.ReverseSearchDictionaries.Bases; + +namespace CustomizePlus.GameData.ReverseSearchDictionaries; + +/// A dictionary that matches names to battle npc ids. +public sealed class ReverseSearchDictBNpc(IDalamudPluginInterface pluginInterface, Logger log, IDataManager gameData) + : ReverseNameDictionary(pluginInterface, log, gameData, "ReverseSearchBNpcs", 7, () => CreateBNpcData(gameData)) +{ + /// Create the data. + private static IReadOnlyDictionary CreateBNpcData(IDataManager gameData) + { + var sheet = gameData.GetExcelSheet(gameData.Language)!; + var dict = new Dictionary((int)sheet.RowCount); + foreach (var n in sheet.Where(n => n.Singular.RawData.Length > 0)) + dict.TryAdd(DataUtility.ToTitleCaseExtended(n.Singular, n.Article), n.RowId); + return dict.ToFrozenDictionary(); + } + + /// + public bool TryGetValue(string key, [NotNullWhen(true)] out uint value) + => Value.TryGetValue(key, out value); + + /// + public uint this[string key] + => Value[key]; +} diff --git a/CustomizePlus.GameData/ReverseSearchDictionaries/ReverseSearchDictCompanion.cs b/CustomizePlus.GameData/ReverseSearchDictionaries/ReverseSearchDictCompanion.cs new file mode 100644 index 0000000..4c26193 --- /dev/null +++ b/CustomizePlus.GameData/ReverseSearchDictionaries/ReverseSearchDictCompanion.cs @@ -0,0 +1,33 @@ +using Dalamud.Plugin.Services; +using Dalamud.Plugin; +using OtterGui.Log; +using Penumbra.GameData.Data; +using System.Collections.Frozen; +using System.Diagnostics.CodeAnalysis; +using Lumina.Excel.GeneratedSheets; +using CustomizePlus.GameData.ReverseSearchDictionaries.Bases; + +namespace CustomizePlus.GameData.ReverseSearchDictionaries; + +/// A dictionary that matches companion names to their ids. +public sealed class ReverseSearchDictCompanion(IDalamudPluginInterface pluginInterface, Logger log, IDataManager gameData) + : ReverseNameDictionary(pluginInterface, log, gameData, "ReverseSearchCompanions", 7, () => CreateCompanionData(gameData)) +{ + /// Create the data. + private static IReadOnlyDictionary CreateCompanionData(IDataManager gameData) + { + var sheet = gameData.GetExcelSheet(gameData.Language)!; + var dict = new Dictionary((int)sheet.RowCount); + foreach (var c in sheet.Where(c => c.Singular.RawData.Length > 0 && c.Order < ushort.MaxValue)) + dict.TryAdd(DataUtility.ToTitleCaseExtended(c.Singular, c.Article), c.RowId); + return dict.ToFrozenDictionary(); + } + + /// + public bool TryGetValue(string key, [NotNullWhen(true)] out uint value) + => Value.TryGetValue(key, out value); + + /// + public uint this[string key] + => Value[key]; +} \ No newline at end of file diff --git a/CustomizePlus.GameData/ReverseSearchDictionaries/ReverseSearchDictENpc.cs b/CustomizePlus.GameData/ReverseSearchDictionaries/ReverseSearchDictENpc.cs new file mode 100644 index 0000000..81405a8 --- /dev/null +++ b/CustomizePlus.GameData/ReverseSearchDictionaries/ReverseSearchDictENpc.cs @@ -0,0 +1,33 @@ +using Dalamud.Plugin.Services; +using Dalamud.Plugin; +using OtterGui.Log; +using Penumbra.GameData.Data; +using System.Collections.Frozen; +using System.Diagnostics.CodeAnalysis; +using Lumina.Excel.GeneratedSheets; +using CustomizePlus.GameData.ReverseSearchDictionaries.Bases; + +namespace CustomizePlus.GameData.ReverseSearchDictionaries; + +/// A dictionary that matches names to event npc ids. +public sealed class ReverseSearchDictENpc(IDalamudPluginInterface pluginInterface, Logger log, IDataManager gameData) + : ReverseNameDictionary(pluginInterface, log, gameData, "ReverseSearchENpcs", 7, () => CreateENpcData(gameData)) +{ + /// Create the data. + private static IReadOnlyDictionary CreateENpcData(IDataManager gameData) + { + var sheet = gameData.GetExcelSheet(gameData.Language)!; + var dict = new Dictionary((int)sheet.RowCount); + foreach (var n in sheet.Where(e => e.Singular.RawData.Length > 0)) + dict.TryAdd(DataUtility.ToTitleCaseExtended(n.Singular, n.Article), n.RowId); + return dict.ToFrozenDictionary(); + } + + /// + public bool TryGetValue(string key, [NotNullWhen(true)] out uint value) + => Value.TryGetValue(key, out value); + + /// + public uint this[string key] + => Value[key]; +} \ No newline at end of file diff --git a/CustomizePlus.GameData/ReverseSearchDictionaries/ReverseSearchDictMount.cs b/CustomizePlus.GameData/ReverseSearchDictionaries/ReverseSearchDictMount.cs new file mode 100644 index 0000000..db8cdc3 --- /dev/null +++ b/CustomizePlus.GameData/ReverseSearchDictionaries/ReverseSearchDictMount.cs @@ -0,0 +1,56 @@ +using Dalamud.Plugin.Services; +using Dalamud.Plugin; +using OtterGui.Log; +using Penumbra.GameData.Data; +using System.Collections.Frozen; +using System.Diagnostics.CodeAnalysis; +using Lumina.Excel.GeneratedSheets; +using CustomizePlus.GameData.ReverseSearchDictionaries.Bases; +using Dalamud.Utility; + +namespace CustomizePlus.GameData.ReverseSearchDictionaries; + +/// A dictionary that matches names to mount ids. +public sealed class ReverseSearchDictMount(IDalamudPluginInterface pluginInterface, Logger log, IDataManager gameData) + : ReverseNameDictionary(pluginInterface, log, gameData, "ReverseSearchMounts", 7, () => CreateMountData(gameData)) +{ + /// Create the data. + private static IReadOnlyDictionary CreateMountData(IDataManager gameData) + { + var sheet = gameData.GetExcelSheet(gameData.Language)!; + var dict = new Dictionary((int)sheet.RowCount); + // Add some custom data. + dict.TryAdd("Falcon (Porter)", 119); + dict.TryAdd("Hippo Cart (Quest)", 295); + dict.TryAdd("Hippo Cart (Quest)", 296); + dict.TryAdd("Miw Miisv (Quest)", 298); + dict.TryAdd("Moon-hopper (Quest)", 309); + foreach (var m in sheet) + { + if (m.Singular.RawData.Length > 0 && m.Order >= 0) + { + dict.TryAdd(DataUtility.ToTitleCaseExtended(m.Singular, m.Article), m.RowId); + } + else if (m.Unknown18.RawData.Length > 0) + { + // Try to transform some file names into category names. + var whistle = m.Unknown18.ToDalamudString().ToString(); + whistle = whistle.Replace("SE_Bt_Etc_", string.Empty) + .Replace("Mount_", string.Empty) + .Replace("_call", string.Empty) + .Replace("Whistle", string.Empty); + dict.TryAdd($"? {whistle} #{m.RowId}", m.RowId); + } + } + + return dict.ToFrozenDictionary(); + } + + /// + public bool TryGetValue(string key, [NotNullWhen(true)] out uint value) + => Value.TryGetValue(key, out value); + + /// + public uint this[string key] + => Value[key]; +} \ No newline at end of file diff --git a/CustomizePlus/Api/CustomizePlusIpc.General.cs b/CustomizePlus/Api/CustomizePlusIpc.General.cs index b47b201..3402e13 100644 --- a/CustomizePlus/Api/CustomizePlusIpc.General.cs +++ b/CustomizePlus/Api/CustomizePlusIpc.General.cs @@ -4,7 +4,7 @@ namespace CustomizePlus.Api; public partial class CustomizePlusIpc { - private readonly (int Breaking, int Feature) _apiVersion = (5, 1); + private readonly (int Breaking, int Feature) _apiVersion = (5, 2); /// /// When there are breaking changes the first number is bumped up and second one is reset. diff --git a/CustomizePlus/Api/CustomizePlusIpc.Profile.cs b/CustomizePlus/Api/CustomizePlusIpc.Profile.cs index 952927f..6eb17c5 100644 --- a/CustomizePlus/Api/CustomizePlusIpc.Profile.cs +++ b/CustomizePlus/Api/CustomizePlusIpc.Profile.cs @@ -46,7 +46,7 @@ public partial class CustomizePlusIpc .Select(x => { string path = _profileFileSystem.FindLeaf(x, out var leaf) ? leaf.FullName() : x.Name.Text; - return (x.UniqueId, x.Name.Text, path, x.CharacterName.Text, x.Enabled); + return (x.UniqueId, x.Name.Text, path, x.Characters.Count > 0 ? x.Characters[0].ToNameWithoutOwnerName() : "", x.Enabled); //todo: proper update to v5 }) .ToList(); } @@ -134,7 +134,7 @@ public partial class CustomizePlusIpc if (actor == null || !actor.Value.Valid || !actor.Value.IsCharacter) return ((int)ErrorCode.InvalidCharacter, null); - var profile = _profileManager.GetProfileByCharacterName(actor.Value.Utf8Name.ToString(), true); + var profile = _profileManager.GetProfileByActor(actor.Value, true); if (profile == null) return ((int)ErrorCode.ProfileNotFound, null); @@ -261,12 +261,10 @@ public partial class CustomizePlusIpc //warn: intended limitation - ignores default profiles because why you would use default profile on your own character private void OnArmatureChanged(ArmatureChanged.Type type, Armature armature, object? arg3) { - string currentPlayerName = _gameObjectService.GetCurrentPlayerName(); - - if (armature.ActorIdentifier.ToNameWithoutOwnerName() != currentPlayerName) + if (armature.ActorIdentifier != _gameObjectService.GetCurrentPlayerActorIdentifier()) return; - if (armature.ActorIdentifier.HomeWorld == WorldId.AnyWorld) //Cutscene/GPose actors + if (armature.ActorIdentifier.HomeWorld == WorldId.AnyWorld) //Only Cutscene/GPose actors have world set to AnyWorld return; ICharacter? localPlayerCharacter = (ICharacter?)_gameObjectService.GetDalamudGameObjectFromActor(_gameObjectService.GetLocalPlayerActor()); @@ -285,24 +283,12 @@ public partial class CustomizePlusIpc else (activeProfile, oldProfile) = ((Profile?, Profile?))arg3; - if (activeProfile != null) - { - if (activeProfile == _profileManager.DefaultProfile || activeProfile.ProfileType == ProfileType.Editor) - { - //ignore any changes while player is in editor or if player changes between default profiles - //also do not send event if there were no active profile before - if (activeProfile == oldProfile || oldProfile == null) - return; + //do not send event if we are entering editor + if (activeProfile != null && activeProfile.ProfileType == ProfileType.Editor) + return; - OnProfileUpdateInternal(localPlayerCharacter, null); //send empty profile when player enters editor or turns on default profile - return; - } - } - - //do not send event if we are exiting editor or disabling default profile and don't have any active profile - if (oldProfile != null && - (oldProfile == _profileManager.DefaultProfile || oldProfile.ProfileType == ProfileType.Editor) && - activeProfile == null) + //do not send event if we are exiting editor + if (oldProfile != null && oldProfile.ProfileType == ProfileType.Editor) return; OnProfileUpdateInternal(localPlayerCharacter, activeProfile); @@ -311,8 +297,9 @@ public partial class CustomizePlusIpc if (type == ArmatureChanged.Type.Deleted) { - //Do not send event if default or editor profile was used - if (armature.Profile == _profileManager.DefaultProfile || armature.Profile.ProfileType == ProfileType.Editor) //todo: never send if ProfileType != normal? + //Do not send event if editor profile was used + //todo: never send if ProfileType != normal? + if (armature.Profile.ProfileType == ProfileType.Editor) return; OnProfileUpdateInternal(localPlayerCharacter, null); diff --git a/CustomizePlus/Api/Data/IPCCharacterProfile.cs b/CustomizePlus/Api/Data/IPCCharacterProfile.cs index 727e73e..f3066ae 100644 --- a/CustomizePlus/Api/Data/IPCCharacterProfile.cs +++ b/CustomizePlus/Api/Data/IPCCharacterProfile.cs @@ -1,5 +1,7 @@ using CustomizePlus.Configuration.Data.Version3; using CustomizePlus.Core.Data; +using CustomizePlus.Game.Services; +using CustomizePlus.GameData.Extensions; using CustomizePlus.Profiles.Data; using CustomizePlus.Templates.Data; using System; @@ -16,14 +18,18 @@ namespace CustomizePlus.Api.Data; /// public class IPCCharacterProfile { + /// + /// Used only for display purposes + /// public string CharacterName { get; set; } = "Invalid"; + public Dictionary Bones { get; init; } = new(); public static IPCCharacterProfile FromFullProfile(Profile profile) { var ipcProfile = new IPCCharacterProfile { - CharacterName = profile.CharacterName, + CharacterName = profile.Characters.FirstOrDefault().ToNameWithoutOwnerName(), Bones = new Dictionary() }; @@ -47,12 +53,11 @@ public class IPCCharacterProfile { var fullProfile = new Profile { - Name = $"{profile.CharacterName}'s IPC profile", - CharacterName = profile.CharacterName, + Name = $"IPC profile for {profile.CharacterName}", + //Character should be set manually CreationDate = DateTimeOffset.UtcNow, ModifiedDate = DateTimeOffset.UtcNow, Enabled = true, - LimitLookupToOwnedObjects = false, UniqueId = Guid.NewGuid(), Templates = new List