diff --git a/Gearsetter.Test/.gitignore b/Gearsetter.Test/.gitignore new file mode 100644 index 0000000..958518b --- /dev/null +++ b/Gearsetter.Test/.gitignore @@ -0,0 +1,3 @@ +/dist +/obj +/bin diff --git a/Gearsetter.Test/Gearsetter.Test.csproj b/Gearsetter.Test/Gearsetter.Test.csproj new file mode 100644 index 0000000..425c666 --- /dev/null +++ b/Gearsetter.Test/Gearsetter.Test.csproj @@ -0,0 +1,47 @@ + + + + net8.0-windows + enable + enable + + false + true + + + + $(appdata)\XIVLauncher\addon\Hooks\dev\ + + + + $(DALAMUD_HOME)/ + + + + + + + + + + + + $(DalamudLibPath)Dalamud.dll + + + $(DalamudLibPath)Lumina.dll + + + $(DalamudLibPath)Lumina.Excel.dll + + + + + + + + + + + + diff --git a/Gearsetter.Test/ItemSortingTest.cs b/Gearsetter.Test/ItemSortingTest.cs new file mode 100644 index 0000000..f6743e5 --- /dev/null +++ b/Gearsetter.Test/ItemSortingTest.cs @@ -0,0 +1,59 @@ +using Gearsetter.GameData; +using Gearsetter.Model; +using Lumina.Excel.GeneratedSheets; + +namespace Gearsetter.Test; + +public sealed class ItemSortingTest +{ + Lumina.GameData _lumina = new( "C:/Program Files (x86)/steam/steamapps/common/FINAL FANTASY XIV Online/game/sqpack" ); + + [Fact] + public void Test1() + { + var items = _lumina.GetExcelSheet()!; + List initialItemIds = + [ + 11851, + 11853, + 14447, + 15131, + 16039, + 16240, + 17436, + 25928, + 32558, + ]; + + var itemList = new ItemList + { + ClassJob = EClassJob.Marauder, + EquipSlotCategory = EEquipSlotCategory.Ears, + ItemUiCategory = 41, + Items = initialItemIds.Select(rowId => new EquipmentItem(items.GetRow(rowId)!, false)).ToList(), + + }; + + var primaryStats = _lumina.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); + + itemList.UpdateStats(primaryStats, new Configuration()); + itemList.Sort(); + + List expectedItems = + [ + 32558, + 25928, + 11851, + 17436, + 16240, + 14447, + 11853, + 16039, + 15131, // weathered earrings benefit from having primary stats + ]; + Assert.Equal(expectedItems, itemList.Items.Select(x => x.ItemId).ToList()); + } +} diff --git a/Gearsetter.sln b/Gearsetter.sln index 763ec25..8f40b55 100644 --- a/Gearsetter.sln +++ b/Gearsetter.sln @@ -2,6 +2,8 @@ Microsoft Visual Studio Solution File, Format Version 12.00 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Gearsetter", "Gearsetter\Gearsetter.csproj", "{3E87693D-1FEE-486D-80E9-C6D95E68160F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Gearsetter.Test", "Gearsetter.Test\Gearsetter.Test.csproj", "{19044F87-4C6D-4926-B5C8-5BB7760DC44C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -12,5 +14,9 @@ Global {3E87693D-1FEE-486D-80E9-C6D95E68160F}.Debug|Any CPU.Build.0 = Debug|Any CPU {3E87693D-1FEE-486D-80E9-C6D95E68160F}.Release|Any CPU.ActiveCfg = Release|Any CPU {3E87693D-1FEE-486D-80E9-C6D95E68160F}.Release|Any CPU.Build.0 = Release|Any CPU + {19044F87-4C6D-4926-B5C8-5BB7760DC44C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {19044F87-4C6D-4926-B5C8-5BB7760DC44C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {19044F87-4C6D-4926-B5C8-5BB7760DC44C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {19044F87-4C6D-4926-B5C8-5BB7760DC44C}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/Gearsetter/AssemblyInfo.cs b/Gearsetter/AssemblyInfo.cs new file mode 100644 index 0000000..59510ce --- /dev/null +++ b/Gearsetter/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Gearsetter.Test")] diff --git a/Gearsetter/Gearsetter.csproj b/Gearsetter/Gearsetter.csproj index caa3d77..d13af4c 100644 --- a/Gearsetter/Gearsetter.csproj +++ b/Gearsetter/Gearsetter.csproj @@ -1,7 +1,7 @@ net8.0-windows - 0.5 + 0.6 12 enable true diff --git a/Gearsetter/Model/ItemList.cs b/Gearsetter/Model/ItemList.cs index 1214f23..61a31f0 100644 --- a/Gearsetter/Model/ItemList.cs +++ b/Gearsetter/Model/ItemList.cs @@ -2,8 +2,8 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; +using Dalamud.Logging; using Gearsetter.GameData; -using Lumina.Excel.GeneratedSheets; namespace Gearsetter.Model; @@ -25,88 +25,26 @@ internal sealed class ItemList public void Sort() { - Items.Sort((a, b) => -Sort(a, b)); - } + var preferredItems = Items + .Where(x => PreferredItems.ContainsKey(x.ItemId)) + .ToList(); + var defaultItems = Items + .Except(preferredItems) + .OrderDescending(new EquipmentItemComparer(SubstatPriorities)) + .ToList(); - private int Sort(EquipmentItem a, EquipmentItem b) - { - // special items - if (PreferredItems.ContainsKey(a.ItemId) || PreferredItems.ContainsKey(b.ItemId)) + // insert the preferred items + foreach (EquipmentItem preferredItem in preferredItems) { - 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); - } + int level = PreferredItems[preferredItem.ItemId]; + int index = defaultItems.FindIndex(x => x.Level < level); + if (index >= 0) + defaultItems.Insert(index, preferredItem); + else + defaultItems.Add(preferredItem); } - // 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); + Items = defaultItems; } public void UpdateStats(Dictionary primaryStats, Configuration configuration) @@ -135,4 +73,81 @@ internal sealed class ItemList .ToList(); } } + + private sealed class EquipmentItemComparer(IReadOnlyList substatPriorities) : IComparer + { + public int Compare(EquipmentItem? a, EquipmentItem? b) + { + ArgumentNullException.ThrowIfNull(a); + ArgumentNullException.ThrowIfNull(b); + + // weapons: most damage wins + int damageA = a.Damage; + int damageB = b.Damage; + if (damageA != damageB) + return damageA.CompareTo(damageB); + + // gear: primary stat wins + // + // we pretend every gear item has at least 1 primary stat to ensure weathered items are sorted last(ish), + // where they would otherwise get sorted as better-than-shire items (while that may be correct, it's also + // stupid) + int primaryStatA = Math.Max(1, a.PrimaryStat); + int primaryStatB = Math.Max(1, 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) + { + // aetherial items aren't "special" enough to be sorted higher than normal gear + int rarityA = a.Rarity; + int rarityB = b.Rarity; + + if (rarityA == 7) + rarityA = 1; + + if (rarityB == 7) + rarityB = 1; + + return rarityA.CompareTo(rarityB); + } + + // 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); + } + } }