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);
+ }
+ }
}