diff --git a/Gearsetter.Test/ItemSortingTest.cs b/Gearsetter.Test/ItemSortingTest.cs index 2676c13..28727cc 100644 --- a/Gearsetter.Test/ItemSortingTest.cs +++ b/Gearsetter.Test/ItemSortingTest.cs @@ -45,7 +45,10 @@ public sealed class ItemSortingTest .Where(x => x.PrimaryStat > 0) .ToDictionary(x => (EClassJob)x.RowId, x => (EBaseParam)x.PrimaryStat); - itemList.UpdateStats(primaryStats, new Configuration()); + var itemLevelCaps = new ItemLevelCaps(_lumina.GetExcelSheet()!, + _lumina.GetExcelSheet()!); + + itemList.UpdateStats(primaryStats, new Configuration(), itemLevelCaps); itemList.Sort(); List expectedItems = diff --git a/Gearsetter/GameData/GameDataHolder.cs b/Gearsetter/GameData/GameDataHolder.cs index 513fce3..d4b0ad9 100644 --- a/Gearsetter/GameData/GameDataHolder.cs +++ b/Gearsetter/GameData/GameDataHolder.cs @@ -14,12 +14,14 @@ namespace Gearsetter.GameData; internal sealed class GameDataHolder { private readonly Configuration _configuration; + private readonly ItemLevelCaps _itemLevelCaps; private readonly Dictionary> _classJobCategories; private readonly IReadOnlyList _allItemLists; public GameDataHolder(IDataManager dataManager, Configuration configuration) { _configuration = configuration; + _itemLevelCaps = new ItemLevelCaps(dataManager); _classJobCategories = dataManager.GetExcelSheet() .ToDictionary(x => x.RowId, x => new Dictionary @@ -179,7 +181,7 @@ internal sealed class GameDataHolder { foreach (ItemList itemList in _allItemLists) { - itemList.UpdateStats(PrimaryStats, _configuration); + itemList.UpdateStats(PrimaryStats, _configuration, _itemLevelCaps); itemList.Sort(); } } diff --git a/Gearsetter/GameData/ItemLevelCaps.cs b/Gearsetter/GameData/ItemLevelCaps.cs new file mode 100644 index 0000000..0ef087c --- /dev/null +++ b/Gearsetter/GameData/ItemLevelCaps.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using Dalamud.Plugin.Services; +using Lumina.Excel; +using Lumina.Excel.Sheets; + +namespace Gearsetter.GameData; + +internal sealed class ItemLevelCaps +{ + private readonly ExcelSheet _baseParamSheet; + private readonly Dictionary<(uint, EBaseParam), ushort> _caps = []; + + public ItemLevelCaps(IDataManager dataManager) + : this(dataManager.GetExcelSheet(), dataManager.GetExcelSheet()) + { + } + + public ItemLevelCaps(ExcelSheet itemLevelSheet, ExcelSheet baseParamSheet) + { + _baseParamSheet = baseParamSheet; + foreach (var itemLevel in itemLevelSheet) + { + _caps[(itemLevel.RowId, EBaseParam.Strength)] = itemLevel.Strength; + _caps[(itemLevel.RowId, EBaseParam.Dexterity)] = itemLevel.Dexterity; + _caps[(itemLevel.RowId, EBaseParam.Vitality)] = itemLevel.Vitality; + _caps[(itemLevel.RowId, EBaseParam.Intelligence)] = itemLevel.Intelligence; + _caps[(itemLevel.RowId, EBaseParam.Mind)] = itemLevel.Mind; + _caps[(itemLevel.RowId, EBaseParam.Piety)] = itemLevel.Piety; + + _caps[(itemLevel.RowId, EBaseParam.GP)] = itemLevel.GP; + _caps[(itemLevel.RowId, EBaseParam.CP)] = itemLevel.CP; + + _caps[(itemLevel.RowId, EBaseParam.DamagePhys)] = itemLevel.PhysicalDamage; + _caps[(itemLevel.RowId, EBaseParam.DamageMag)] = itemLevel.MagicalDamage; + + _caps[(itemLevel.RowId, EBaseParam.DefensePhys)] = itemLevel.Defense; + _caps[(itemLevel.RowId, EBaseParam.DefenseMag)] = itemLevel.MagicDefense; + + _caps[(itemLevel.RowId, EBaseParam.Tenacity)] = itemLevel.Tenacity; + _caps[(itemLevel.RowId, EBaseParam.Crit)] = itemLevel.CriticalHit; + _caps[(itemLevel.RowId, EBaseParam.DirectHit)] = itemLevel.DirectHitRate; + _caps[(itemLevel.RowId, EBaseParam.Determination)] = itemLevel.Determination; + _caps[(itemLevel.RowId, EBaseParam.SpellSpeed)] = itemLevel.SpellSpeed; + _caps[(itemLevel.RowId, EBaseParam.SkillSpeed)] = itemLevel.SkillSpeed; + + _caps[(itemLevel.RowId, EBaseParam.Gathering)] = itemLevel.Gathering; + _caps[(itemLevel.RowId, EBaseParam.Perception)] = itemLevel.Perception; + _caps[(itemLevel.RowId, EBaseParam.Craftsmanship)] = itemLevel.Craftsmanship; + _caps[(itemLevel.RowId, EBaseParam.Control)] = itemLevel.Control; + } + } + + public short GetMaximum(Item item, EBaseParam baseParamValue) + { + var baseParam = _baseParamSheet.GetRow((uint)baseParamValue); + return (short)Math.Round( + _caps[(item.LevelItem.RowId, baseParamValue)] * + (baseParam.EquipSlotCategoryPct[(int)item.EquipSlotCategory.RowId] / 1000f), MidpointRounding.AwayFromZero); + } + + // From caraxi/SimpleTWeaks + [Sheet("BaseParam")] + public readonly unsafe struct ExtendedBaseParam(ExcelPage page, uint offset, uint row) + : IExcelRow + { + private const int ParamCount = 23; + + public BaseParam BaseParam => new(page, offset, row); + + public Collection EquipSlotCategoryPct => + new(page, offset, offset, &EquipSlotCategoryPctCtor, ParamCount); + + private static ushort EquipSlotCategoryPctCtor(ExcelPage page, uint parentOffset, uint offset, uint i) => + i == 0 ? (ushort)0 : page.ReadUInt16(offset + 8 + (i - 1) * 2); + + public static ExtendedBaseParam Create(ExcelPage page, uint offset, uint row) => new(page, offset, row); + public uint RowId => row; + } +} diff --git a/Gearsetter/Gearsetter.csproj b/Gearsetter/Gearsetter.csproj index 1eb8ebb..f5ca3de 100644 --- a/Gearsetter/Gearsetter.csproj +++ b/Gearsetter/Gearsetter.csproj @@ -1,6 +1,6 @@ - 2.0 + 2.1 dist diff --git a/Gearsetter/Model/BaseItem.cs b/Gearsetter/Model/BaseItem.cs index 12ad9cb..bc277f3 100644 --- a/Gearsetter/Model/BaseItem.cs +++ b/Gearsetter/Model/BaseItem.cs @@ -1,4 +1,5 @@ -using Gearsetter.GameData; +using System.Linq; +using Gearsetter.GameData; using LLib.GameData; using Lumina.Excel.Sheets; @@ -26,24 +27,16 @@ internal abstract record BaseItem(Item Item, bool Hq, MateriaStats? MateriaStats get { if (ClassJob.DealsMagicDamage()) - return Item.DamageMag + Stats.Get(EBaseParam.DamageMag); + return Item.DamageMag + Stats.Get(EBaseParam.DamageMag, null); else if (ClassJob.DealsPhysicalDamage()) - return Item.DamagePhys + Stats.Get(EBaseParam.DamagePhys); + return Item.DamagePhys + Stats.Get(EBaseParam.DamagePhys, null); else return 0; } } public bool HasAnyStat(params EBaseParam[] substats) - { - foreach (EBaseParam substat in substats) - { - if (Stats.Get(substat) > 0) - return true; - } - - return false; - } + => substats.Any(x => Stats.Has(x)); public bool IsCombatRelicWithoutSubstats() { diff --git a/Gearsetter/Model/EquipmentItem.cs b/Gearsetter/Model/EquipmentItem.cs index 20ce940..e144b83 100644 --- a/Gearsetter/Model/EquipmentItem.cs +++ b/Gearsetter/Model/EquipmentItem.cs @@ -1,5 +1,4 @@ -using Gearsetter.GameData; -using LLib.GameData; +using LLib.GameData; using Lumina.Excel.Sheets; namespace Gearsetter.Model; diff --git a/Gearsetter/Model/EquipmentStats.cs b/Gearsetter/Model/EquipmentStats.cs index 353ac68..2f3d5d7 100644 --- a/Gearsetter/Model/EquipmentStats.cs +++ b/Gearsetter/Model/EquipmentStats.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using Gearsetter.GameData; using Lumina.Excel.Sheets; @@ -7,11 +8,13 @@ namespace Gearsetter.Model; internal sealed class EquipmentStats { + private readonly Item _item; private readonly Dictionary _equipmentValues; private readonly Dictionary _materiaValues; public EquipmentStats(Item item, bool hq, MateriaStats? materiaStats) { + _item = item; _equipmentValues = Enumerable.Range(0, item.BaseParam.Count) .Where(i => item.BaseParam[i].RowId > 0) .ToDictionary(i => (EBaseParam)item.BaseParam[i].RowId, i => item.BaseParamValue[i]); @@ -44,9 +47,9 @@ internal sealed class EquipmentStats } } - public short Get(EBaseParam param) + public short Get(EBaseParam param, ItemLevelCaps? itemLevelCaps) { - return (short)(GetEquipment(param) + GetMateria(param)); + return (short)(GetEquipment(param) + GetMateria(param, itemLevelCaps)); } public short GetEquipment(EBaseParam param) @@ -55,9 +58,26 @@ internal sealed class EquipmentStats return v; } - public short GetMateria(EBaseParam param) + public short GetMateria(EBaseParam param, ItemLevelCaps? itemLevelCaps) { _materiaValues.TryGetValue(param, out short v); + if (v == 0) + return v; + + if (param != EBaseParam.DamagePhys && param != EBaseParam.DamageMag) + { + // This isn't necessary accurate for Eureka relics, which can (in theory) have +1000 critical hit, but + // they're both outdated and the limits aren't relevant for a decision of 'which gear piece is better'; + // worst case it'll suggest the i405 savage weapon instead. + ArgumentNullException.ThrowIfNull(itemLevelCaps); + short max = itemLevelCaps.GetMaximum(_item, param); + short equipped = _equipmentValues.GetValueOrDefault(param); + if (v + equipped > max) + return (short)(max - equipped); + } + return v; } + + public bool Has(EBaseParam substat) => _equipmentValues.ContainsKey(substat) || _materiaValues.ContainsKey(substat); } diff --git a/Gearsetter/Model/ItemList.cs b/Gearsetter/Model/ItemList.cs index 89152d5..788767b 100644 --- a/Gearsetter/Model/ItemList.cs +++ b/Gearsetter/Model/ItemList.cs @@ -22,16 +22,18 @@ internal sealed class ItemList public required uint ItemUiCategory { get; init; } public required List Items { get; set; } public EBaseParam PrimaryStat { get; set; } - public IReadOnlyList SubstatPriorities { get; set; } = new List(); + public IReadOnlyList SubstatPriorities { get; private set; } = new List(); + public ItemLevelCaps ItemLevelCaps { get; private set; } = null!; public void Sort() { Items = Items - .OrderDescending(new ItemComparer(SubstatPriorities)) + .OrderDescending(new ItemComparer(SubstatPriorities, ItemLevelCaps)) .ToList(); } - public void UpdateStats(Dictionary primaryStats, Configuration configuration) + public void UpdateStats(Dictionary primaryStats, Configuration configuration, + ItemLevelCaps itemLevelCaps) { if (ClassJob.IsTank()) SubstatPriorities = configuration.StatPriorityTanks; @@ -50,13 +52,15 @@ internal sealed class ItemList else SubstatPriorities = []; + ItemLevelCaps = itemLevelCaps; + if (primaryStats.TryGetValue(ClassJob, out EBaseParam primaryStat)) { PrimaryStat = primaryStat; Items = Items .Where(x => x is EquipmentItem) .Cast() - .Select(x => x with { PrimaryStat = x.Stats.Get(primaryStat) }) + .Select(x => x with { PrimaryStat = x.Stats.Get(primaryStat, itemLevelCaps) }) .Cast() .ToList(); } @@ -78,7 +82,7 @@ internal sealed class ItemList Items.Add( new InventoryItem(basicItem.Item, basicItem.Hq, materias, basicItem.ClassJob) { - PrimaryStat = basicItem.Stats.Get(PrimaryStat) + PrimaryStat = basicItem.Stats.Get(PrimaryStat, ItemLevelCaps) }); } } @@ -91,7 +95,10 @@ internal sealed class ItemList Items.RemoveAll(x => x is InventoryItem); } - private sealed class ItemComparer(IReadOnlyList substatPriorities) : IComparer + private sealed class ItemComparer( + IReadOnlyList substatPriorities, + ItemLevelCaps itemLevelCaps + ) : IComparer { public int Compare(BaseItem? a, BaseItem? b) { @@ -120,14 +127,14 @@ internal sealed class ItemList return primaryStatA.CompareTo(primaryStatB); // gear: vitality wins - int vitalityA = a.Stats.Get(EBaseParam.Vitality); - int vitalityB = b.Stats.Get(EBaseParam.Vitality); + int vitalityA = a.Stats.Get(EBaseParam.Vitality, itemLevelCaps); + int vitalityB = b.Stats.Get(EBaseParam.Vitality, itemLevelCaps); 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)); + int sumOfSubstatsA = substatPriorities.Sum(x => a.Stats.Get(x, itemLevelCaps)); + int sumOfSubstatsB = substatPriorities.Sum(x => b.Stats.Get(x, itemLevelCaps)); // 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 @@ -162,8 +169,8 @@ internal sealed class ItemList // individual substats foreach (EBaseParam substat in substatPriorities) { - int substatA = a.Stats.Get(substat); - int substatB = b.Stats.Get(substat); + int substatA = a.Stats.Get(substat, itemLevelCaps); + int substatB = b.Stats.Get(substat, itemLevelCaps); if (substatA != substatB) return substatA.CompareTo(substatB); } @@ -172,7 +179,7 @@ internal sealed class ItemList return string.CompareOrdinal(a.Name, b.Name); } - public static bool TryGetPreferredItemPriority(BaseItem self, BaseItem other, out byte priority) + private static bool TryGetPreferredItemPriority(BaseItem self, BaseItem other, out byte priority) { if (PreferredItems.TryGetValue(self.ItemId, out byte levelSelf)) { diff --git a/Gearsetter/Windows/EquipmentBrowserWindow.cs b/Gearsetter/Windows/EquipmentBrowserWindow.cs index 1e80dd5..85c1873 100644 --- a/Gearsetter/Windows/EquipmentBrowserWindow.cs +++ b/Gearsetter/Windows/EquipmentBrowserWindow.cs @@ -215,7 +215,7 @@ internal sealed class EquipmentBrowserWindow : LWindow if (ImGui.TableNextColumn()) { var estat = item.Stats.GetEquipment(substat); - var mstat = item.Stats.GetMateria(substat); + var mstat = item.Stats.GetMateria(substat, itemList.ItemLevelCaps); if (estat == 0 && mstat == 0) ImGui.Text("-"); else if (mstat == 0)