From 63f65e9589eb62296fba1325dec4f55a71284c24 Mon Sep 17 00:00:00 2001 From: Liza Carvelli Date: Fri, 7 Jun 2024 23:28:42 +0200 Subject: [PATCH] Rework logic to explicitly sort items, add /gbrowser Since we no longer rely on an arbitrary 'score' (which wasn't ideally calculated), we now have a stable item order which also improves the handling of some edge-cases. --- Gearsetter/.editorconfig | 2 +- Gearsetter/CachedItem.cs | 149 -------- Gearsetter/ClassJob.cs | 46 --- Gearsetter/Configuration.cs | 37 ++ Gearsetter/GameData/EBaseParam.cs | 33 ++ Gearsetter/GameData/EClassJob.cs | 99 ++++++ Gearsetter/GameData/EEquipSlotCategory.cs | 25 ++ Gearsetter/GameData/GameDataHolder.cs | 204 +++++++++++ Gearsetter/Gearsetter.csproj | 2 +- Gearsetter/GearsetterPlugin.cs | 339 +++++++++---------- Gearsetter/Model/EquipmentItem.cs | 50 +++ Gearsetter/Model/EquipmentStats.cs | 35 ++ Gearsetter/Model/ItemList.cs | 138 ++++++++ Gearsetter/Windows/EquipmentBrowserWindow.cs | 213 ++++++++++++ 14 files changed, 999 insertions(+), 373 deletions(-) delete mode 100644 Gearsetter/CachedItem.cs delete mode 100644 Gearsetter/ClassJob.cs create mode 100644 Gearsetter/Configuration.cs create mode 100644 Gearsetter/GameData/EBaseParam.cs create mode 100644 Gearsetter/GameData/EClassJob.cs create mode 100644 Gearsetter/GameData/EEquipSlotCategory.cs create mode 100644 Gearsetter/GameData/GameDataHolder.cs create mode 100644 Gearsetter/Model/EquipmentItem.cs create mode 100644 Gearsetter/Model/EquipmentStats.cs create mode 100644 Gearsetter/Model/ItemList.cs create mode 100644 Gearsetter/Windows/EquipmentBrowserWindow.cs diff --git a/Gearsetter/.editorconfig b/Gearsetter/.editorconfig index 6a4af82..1e0e3b2 100644 --- a/Gearsetter/.editorconfig +++ b/Gearsetter/.editorconfig @@ -990,7 +990,7 @@ csharp_space_around_binary_operators = before_and_after csharp_using_directive_placement = outside_namespace:silent csharp_prefer_simple_using_statement = true:suggestion csharp_prefer_braces = true:silent -csharp_style_namespace_declarations = block_scoped:silent +csharp_style_namespace_declarations = file_scoped:warning csharp_style_prefer_method_group_conversion = true:silent csharp_style_prefer_top_level_statements = true:silent csharp_style_prefer_primary_constructors = true:suggestion diff --git a/Gearsetter/CachedItem.cs b/Gearsetter/CachedItem.cs deleted file mode 100644 index 485a24d..0000000 --- a/Gearsetter/CachedItem.cs +++ /dev/null @@ -1,149 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Lumina.Excel.GeneratedSheets; - -namespace Gearsetter; - -internal sealed class CachedItem : IEquatable -{ - public required Item Item { get; init; } - public required uint ItemId { get; init; } - public required bool Hq { get; init; } - public required string Name { get; init; } - public required byte Level { get; init; } - public required uint ItemLevel { get; init; } - public required byte Rarity { get; init; } - public required uint EquipSlotCategory { get; init; } - public required IReadOnlyList ClassJobs { get; set; } - - public int CalculateScore(ClassJob classJob, short level) - { - var stats = new Stats(Item, Hq); - - int score = 0; - if (classJob is >= ClassJob.Miner and <= ClassJob.Fisher) - { - score += stats.Get(BaseParam.Gathering) + stats.Get(BaseParam.Perception) + stats.Get(BaseParam.GP); - } - else if (classJob is >= ClassJob.Carpenter and <= ClassJob.Culinarian) - { - score += stats.Get(BaseParam.Craftsmanship) + stats.Get(BaseParam.Control) + stats.Get(BaseParam.CP); - } - else - { - if (ItemId == 41081 && level < 90) - return int.MaxValue; - else if (ItemId == 33648 && level < 80) - return int.MaxValue - 1; - else if (ItemId == 24589 && level < 70) - return int.MaxValue - 2; - else if (ItemId == 16039 && level < 50) - return int.MaxValue - 3; - - if (classJob is ClassJob.Conjurer or ClassJob.WhiteMage or ClassJob.Scholar or ClassJob.Astrologian - or ClassJob.Sage or ClassJob.Thaumaturge or ClassJob.BlackMage or ClassJob.Arcanist or ClassJob.Summoner - or ClassJob.RedMage or ClassJob.BlueMage) - { - score += 1_000_000 * (Item.DamageMag + stats.Get(BaseParam.DamageMag)); - } - else - score += 1_000_000 * (Item.DamagePhys + stats.Get(BaseParam.DamagePhys)); - - score += Item.DefensePhys + stats.Get(BaseParam.DefensePhys); - score += Item.DefenseMag + stats.Get(BaseParam.DefenseMag); - - score += 100 * (stats.Get(BaseParam.Strength) + stats.Get(BaseParam.Dexterity) + - stats.Get(BaseParam.Intelligence) + stats.Get(BaseParam.Mind)); - - score += Rarity; - } - - return score; - } - - public bool Equals(CachedItem? other) - { - if (ReferenceEquals(null, other)) return false; - if (ReferenceEquals(this, other)) return true; - return ItemId == other.ItemId && Hq == other.Hq; - } - - public override bool Equals(object? obj) - { - return ReferenceEquals(this, obj) || obj is CachedItem other && Equals(other); - } - - public override int GetHashCode() - { - return HashCode.Combine(ItemId, Hq); - } - - public static bool operator ==(CachedItem? left, CachedItem? right) - { - return Equals(left, right); - } - - public static bool operator !=(CachedItem? left, CachedItem? right) - { - return !Equals(left, right); - } - - public enum BaseParam : byte - { - Strength = 1, - Dexterity = 2, - Vitality = 3, - Intelligence = 4, - Mind = 5, - Piety = 6, - - GP = 10, - CP = 11, - - DamagePhys = 12, - DamageMag = 13, - - DefensePhys = 21, - DefenseMag = 24, - - Tenacity = 19, - Crit = 27, - DirectHit = 22, - Determination = 44, - SpellSpeed = 46, - - Craftsmanship = 70, - Control = 71, - Gathering = 72, - Perception = 73, - } - - private sealed class Stats - { - private readonly Dictionary _values; - - public Stats(Item item, bool hq) - { - _values = item.UnkData59.Where(x => x.BaseParam > 0) - .ToDictionary(x => (BaseParam)x.BaseParam, x => x.BaseParamValue); - if (hq) - { - foreach (var hqstat in item.UnkData73.Select(x => - ((BaseParam)x.BaseParamSpecial, x.BaseParamValueSpecial))) - { - if (_values.TryGetValue(hqstat.Item1, out var stat)) - _values[hqstat.Item1] = (short)(stat + hqstat.BaseParamValueSpecial); - else - _values[hqstat.Item1] = hqstat.BaseParamValueSpecial; - } - } - } - - public short Get(BaseParam param) - { - _values.TryGetValue(param, out short v); - return v; - } - } -} diff --git a/Gearsetter/ClassJob.cs b/Gearsetter/ClassJob.cs deleted file mode 100644 index 5f23038..0000000 --- a/Gearsetter/ClassJob.cs +++ /dev/null @@ -1,46 +0,0 @@ -namespace Gearsetter; - -internal enum ClassJob -{ - Adventurer = 0, - Gladiator = 1, - Pugilist = 2, - Marauder = 3, - Lancer = 4, - Archer = 5, - Conjurer = 6, - Thaumaturge = 7, - Carpenter = 8, - Blacksmith = 9, - Armorer = 10, - Goldsmith = 11, - Leatherworker = 12, - Weaver = 13, - Alchemist = 14, - Culinarian = 15, - Miner = 16, - Botanist = 17, - Fisher = 18, - Paladin = 19, - Monk = 20, - Warrior = 21, - Dragoon = 22, - Bard = 23, - WhiteMage = 24, - BlackMage = 25, - Arcanist = 26, - Summoner = 27, - Scholar = 28, - Rogue = 29, - Ninja = 30, - Machinist = 31, - DarkKnight = 32, - Astrologian = 33, - Samurai = 34, - RedMage = 35, - BlueMage = 36, - Gunbreaker = 37, - Dancer = 38, - Reaper = 39, - Sage = 40, -} diff --git a/Gearsetter/Configuration.cs b/Gearsetter/Configuration.cs new file mode 100644 index 0000000..943c402 --- /dev/null +++ b/Gearsetter/Configuration.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; +using Dalamud.Configuration; +using Gearsetter.GameData; + +namespace Gearsetter; + +internal sealed class Configuration : IPluginConfiguration +{ + public int Version { get; set; } = 1; + + public List StatPriorityTanks { get; set; } = new(); + public List StatPriorityHealer { get; set; } = new(); + public List StatPriorityMelee { get; set; } = new(); + public List StatPriorityPhysicalRanged { get; set; } = new(); + public List StatPriorityCaster { get; set; } = new(); + public List StatPriorityCrafter { get; set; } = new(); + public List StatPriorityGatherer { get; set; } = new(); + + public static Configuration Create() + { + // this isn't ideal in all cases, but it's a starting point in case we ever want to make this class-specific + return new Configuration + { + StatPriorityTanks = [EBaseParam.Crit, EBaseParam.DirectHit, EBaseParam.Determination, EBaseParam.Tenacity], + StatPriorityHealer = + [EBaseParam.Crit, EBaseParam.DirectHit, EBaseParam.Determination, EBaseParam.SpellSpeed], + StatPriorityMelee = + [EBaseParam.Crit, EBaseParam.Determination, EBaseParam.DirectHit, EBaseParam.SkillSpeed], + StatPriorityPhysicalRanged = + [EBaseParam.Crit, EBaseParam.Determination, EBaseParam.DirectHit, EBaseParam.SkillSpeed], + StatPriorityCaster = + [EBaseParam.Crit, EBaseParam.Determination, EBaseParam.DirectHit, EBaseParam.SpellSpeed], + StatPriorityCrafter = [EBaseParam.CP, EBaseParam.Craftsmanship, EBaseParam.Control], + StatPriorityGatherer = [EBaseParam.GP, EBaseParam.Gathering, EBaseParam.Perception] + }; + } +} diff --git a/Gearsetter/GameData/EBaseParam.cs b/Gearsetter/GameData/EBaseParam.cs new file mode 100644 index 0000000..a28d8d5 --- /dev/null +++ b/Gearsetter/GameData/EBaseParam.cs @@ -0,0 +1,33 @@ +namespace Gearsetter.GameData +{ + internal enum EBaseParam : byte + { + Strength = 1, + Dexterity = 2, + Vitality = 3, + Intelligence = 4, + Mind = 5, + Piety = 6, + + GP = 10, + CP = 11, + + DamagePhys = 12, + DamageMag = 13, + + DefensePhys = 21, + DefenseMag = 24, + + Tenacity = 19, + Crit = 27, + DirectHit = 22, + Determination = 44, + SpellSpeed = 46, + SkillSpeed = 23, + + Craftsmanship = 70, + Control = 71, + Gathering = 72, + Perception = 73, + } +} diff --git a/Gearsetter/GameData/EClassJob.cs b/Gearsetter/GameData/EClassJob.cs new file mode 100644 index 0000000..f0275ae --- /dev/null +++ b/Gearsetter/GameData/EClassJob.cs @@ -0,0 +1,99 @@ +namespace Gearsetter.GameData; + +internal enum EClassJob : uint +{ + Adventurer = 0, + Gladiator = 1, + Pugilist = 2, + Marauder = 3, + Lancer = 4, + Archer = 5, + Conjurer = 6, + Thaumaturge = 7, + Carpenter = 8, + Blacksmith = 9, + Armorer = 10, + Goldsmith = 11, + Leatherworker = 12, + Weaver = 13, + Alchemist = 14, + Culinarian = 15, + Miner = 16, + Botanist = 17, + Fisher = 18, + Paladin = 19, + Monk = 20, + Warrior = 21, + Dragoon = 22, + Bard = 23, + WhiteMage = 24, + BlackMage = 25, + Arcanist = 26, + Summoner = 27, + Scholar = 28, + Rogue = 29, + Ninja = 30, + Machinist = 31, + DarkKnight = 32, + Astrologian = 33, + Samurai = 34, + RedMage = 35, + BlueMage = 36, + Gunbreaker = 37, + Dancer = 38, + Reaper = 39, + Sage = 40, +} + +internal static class EClassJobExtensions +{ + public static bool IsTank(this EClassJob classJob) => + classJob is EClassJob.Gladiator + or EClassJob.Paladin + or EClassJob.Marauder + or EClassJob.Warrior + or EClassJob.DarkKnight + or EClassJob.Gunbreaker; + + public static bool IsHealer(this EClassJob classJob) => + classJob is EClassJob.Conjurer + or EClassJob.WhiteMage + or EClassJob.Scholar + or EClassJob.Astrologian + or EClassJob.Sage; + + public static bool IsMelee(this EClassJob classJob) => + classJob is EClassJob.Pugilist + or EClassJob.Monk + or EClassJob.Lancer + or EClassJob.Dragoon + or EClassJob.Rogue + or EClassJob.Ninja + or EClassJob.Samurai + or EClassJob.Reaper; + + public static bool IsPhysicalRanged(this EClassJob classJob) => + classJob is EClassJob.Archer + or EClassJob.Bard + or EClassJob.Machinist + or EClassJob.Dancer; + + public static bool IsCaster(this EClassJob classJob) => + classJob is EClassJob.Thaumaturge + or EClassJob.BlackMage + or EClassJob.Arcanist + or EClassJob.Summoner + or EClassJob.RedMage + or EClassJob.BlueMage; + + public static bool DealsPhysicalDamage(this EClassJob classJob) => + classJob.IsTank() || classJob.IsMelee() || classJob.IsPhysicalRanged(); + + public static bool DealsMagicDamage(this EClassJob classJob) => + classJob.IsHealer() || classJob.IsCaster(); + + public static bool IsCrafter(this EClassJob classJob) => + classJob is >= EClassJob.Carpenter and <= EClassJob.Culinarian; + + public static bool IsGatherer(this EClassJob classJob) => classJob is >= EClassJob.Miner and <= EClassJob.Fisher; +} diff --git a/Gearsetter/GameData/EEquipSlotCategory.cs b/Gearsetter/GameData/EEquipSlotCategory.cs new file mode 100644 index 0000000..ad9361e --- /dev/null +++ b/Gearsetter/GameData/EEquipSlotCategory.cs @@ -0,0 +1,25 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Gearsetter.GameData; + +[SuppressMessage("Performance", "CA1028", Justification = "uint in Lumina")] +[SuppressMessage("Design", "CA1027", Justification = "Not Flags")] +public enum EEquipSlotCategory : uint +{ + None = 0, + OneHandedMainHand = 1, + Shield = 2, + Head = 3, + Body = 4, + Hands = 5, + Legs = 7, + Feet = 8, + Ears = 9, + Neck = 10, + Wrists = 11, + Rings = 12, + TwoHandedMainHand = 13, + + // 14 isn't used + // anything beyond is very weird gear, taking up multiple inventory slots; irrelevant in anything beyond ARR +} diff --git a/Gearsetter/GameData/GameDataHolder.cs b/Gearsetter/GameData/GameDataHolder.cs new file mode 100644 index 0000000..ee9c9f7 --- /dev/null +++ b/Gearsetter/GameData/GameDataHolder.cs @@ -0,0 +1,204 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Dalamud; +using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Client.Game; +using Gearsetter.Model; +using Lumina.Excel.GeneratedSheets; + +namespace Gearsetter.GameData; + +internal sealed class GameDataHolder +{ + private readonly Configuration _configuration; + private readonly Dictionary> _classJobCategories; + private readonly IReadOnlyList _allItemLists; + + public GameDataHolder(IDataManager dataManager, Configuration configuration) + { + _configuration = configuration; + _classJobCategories = dataManager.GetExcelSheet()! + .ToDictionary(x => x.RowId, x => + new Dictionary + { + { EClassJob.Adventurer, x.ADV }, + { EClassJob.Gladiator, x.GLA }, + { EClassJob.Pugilist, x.PGL }, + { EClassJob.Marauder, x.MRD }, + { EClassJob.Lancer, x.LNC }, + { EClassJob.Archer, x.ARC }, + { EClassJob.Conjurer, x.CNJ }, + { EClassJob.Thaumaturge, x.THM }, + { EClassJob.Carpenter, x.CRP }, + { EClassJob.Blacksmith, x.BSM }, + { EClassJob.Armorer, x.ARM }, + { EClassJob.Goldsmith, x.GSM }, + { EClassJob.Leatherworker, x.LTW }, + { EClassJob.Weaver, x.WVR }, + { EClassJob.Alchemist, x.ALC }, + { EClassJob.Culinarian, x.CUL }, + { EClassJob.Miner, x.MIN }, + { EClassJob.Botanist, x.BTN }, + { EClassJob.Fisher, x.FSH }, + { EClassJob.Paladin, x.PLD }, + { EClassJob.Monk, x.MNK }, + { EClassJob.Warrior, x.WAR }, + { EClassJob.Dragoon, x.DRG }, + { EClassJob.Bard, x.BRD }, + { EClassJob.WhiteMage, x.WHM }, + { EClassJob.BlackMage, x.BLM }, + { EClassJob.Arcanist, x.ACN }, + { EClassJob.Summoner, x.SMN }, + { EClassJob.Scholar, x.SCH }, + { EClassJob.Rogue, x.ROG }, + { EClassJob.Ninja, x.NIN }, + { EClassJob.Machinist, x.MCH }, + { EClassJob.DarkKnight, x.DRK }, + { EClassJob.Astrologian, x.AST }, + { EClassJob.Samurai, x.SAM }, + { EClassJob.RedMage, x.RDM }, + { EClassJob.BlueMage, x.BLU }, + { EClassJob.Gunbreaker, x.GNB }, + { EClassJob.Dancer, x.DNC }, + { EClassJob.Reaper, x.RPR }, + { EClassJob.Sage, x.SGE }, + } + .Where(y => y.Value) + .Select(y => y.Key) + .ToList()); + ClassJobNames = dataManager.GetExcelSheet()! + .Where(x => x.RowId > 0 && Enum.IsDefined(typeof(EClassJob), x.RowId)) + .OrderBy(x => x.UIPriority) + .Select(x => ((EClassJob)x.RowId, + dataManager.Language == ClientLanguage.English ? x.NameEnglish.ToString() : x.Name.ToString())) + .ToList(); + PrimaryStats = dataManager.GetExcelSheet()! + .Where(x => x.RowId > 0 && Enum.IsDefined(typeof(EClassJob), x.RowId)) + .Where(x => x.PrimaryStat > 0) + .ToDictionary(x => (EClassJob)x.RowId, x => (EBaseParam)x.PrimaryStat); + ItemUiCategoryNames = dataManager.GetExcelSheet()! + .Where(x => x.RowId > 0) + .OrderBy(x => x.OrderMajor) + .ThenBy(x => x.OrderMinor) + .Select(x => (x.RowId, x.Name.ToString())) + .ToList(); + + _allItemLists = + dataManager.GetExcelSheet()! + .Where(x => x.RowId > 1600) // exclude outdated names + .Where(x => x.EquipSlotCategory.Row > 0 && + Enum.IsDefined(typeof(EEquipSlotCategory), x.EquipSlotCategory.Row)) + .Where(x => x.LevelItem.Row > 1) // ignore ilvl 1 glamour items (also includes starter weapons) + .SelectMany(LoadItem) + .SelectMany(x => x.ClassJobs.Select(y => x.Item with { ClassJob = y })) + .Where(x => + { + bool isGatheringItem = x.HasAnyStat(EBaseParam.Gathering, EBaseParam.Perception, EBaseParam.GP); + if (x.ClassJob.IsGatherer()) + return isGatheringItem; + + bool isCraftingItem = x.HasAnyStat(EBaseParam.Craftsmanship, EBaseParam.Control, EBaseParam.CP); + if (x.ClassJob.IsGatherer()) + return isCraftingItem; + + return !isGatheringItem && !isCraftingItem; + }) + .GroupBy(item => new + { + item.ClassJob, + item.EquipSlotCategory, + item.ItemUiCategory, + }) + .Select(x => new ItemList + { + ClassJob = x.Key.ClassJob, + EquipSlotCategory = (EEquipSlotCategory)x.Key.EquipSlotCategory, + ItemUiCategory = x.Key.ItemUiCategory, + Items = x.ToList(), + }).ToList() + .AsReadOnly(); + + UpdateAndSortLists(); + } + + + public IReadOnlyList<(EClassJob ClassJob, string Name)> ClassJobNames { get; } + public IReadOnlyList<(uint ItemUiCategory, string Name)> ItemUiCategoryNames { get; } + public Dictionary PrimaryStats { get; } + + public Dictionary StatNames { get; } = new() + { + { EBaseParam.Crit, "Crit" }, + { EBaseParam.DirectHit, "DH" }, + { EBaseParam.Determination, "Det" }, + { EBaseParam.SkillSpeed, "SkS" }, + { EBaseParam.SpellSpeed, "SpS" }, + { EBaseParam.Tenacity, "Tenacity" }, + + { EBaseParam.CP, "CP" }, + { EBaseParam.Craftsmanship, "CMS" }, + { EBaseParam.Control, "Control" }, + + { EBaseParam.GP, "GP" }, + { EBaseParam.Gathering, "Gathering" }, + { EBaseParam.Perception, "Perception" }, + }; + + public InventoryType[] DefaultInventoryTypes { get; } = + [ + InventoryType.Inventory1, + InventoryType.Inventory2, + InventoryType.Inventory3, + InventoryType.Inventory4, + InventoryType.ArmoryMainHand, + InventoryType.ArmoryOffHand, + InventoryType.ArmoryHead, + InventoryType.ArmoryBody, + InventoryType.ArmoryHands, + InventoryType.ArmoryLegs, + InventoryType.ArmoryFeets, + InventoryType.ArmoryEar, + InventoryType.ArmoryNeck, + InventoryType.ArmoryWrist, + InventoryType.ArmoryRings, + InventoryType.EquippedItems + ]; + + public void UpdateAndSortLists() + { + foreach (ItemList itemList in _allItemLists) + { + itemList.UpdateStats(PrimaryStats, _configuration); + itemList.Sort(); + } + } + + public IEnumerable GetItemLists(EClassJob classJob) + => _allItemLists.Where(x => x.ClassJob == classJob); + + public ItemList? GetItemList(EClassJob classJob, EEquipSlotCategory equipSlotCategory) + => _allItemLists.SingleOrDefault(x => x.ClassJob == classJob && x.EquipSlotCategory == equipSlotCategory); + + public IList<(EEquipSlotCategory EquipSlotCategory, string UiCategoryName)> GetItemListsForJob(EClassJob classJob) + { + return ItemUiCategoryNames + .Select(x => new + { + x.Name, + List = _allItemLists.SingleOrDefault( + y => y.ClassJob == classJob && x.ItemUiCategory == y.ItemUiCategory) + }) + .Where(x => x.List != null) + .Select(x => (x.List!.EquipSlotCategory, x.Name)) + .ToList(); + } + + private IEnumerable<(EquipmentItem Item, List ClassJobs)> LoadItem(Item item) + { + var classJobCategories = _classJobCategories[item.ClassJobCategory.Row]; + yield return (new EquipmentItem(item, false), classJobCategories); + if (item.CanBeHq) + yield return (new EquipmentItem(item, true), classJobCategories); + } +} diff --git a/Gearsetter/Gearsetter.csproj b/Gearsetter/Gearsetter.csproj index 863ece5..fc2a64e 100644 --- a/Gearsetter/Gearsetter.csproj +++ b/Gearsetter/Gearsetter.csproj @@ -1,7 +1,7 @@ net8.0-windows - 0.3 + 0.4 12 enable true diff --git a/Gearsetter/GearsetterPlugin.cs b/Gearsetter/GearsetterPlugin.cs index ddd253e..35bd196 100644 --- a/Gearsetter/GearsetterPlugin.cs +++ b/Gearsetter/GearsetterPlugin.cs @@ -6,11 +6,15 @@ using System.Text; using Dalamud.Game.Command; using Dalamud.Game.Text.SeStringHandling; using Dalamud.Game.Text.SeStringHandling.Payloads; +using Dalamud.Interface.Windowing; using Dalamud.Plugin; using Dalamud.Plugin.Services; using FFXIVClientStructs.FFXIV.Client.Game; using FFXIVClientStructs.FFXIV.Client.Game.UI; using FFXIVClientStructs.FFXIV.Client.UI.Misc; +using Gearsetter.GameData; +using Gearsetter.Model; +using Gearsetter.Windows; using Lumina.Excel.GeneratedSheets; namespace Gearsetter; @@ -18,37 +22,19 @@ namespace Gearsetter; [SuppressMessage("ReSharper", "UnusedType.Global")] public sealed class GearsetterPlugin : IDalamudPlugin { - private static readonly InventoryType[] DefaultInventoryTypes = - [ - InventoryType.Inventory1, - InventoryType.Inventory2, - InventoryType.Inventory3, - InventoryType.Inventory4, - InventoryType.ArmoryMainHand, - InventoryType.ArmoryOffHand, - InventoryType.ArmoryHead, - InventoryType.ArmoryBody, - InventoryType.ArmoryHands, - InventoryType.ArmoryLegs, - InventoryType.ArmoryFeets, - InventoryType.ArmoryEar, - InventoryType.ArmoryNeck, - InventoryType.ArmoryWrist, - InventoryType.ArmoryRings, - InventoryType.EquippedItems - ]; - + private readonly WindowSystem _windowSystem = new(nameof(GearsetterPlugin)); private readonly DalamudPluginInterface _pluginInterface; private readonly ICommandManager _commandManager; private readonly IChatGui _chatGui; private readonly IDataManager _dataManager; private readonly IPluginLog _pluginLog; private readonly IClientState _clientState; + private readonly Configuration _configuration; + private readonly GameDataHolder _gameDataHolder; + private readonly EquipmentBrowserWindow _equipmentBrowserWindow; private readonly IReadOnlyDictionary _linkPayloads; - private readonly Dictionary> _classJobCategories; - private readonly Dictionary _classJobToArrayIndex; - private readonly Dictionary _cachedItems = new(); + private readonly Dictionary _classJobToArrayIndex; public GearsetterPlugin(DalamudPluginInterface pluginInterface, ICommandManager commandManager, IChatGui chatGui, IDataManager dataManager, IPluginLog pluginLog, IClientState clientState) @@ -62,63 +48,34 @@ public sealed class GearsetterPlugin : IDalamudPlugin _pluginLog = pluginLog; _clientState = clientState; - _commandManager.AddHandler("/gup", new CommandInfo(ProcessCommand)); + Configuration? configuration = (Configuration?)_pluginInterface.GetPluginConfig(); + if (configuration == null) + { + configuration = Configuration.Create(); + _pluginInterface.SavePluginConfig(configuration); + } + + _configuration = configuration; + _gameDataHolder = new GameDataHolder(dataManager, _configuration); + _equipmentBrowserWindow = new EquipmentBrowserWindow(this, _gameDataHolder, _clientState, _chatGui); + _windowSystem.AddWindow(_equipmentBrowserWindow); + + _commandManager.AddHandler("/gup", new CommandInfo(ShowUpgrades) + { + HelpMessage = "Show possible gear upgrades for all gearsets" + }); + _commandManager.AddHandler("/gbrowser", new CommandInfo(ToggleEquipmentBrowser) + { + HelpMessage = "Toggle the equipment browser window" + }); _linkPayloads = Enumerable.Range(0, 100) .ToDictionary(x => (byte)x, x => _pluginInterface.AddChatLinkHandler((byte)x, ChangeGearset)).AsReadOnly(); _clientState.TerritoryChanged += TerritoryChanged; + _pluginInterface.UiBuilder.Draw += _windowSystem.Draw; - _classJobToArrayIndex = dataManager.GetExcelSheet()! - .Where(x => x.RowId > 0) - .ToDictionary(x => (byte)x.RowId, x => (byte)x.ExpArrayIndex); - _classJobCategories = _dataManager.GetExcelSheet()! - .ToDictionary(x => x.RowId, x => - new Dictionary - { - { ClassJob.Adventurer, x.ADV }, - { ClassJob.Gladiator, x.GLA }, - { ClassJob.Pugilist, x.PGL }, - { ClassJob.Marauder, x.MRD }, - { ClassJob.Lancer, x.LNC }, - { ClassJob.Archer, x.ARC }, - { ClassJob.Conjurer, x.CNJ }, - { ClassJob.Thaumaturge, x.THM }, - { ClassJob.Carpenter, x.CRP }, - { ClassJob.Blacksmith, x.BSM }, - { ClassJob.Armorer, x.ARM }, - { ClassJob.Goldsmith, x.GSM }, - { ClassJob.Leatherworker, x.LTW }, - { ClassJob.Weaver, x.WVR }, - { ClassJob.Alchemist, x.ALC }, - { ClassJob.Culinarian, x.CUL }, - { ClassJob.Miner, x.MIN }, - { ClassJob.Botanist, x.BTN }, - { ClassJob.Fisher, x.FSH }, - { ClassJob.Paladin, x.PLD }, - { ClassJob.Monk, x.MNK }, - { ClassJob.Warrior, x.WAR }, - { ClassJob.Dragoon, x.DRG }, - { ClassJob.Bard, x.BRD }, - { ClassJob.WhiteMage, x.WHM }, - { ClassJob.BlackMage, x.BLM }, - { ClassJob.Arcanist, x.ACN }, - { ClassJob.Summoner, x.SMN }, - { ClassJob.Scholar, x.SCH }, - { ClassJob.Rogue, x.ROG }, - { ClassJob.Ninja, x.NIN }, - { ClassJob.Machinist, x.MCH }, - { ClassJob.DarkKnight, x.DRK }, - { ClassJob.Astrologian, x.AST }, - { ClassJob.Samurai, x.SAM }, - { ClassJob.RedMage, x.RDM }, - { ClassJob.BlueMage, x.BLU }, - { ClassJob.Gunbreaker, x.GNB }, - { ClassJob.Dancer, x.DNC }, - { ClassJob.Reaper, x.RPR }, - { ClassJob.Sage, x.SGE }, - } - .Where(y => y.Value) - .Select(y => y.Key) - .ToList()); + _classJobToArrayIndex = dataManager.GetExcelSheet()! + .Where(x => x.RowId > 0 && Enum.IsDefined(typeof(EClassJob), x.RowId)) + .ToDictionary(x => (EClassJob)x.RowId, x => (byte)x.ExpArrayIndex); } private void TerritoryChanged(ushort territory) @@ -127,26 +84,15 @@ public sealed class GearsetterPlugin : IDalamudPlugin ShowUpgrades(); } - private void ProcessCommand(string command, string arguments) => ShowUpgrades(); + + private void ToggleEquipmentBrowser(string command, string arguments) + => _equipmentBrowserWindow.Toggle(); + + private void ShowUpgrades(string command, string arguments) => ShowUpgrades(); private unsafe void ShowUpgrades() { - var inventoryManager = InventoryManager.Instance(); - List inventoryItems = new(); - foreach (var inventoryType in DefaultInventoryTypes) - { - var container = inventoryManager->GetInventoryContainer(inventoryType); - for (int i = 0; i < container->Size; ++i) - { - var item = container->GetInventorySlot(i); - if (item != null && item->ItemID != 0) - { - CachedItem? cachedItem = LookupItem(item->ItemID, item->Flags.HasFlag(InventoryItem.ItemFlags.HQ)); - if (cachedItem != null) - inventoryItems.Add(cachedItem); - } - } - } + var inventoryItems = GetAllInventoryItems(); var gearsetModule = RaptureGearsetModule.Instance(); if (gearsetModule == null) @@ -166,7 +112,8 @@ public sealed class GearsetterPlugin : IDalamudPlugin _chatGui.Print("All your gearsets are OK."); } - private unsafe bool HandleGearset(RaptureGearsetModule.GearsetEntry* gearset, List inventoryItems) + private unsafe bool HandleGearset(RaptureGearsetModule.GearsetEntry* gearset, + Dictionary<(uint ItemId, bool Hq), int> inventoryItems) { string name = GetGearsetName(gearset); if (name.Contains('_', StringComparison.Ordinal) || @@ -176,19 +123,20 @@ public sealed class GearsetterPlugin : IDalamudPlugin List> upgrades = new() { - HandleGearsetItem("Main Hand", gearset, gearset->MainHand, inventoryItems), - HandleGearsetItem("Off Hand", gearset, gearset->OffHand, inventoryItems), + HandleGearsetItem("Main Hand", gearset, [gearset->ItemsSpan[0]], inventoryItems, EEquipSlotCategory.None), + HandleOffHand(gearset, inventoryItems), - HandleGearsetItem("Head", gearset, gearset->Head, inventoryItems), - HandleGearsetItem("Body", gearset, gearset->Body, inventoryItems), - HandleGearsetItem("Hands", gearset, gearset->Hands, inventoryItems), - HandleGearsetItem("Legs", gearset, gearset->Legs, inventoryItems), - HandleGearsetItem("Feet", gearset, gearset->Feet, inventoryItems), + HandleGearsetItem("Head", gearset, [gearset->ItemsSpan[2]], inventoryItems, EEquipSlotCategory.Head), + HandleGearsetItem("Body", gearset, [gearset->ItemsSpan[3]], inventoryItems, EEquipSlotCategory.Body), + HandleGearsetItem("Hands", gearset, [gearset->ItemsSpan[4]], inventoryItems, EEquipSlotCategory.Hands), + HandleGearsetItem("Legs", gearset, [gearset->ItemsSpan[6]], inventoryItems, EEquipSlotCategory.Legs), + HandleGearsetItem("Feet", gearset, [gearset->ItemsSpan[7]], inventoryItems, EEquipSlotCategory.Feet), - HandleGearsetItem("Ears", gearset, gearset->Ears, inventoryItems), - HandleGearsetItem("Neck", gearset, gearset->Neck, inventoryItems), - HandleGearsetItem("Wrists", gearset, gearset->Wrists, inventoryItems), - HandleGearsetItem("Rings", gearset, new[] { gearset->RingRight, gearset->RingLeft }, inventoryItems), + HandleGearsetItem("Ears", gearset, [gearset->ItemsSpan[8]], inventoryItems, EEquipSlotCategory.Ears), + HandleGearsetItem("Neck", gearset, [gearset->ItemsSpan[9]], inventoryItems, EEquipSlotCategory.Neck), + HandleGearsetItem("Wrists", gearset, [gearset->ItemsSpan[10]], inventoryItems, EEquipSlotCategory.Wrists), + HandleGearsetItem("Rings", gearset, [gearset->ItemsSpan[11], gearset->ItemsSpan[12]], inventoryItems, + EEquipSlotCategory.Rings), }; List flatUpgrades = upgrades.SelectMany(x => x).ToList(); @@ -220,94 +168,133 @@ public sealed class GearsetterPlugin : IDalamudPlugin => Encoding.UTF8.GetString(gearset->Name, 0x2F).Split((char)0)[0]; private unsafe List HandleGearsetItem(string label, RaptureGearsetModule.GearsetEntry* gearset, - RaptureGearsetModule.GearsetItem gearsetItem, List inventoryItems) - => HandleGearsetItem(label, gearset, new[] { gearsetItem }, inventoryItems); - - private unsafe List HandleGearsetItem(string label, RaptureGearsetModule.GearsetEntry* gearset, - RaptureGearsetModule.GearsetItem[] gearsetItem, List inventoryItems) + RaptureGearsetModule.GearsetItem[] gearsetItem, Dictionary<(uint ItemId, bool Hq), int> inventoryItems, + EEquipSlotCategory equipSlotCategory) { - gearsetItem = gearsetItem.Where(x => x.ItemID != 0).ToArray(); - if (gearsetItem.Length > 0) + EClassJob classJob = (EClassJob)gearset->ClassJob; + var itemLists = _gameDataHolder.GetItemLists(classJob); + + if (gearsetItem.Any(x => x.ItemID > 0)) { - ClassJob classJob = (ClassJob)gearset->ClassJob; - CachedItem[] currentItems = gearsetItem.Select(x => LookupItem(x.ItemID)).Where(x => x != null) - .Select(x => x!).ToArray(); - if (currentItems.Length == 0) - { - _pluginLog.Information($"Unable to find gearset items"); - return new List(); - } - - var level = PlayerState.Instance()->ClassJobLevelArray[ - _classJobToArrayIndex[gearset->ClassJob]]; - - var bestItems = inventoryItems - .Where(x => x.EquipSlotCategory == currentItems[0].EquipSlotCategory) - .Where(x => x.Level <= level) - .Where(x => x.ClassJobs.Contains(classJob)) - .Where(x => x.CalculateScore(classJob, level) > 0) - .OrderByDescending(x => x.CalculateScore(classJob, level)) - .Take(gearsetItem.Length) - .ToList(); - foreach (var currentItem in currentItems) - { - if (bestItems.Contains(currentItem)) - bestItems.Remove(currentItem); - } - - // don't make suggestions for equal scores - bestItems.RemoveAll(x => - x.CalculateScore(classJob, level) == - currentItems.Select(y => y.CalculateScore(classJob, level)).Max()); - - return bestItems - .Select(x => new SeString(new TextPayload($"{label}: ")) - .Append(SeString.CreateItemLink(x.ItemId, x.Hq))).ToList(); + var firstEquippedItem = gearsetItem.First(x => x.ItemID > 0); + equipSlotCategory = (EEquipSlotCategory)(_dataManager.GetExcelSheet()! + .GetRow(firstEquippedItem.ItemID % 1_000_000) + ?.EquipSlotCategory?.Row ?? 0); } - return new List(); + if (equipSlotCategory == EEquipSlotCategory.None) + { + _pluginLog.Warning($"Unable to find item to determine equip slot category"); + return new List(); + } + + EquipmentItem?[] currentItems = gearsetItem.Select(x => new + { + ItemId = x.ItemID % 1_000_000, + Hq = x.ItemID > 1_000_000 + }) + .Select(x => + { + if (x.ItemId == 0) + return null; + + return itemLists + .SelectMany(y => y.Items.Where(z => x.ItemId == z.ItemId && x.Hq == z.Hq)) + .FirstOrDefault(); + }) + .ToArray(); + + var availableList = _gameDataHolder.GetItemList(classJob, equipSlotCategory); + if (availableList == null) + return new List(); + + var level = GetLevel(classJob); + var bestItems = availableList.Items + .Where(x => x.Level <= level) + .SelectMany(x => + { + if (inventoryItems.TryGetValue((x.ItemId, x.Hq), out int count) && count > 0) + return Enumerable.Repeat(x, count); + else + return []; + }) + .Take(gearsetItem.Length) + .ToList(); + _pluginLog.Information( + $"{equipSlotCategory}: {string.Join(" ", currentItems.Select(x => $"{x?.ItemId}|{x?.Hq}"))}"); + foreach (var currentItem in currentItems) + { + var foundIndex = bestItems.FindIndex(x => + currentItem != null && currentItem.ItemId == x.ItemId && currentItem.Hq == x.Hq); + if (foundIndex >= 0) + bestItems.RemoveAt(foundIndex); + } + + return bestItems + .Select(x => new SeString(new TextPayload($"{label}: ")) + .Append(SeString.CreateItemLink(x.ItemId, x.Hq))).ToList(); } - private CachedItem? LookupItem(uint itemId) + + private unsafe List HandleOffHand(RaptureGearsetModule.GearsetEntry* gearset, + Dictionary<(uint ItemId, bool Hq), int> inventoryItems) { - if (_cachedItems.TryGetValue(itemId, out CachedItem? cachedItem)) - return cachedItem; + var mainHand = gearset->ItemsSpan[0]; + if (mainHand.ItemID == 0) + return new List(); - try - { - var item = _dataManager.GetExcelSheet()!.GetRow(itemId % 1_000_000)!; - cachedItem = new CachedItem - { - Item = item, - ItemId = item.RowId, - Hq = itemId > 1_000_000, - Name = item.Name.ToString(), - Level = item.LevelEquip, - ItemLevel = item.LevelItem.Row, - Rarity = item.Rarity, - EquipSlotCategory = item.EquipSlotCategory.Row, - ClassJobs = _classJobCategories[item.ClassJobCategory.Row], - }; - _cachedItems[itemId] = cachedItem; - return cachedItem; - } - catch (Exception) - { - _pluginLog.Information($"Unable to lookup item {itemId}"); - return null; - } + // if it's a twohanded weapon, ignore it + EEquipSlotCategory equipSlotCategory = + (EEquipSlotCategory)(_dataManager.GetExcelSheet()!.GetRow(mainHand.ItemID % 1_000_000)?.RowId ?? 0); + if (equipSlotCategory != EEquipSlotCategory.OneHandedMainHand) + return new List(); + + return HandleGearsetItem("Off Hand", gearset, [gearset->ItemsSpan[1]], inventoryItems, + EEquipSlotCategory.Shield); } - private CachedItem? LookupItem(uint itemId, bool hq) - => LookupItem(itemId + (hq ? 1_000_000u : 0)); - private unsafe void ChangeGearset(uint commandId, SeString seString) => RaptureGearsetModule.Instance()->EquipGearset((byte)commandId); + public unsafe Dictionary<(uint ItemId, bool Hq), int> GetAllInventoryItems() + { + Dictionary<(uint, bool), int> inventoryItems = new(); + InventoryManager* inventoryManager = InventoryManager.Instance(); + foreach (var inventoryType in _gameDataHolder.DefaultInventoryTypes) + { + var container = inventoryManager->GetInventoryContainer(inventoryType); + for (int i = 0; i < container->Size; ++i) + { + var item = container->GetInventorySlot(i); + if (item != null && item->ItemID != 0) + { + var key = (item->ItemID, item->Flags.HasFlag(InventoryItem.ItemFlags.HQ)); + if (inventoryItems.TryGetValue(key, out var value)) + inventoryItems[key] = value + 1; + else + inventoryItems[key] = 1; + } + } + } + + return inventoryItems; + } + + internal unsafe byte GetLevel(EClassJob classJob) + { + var playerState = PlayerState.Instance(); + if (playerState == null) + return 0; + + return (byte)playerState->ClassJobLevelArray[_classJobToArrayIndex[classJob]]; + } + public void Dispose() { + _pluginInterface.UiBuilder.Draw -= _windowSystem.Draw; _clientState.TerritoryChanged -= TerritoryChanged; _pluginInterface.RemoveChatLinkHandler(); + _commandManager.RemoveHandler("/gbrowser"); _commandManager.RemoveHandler("/gup"); } } diff --git a/Gearsetter/Model/EquipmentItem.cs b/Gearsetter/Model/EquipmentItem.cs new file mode 100644 index 0000000..60932b5 --- /dev/null +++ b/Gearsetter/Model/EquipmentItem.cs @@ -0,0 +1,50 @@ +using Gearsetter.GameData; +using Lumina.Excel.GeneratedSheets; + +namespace Gearsetter.Model; + +internal sealed record EquipmentItem(Item Item, bool Hq) +{ + public Item Item { get; } = Item; + public uint ItemId { get; } = Item.RowId; + public bool Hq { get; } = Hq; + public bool CanBeHq { get; } = Item.CanBeHq; + public string Name { get; } = Item.Name.ToString(); + public byte Level { get; } = Item.LevelEquip; + public uint ItemLevel { get; } = Item.LevelItem.Row; + public byte Rarity { get; } = Item.Rarity; + public EEquipSlotCategory EquipSlotCategory { get; } = (EEquipSlotCategory)Item.EquipSlotCategory.Row; + public uint ItemUiCategory { get; } = Item.ItemUICategory.Row; + public EClassJob ClassJob { get; init; } = EClassJob.Adventurer; + public EquipmentStats Stats { get; } = new(Item, Hq); + + public int PrimaryStat { get; init; } = -1; + + public int Damage => ClassJob.DealsMagicDamage() + ? Item.DamageMag + Stats.Get(EBaseParam.DamageMag) + : Item.DamagePhys + Stats.Get(EBaseParam.DamagePhys); + + public bool HasAnyStat(params EBaseParam[] substats) + { + foreach (EBaseParam substat in substats) + { + if (Stats.Get(substat) > 0) + return true; + } + + return false; + } + + public bool IsCombatRelicWithoutSubstats() + { + return Rarity == 4 + && EquipSlotCategory is EEquipSlotCategory.OneHandedMainHand + or EEquipSlotCategory.TwoHandedMainHand + or EEquipSlotCategory.Shield + && !ClassJob.IsCrafter() + && !ClassJob.IsGatherer() + && !HasAnyStat(EBaseParam.Crit, + EBaseParam.DirectHit, EBaseParam.Determination, EBaseParam.SkillSpeed, + EBaseParam.SpellSpeed, EBaseParam.Tenacity); + } +} diff --git a/Gearsetter/Model/EquipmentStats.cs b/Gearsetter/Model/EquipmentStats.cs new file mode 100644 index 0000000..72be798 --- /dev/null +++ b/Gearsetter/Model/EquipmentStats.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using System.Linq; +using Gearsetter.GameData; +using Lumina.Excel.GeneratedSheets; + +namespace Gearsetter.Model +{ + internal sealed class EquipmentStats + { + private readonly Dictionary _values; + + public EquipmentStats(Item item, bool hq) + { + _values = item.UnkData59.Where(x => x.BaseParam > 0) + .ToDictionary(x => (EBaseParam)x.BaseParam, x => x.BaseParamValue); + if (hq) + { + foreach (var hqstat in item.UnkData73.Select(x => + ((EBaseParam)x.BaseParamSpecial, x.BaseParamValueSpecial))) + { + if (_values.TryGetValue(hqstat.Item1, out var stat)) + _values[hqstat.Item1] = (short)(stat + hqstat.BaseParamValueSpecial); + else + _values[hqstat.Item1] = hqstat.BaseParamValueSpecial; + } + } + } + + public short Get(EBaseParam param) + { + _values.TryGetValue(param, out short v); + return v; + } + } +} diff --git a/Gearsetter/Model/ItemList.cs b/Gearsetter/Model/ItemList.cs new file mode 100644 index 0000000..1214f23 --- /dev/null +++ b/Gearsetter/Model/ItemList.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using Gearsetter.GameData; +using Lumina.Excel.GeneratedSheets; + +namespace Gearsetter.Model; + +internal sealed class ItemList +{ + private static readonly ReadOnlyDictionary PreferredItems = new Dictionary() + { + { 41081, 90 }, + { 33648, 80 }, + { 24589, 70 }, + { 16039, 50 } + }.AsReadOnly(); + + public required EClassJob ClassJob { get; init; } + public required EEquipSlotCategory EquipSlotCategory { get; init; } + public required uint ItemUiCategory { get; init; } + public required List Items { get; set; } + public IReadOnlyList SubstatPriorities { get; set; } = new List(); + + public void Sort() + { + Items.Sort((a, b) => -Sort(a, b)); + } + + private int Sort(EquipmentItem a, EquipmentItem b) + { + // special items + if (PreferredItems.ContainsKey(a.ItemId) || PreferredItems.ContainsKey(b.ItemId)) + { + byte? levelA = null; + byte? levelB = null; + if (PreferredItems.TryGetValue(a.ItemId, out byte overrideA)) + levelA = overrideA; + if (PreferredItems.TryGetValue(b.ItemId, out byte overrideB)) + levelB = overrideB; + + if (levelA != null && levelB != null) + return levelA.Value.CompareTo(levelB.Value); + else if (levelA != null) + { + if (levelA == b.Level) + return (a.ItemLevel - 1).CompareTo(b.ItemLevel); + return levelA.Value.CompareTo(b.Level); + } + else if (levelB != null) + { + if (a.Level == levelB) + return a.ItemLevel.CompareTo(b.ItemLevel - 1); + return a.Level.CompareTo(levelB.Value); + } + } + + // weapons: most damage wins + int damageA = a.Damage; + int damageB = b.Damage; + if (damageA != damageB) + return damageA.CompareTo(damageB); + + // gear: primary stat wins + int primaryStatA = a.PrimaryStat; + int primaryStatB = b.PrimaryStat; + if (primaryStatA != primaryStatB) + return primaryStatA.CompareTo(primaryStatB); + + // gear: vitality wins + int vitalityA = a.Stats.Get(EBaseParam.Vitality); + int vitalityB = b.Stats.Get(EBaseParam.Vitality); + if (vitalityA != vitalityB) + return vitalityA.CompareTo(vitalityB); + + // sum of relevant substats + int sumOfSubstatsA = SubstatPriorities.Sum(x => a.Stats.Get(x)); + int sumOfSubstatsB = SubstatPriorities.Sum(x => b.Stats.Get(x)); + + // some relics have no substats in the sheets, since they can be allocated dynamically + // they are -generally- better/equal to any other weapon on that ilvl + if (sumOfSubstatsA == 0 && a.IsCombatRelicWithoutSubstats()) + sumOfSubstatsA = int.MaxValue; + if (sumOfSubstatsB == 0 && b.IsCombatRelicWithoutSubstats()) + sumOfSubstatsB = int.MaxValue; + + if (sumOfSubstatsA != sumOfSubstatsB) + return sumOfSubstatsA.CompareTo(sumOfSubstatsB); + + // level-based sorting + if (a.Level != b.Level) + return a.Level.CompareTo(b.Level); + if (a.ItemLevel != b.ItemLevel) + return a.ItemLevel.CompareTo(b.ItemLevel); + if (a.Rarity != b.Rarity) + return a.Rarity.CompareTo(b.Rarity); + + // individual substats + foreach (EBaseParam substat in SubstatPriorities) + { + int substatA = a.Stats.Get(substat); + int substatB = b.Stats.Get(substat); + if (substatA != substatB) + return substatA.CompareTo(substatB); + } + + // fallback + return string.CompareOrdinal(a.Name, b.Name); + } + + public void UpdateStats(Dictionary primaryStats, Configuration configuration) + { + if (ClassJob.IsTank()) + SubstatPriorities = configuration.StatPriorityTanks; + else if (ClassJob.IsHealer()) + SubstatPriorities = configuration.StatPriorityHealer; + else if (ClassJob.IsMelee()) + SubstatPriorities = configuration.StatPriorityMelee; + else if (ClassJob.IsPhysicalRanged()) + SubstatPriorities = configuration.StatPriorityPhysicalRanged; + else if (ClassJob.IsCaster()) + SubstatPriorities = configuration.StatPriorityCaster; + else if (ClassJob.IsCrafter()) + SubstatPriorities = configuration.StatPriorityCrafter; + else if (ClassJob.IsGatherer()) + SubstatPriorities = configuration.StatPriorityGatherer; + else + SubstatPriorities = []; + + if (primaryStats.TryGetValue(ClassJob, out EBaseParam primaryStat)) + { + Items = Items + .Select(x => x with { PrimaryStat = x.Stats.Get(primaryStat) }) + .ToList(); + } + } +} diff --git a/Gearsetter/Windows/EquipmentBrowserWindow.cs b/Gearsetter/Windows/EquipmentBrowserWindow.cs new file mode 100644 index 0000000..5585048 --- /dev/null +++ b/Gearsetter/Windows/EquipmentBrowserWindow.cs @@ -0,0 +1,213 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Numerics; +using Dalamud.Game.Text; +using Dalamud.Game.Text.SeStringHandling; +using Dalamud.Interface.Colors; +using Dalamud.Interface.Windowing; +using Dalamud.Plugin.Services; +using Gearsetter.GameData; +using ImGuiNET; + +namespace Gearsetter.Windows; + +internal sealed class EquipmentBrowserWindow : Window +{ + private readonly GearsetterPlugin _plugin; + private readonly GameDataHolder _dataHolder; + private readonly IClientState _clientState; + private readonly IChatGui _chatGui; + private readonly string[] _classJobNames; + private readonly EClassJob[] _classJobIds; + + private EClassJob _selectedClassJob = EClassJob.Paladin; + private EEquipSlotCategory _selectedEquipmentCategory = EEquipSlotCategory.None; + private string[] _equipmentCategoryNames = []; + private EEquipSlotCategory[] _equipmentCategoryIds = []; + + private bool _onlyShowOwnedItems; + private bool _onlyShowEquippableItems; + private bool _hideNormalQualityItems = true; + + public EquipmentBrowserWindow(GearsetterPlugin plugin, GameDataHolder dataHolder, IClientState clientState, + IChatGui chatGui) + : base("Equipment Browser") + { + _plugin = plugin; + _dataHolder = dataHolder; + _clientState = clientState; + _chatGui = chatGui; + _classJobNames = dataHolder.ClassJobNames.Select(x => x.Name).ToArray(); + _classJobIds = dataHolder.ClassJobNames.Select(x => x.ClassJob).ToArray(); + UpdateEquipmentCategories(); + + SizeConstraints = new WindowSizeConstraints + { + MinimumSize = new Vector2(800, 500) + }; + } + + public override void OnOpen() + { + if (_clientState.LocalPlayer != null) + _selectedClassJob = (EClassJob)_clientState.LocalPlayer.ClassJob.Id; + + UpdateEquipmentCategories(); + } + + public override bool DrawConditions() + { + return _clientState.IsLoggedIn; + } + + public override void Draw() + { + int currentClassJob = Array.IndexOf(_classJobIds, _selectedClassJob); + if (currentClassJob == -1) + { + _selectedClassJob = EClassJob.Paladin; + currentClassJob = Array.IndexOf(_classJobIds, _selectedClassJob); + UpdateEquipmentCategories(); + } + + if (ImGui.Combo("Class/Job", ref currentClassJob, _classJobNames, _classJobNames.Length)) + { + _selectedClassJob = _classJobIds[currentClassJob]; + UpdateEquipmentCategories(); + } + + int currentCategory = Array.IndexOf(_equipmentCategoryIds, _selectedEquipmentCategory); + if (currentCategory == -1) + { + if (_equipmentCategoryIds.Length == 0) + return; + + _selectedEquipmentCategory = _equipmentCategoryIds[0]; + currentCategory = 0; + } + + if (ImGui.Combo("Category", ref currentCategory, _equipmentCategoryNames, _equipmentCategoryNames.Length)) + _selectedEquipmentCategory = _equipmentCategoryIds[currentCategory]; + + var itemList = _dataHolder.GetItemList(_selectedClassJob, _selectedEquipmentCategory); + if (itemList == null) + return; + + ImGui.Checkbox("Only show items matching your level", ref _onlyShowEquippableItems); + ImGui.SameLine(); + ImGui.Checkbox("Only show owned items", ref _onlyShowOwnedItems); + ImGui.SameLine(); + ImGui.Checkbox("Hide normal quality items", ref _hideNormalQualityItems); + + Dictionary<(uint, bool), int>? ownedItems = null; + if (_onlyShowOwnedItems) + ownedItems = _plugin.GetAllInventoryItems(); + + byte maxLevel = byte.MaxValue; + if (_onlyShowEquippableItems) + maxLevel = _plugin.GetLevel(_selectedClassJob); + + bool includeDamage = _selectedEquipmentCategory is EEquipSlotCategory.OneHandedMainHand + or EEquipSlotCategory.Shield + or EEquipSlotCategory.TwoHandedMainHand + && !itemList.ClassJob.IsCrafter() + && !itemList.ClassJob.IsGatherer(); + if (ImGui.BeginTable("ItemList", 2 + (includeDamage ? 1 : 0) + itemList.SubstatPriorities.Count, + ImGuiTableFlags.Borders | ImGuiTableFlags.Resizable)) + { + ImGui.TableSetupColumn("Name", ImGuiTableColumnFlags.None, 300); + ImGui.TableSetupColumn("Level", ImGuiTableColumnFlags.WidthFixed, 50); + if (includeDamage) + ImGui.TableSetupColumn("Damage", ImGuiTableColumnFlags.WidthFixed, 50); + foreach (var substat in itemList.SubstatPriorities) + ImGui.TableSetupColumn(_dataHolder.StatNames[substat], ImGuiTableColumnFlags.WidthFixed, 50); + + ImGui.TableHeadersRow(); + + foreach (var item in itemList.Items) + { + if (ownedItems != null && !ownedItems.ContainsKey((item.ItemId, item.Hq))) + continue; + + if (item.Level > maxLevel) + continue; + + if (_hideNormalQualityItems && item.CanBeHq && !item.Hq) + continue; + + ImGui.TableNextRow(); + + if (ImGui.TableNextColumn()) + { + Vector4? color = item.Rarity switch + { + 2 => ImGuiColors.ParsedGreen, + 3 => ImGuiColors.ParsedBlue, + 4 => ImGuiColors.ParsedPurple, + _ => null, + }; + + string name = item.Name; + if (item.Hq) + name += $" {SeIconChar.HighQuality.ToIconString()}"; + + if (color != null) + ImGui.TextColored(color.Value, name); + else + ImGui.Text(name); + + if (ImGui.IsItemClicked()) + { + try + { + _chatGui.Print(SeString.CreateItemLink(item.ItemId, item.Hq)); + } + catch (Exception) + { + // doesn't matter, just nice-to-have + } + } + } + + if (ImGui.TableNextColumn()) + { + if (item.Level >= 50 && item.Level % 10 == 0) + ImGui.Text(string.Create(CultureInfo.InvariantCulture, $"{item.Level} ({item.ItemLevel})")); + else + ImGui.Text(item.Level.ToString(CultureInfo.InvariantCulture)); + } + + if (includeDamage && ImGui.TableNextColumn()) + ImGui.Text(item.Damage.ToString(CultureInfo.CurrentCulture)); + + foreach (EBaseParam substat in itemList.SubstatPriorities) + { + if (ImGui.TableNextColumn()) + { + var stat = item.Stats.Get(substat); + if (stat == 0) + ImGui.Text("-"); + else + ImGui.Text(stat.ToString(CultureInfo.CurrentCulture)); + } + } + } + + ImGui.EndTable(); + } + } + + private void UpdateEquipmentCategories() + { + var categories = _dataHolder.GetItemListsForJob(_selectedClassJob); + _equipmentCategoryNames = categories.Select(x => x.UiCategoryName).ToArray(); + _equipmentCategoryIds = categories.Select(x => x.EquipSlotCategory).ToArray(); + + if (_equipmentCategoryIds.Length > 0 && !_equipmentCategoryIds.Contains(_selectedEquipmentCategory)) + _selectedEquipmentCategory = _equipmentCategoryIds[0]; + else if (_equipmentCategoryIds.Length == 0) + _selectedEquipmentCategory = EEquipSlotCategory.None; + } +}