using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using Dalamud.Plugin.Services; using FFXIVClientStructs.FFXIV.Client.Game; using Lumina.Excel; using Lumina.Excel.Sheets; namespace LLib.Gear; public sealed class GearStatsCalculator { private static readonly uint[] CanHaveOffhand = [2, 6, 8, 12, 14, 16, 18, 20, 22, 24, 26, 28, 30, 32]; private readonly ExcelSheet _itemSheet; private readonly Dictionary<(uint ItemLevel, EBaseParam BaseParam), ushort> _itemLevelStatCaps = []; private readonly Dictionary<(EBaseParam BaseParam, int EquipSlotCategory), ushort> _equipSlotCategoryPct; private readonly Dictionary _materiaStats; public GearStatsCalculator(IDataManager? dataManager) : this(dataManager?.GetExcelSheet() ?? throw new ArgumentNullException(nameof(dataManager)), dataManager.GetExcelSheet(), dataManager.GetExcelSheet(), dataManager.GetExcelSheet()) { } public GearStatsCalculator(ExcelSheet itemLevelSheet, ExcelSheet baseParamSheet, ExcelSheet materiaSheet, ExcelSheet itemSheet) { ArgumentNullException.ThrowIfNull(itemLevelSheet); ArgumentNullException.ThrowIfNull(baseParamSheet); ArgumentNullException.ThrowIfNull(materiaSheet); ArgumentNullException.ThrowIfNull(itemSheet); _itemSheet = itemSheet; foreach (var itemLevel in itemLevelSheet) { _itemLevelStatCaps[(itemLevel.RowId, EBaseParam.Strength)] = itemLevel.Strength; _itemLevelStatCaps[(itemLevel.RowId, EBaseParam.Dexterity)] = itemLevel.Dexterity; _itemLevelStatCaps[(itemLevel.RowId, EBaseParam.Vitality)] = itemLevel.Vitality; _itemLevelStatCaps[(itemLevel.RowId, EBaseParam.Intelligence)] = itemLevel.Intelligence; _itemLevelStatCaps[(itemLevel.RowId, EBaseParam.Mind)] = itemLevel.Mind; _itemLevelStatCaps[(itemLevel.RowId, EBaseParam.Piety)] = itemLevel.Piety; _itemLevelStatCaps[(itemLevel.RowId, EBaseParam.GP)] = itemLevel.GP; _itemLevelStatCaps[(itemLevel.RowId, EBaseParam.CP)] = itemLevel.CP; _itemLevelStatCaps[(itemLevel.RowId, EBaseParam.DamagePhys)] = itemLevel.PhysicalDamage; _itemLevelStatCaps[(itemLevel.RowId, EBaseParam.DamageMag)] = itemLevel.MagicalDamage; _itemLevelStatCaps[(itemLevel.RowId, EBaseParam.DefensePhys)] = itemLevel.Defense; _itemLevelStatCaps[(itemLevel.RowId, EBaseParam.DefenseMag)] = itemLevel.MagicDefense; _itemLevelStatCaps[(itemLevel.RowId, EBaseParam.Tenacity)] = itemLevel.Tenacity; _itemLevelStatCaps[(itemLevel.RowId, EBaseParam.Crit)] = itemLevel.CriticalHit; _itemLevelStatCaps[(itemLevel.RowId, EBaseParam.DirectHit)] = itemLevel.DirectHitRate; _itemLevelStatCaps[(itemLevel.RowId, EBaseParam.Determination)] = itemLevel.Determination; _itemLevelStatCaps[(itemLevel.RowId, EBaseParam.SpellSpeed)] = itemLevel.SpellSpeed; _itemLevelStatCaps[(itemLevel.RowId, EBaseParam.SkillSpeed)] = itemLevel.SkillSpeed; _itemLevelStatCaps[(itemLevel.RowId, EBaseParam.Gathering)] = itemLevel.Gathering; _itemLevelStatCaps[(itemLevel.RowId, EBaseParam.Perception)] = itemLevel.Perception; _itemLevelStatCaps[(itemLevel.RowId, EBaseParam.Craftsmanship)] = itemLevel.Craftsmanship; _itemLevelStatCaps[(itemLevel.RowId, EBaseParam.Control)] = itemLevel.Control; } _equipSlotCategoryPct = baseParamSheet .SelectMany(x => Enumerable.Range(0, x.EquipSlotCategoryPct.Count) .Select(y => ((EBaseParam)x.RowId, y, x.EquipSlotCategoryPct[y]))) .ToDictionary(x => (x.Item1, x.Item2), x => x.Item3); _materiaStats = materiaSheet.Where(x => x.RowId > 0 && x.BaseParam.RowId > 0) .ToDictionary(x => x.RowId, x => new MateriaInfo((EBaseParam)x.BaseParam.RowId, x.Value, x.Item[0].RowId > 0)); } public unsafe EquipmentStats CalculateGearStats(InventoryItem* item) { List<(uint, byte)> materias = []; byte materiaCount = 0; for (int i = 0; i < 5; ++i) { var materia = item->Materia[i]; if (materia != 0) { materiaCount++; materias.Add((materia, item->MateriaGrades[i])); } } return CalculateGearStats(_itemSheet.GetRow(item->ItemId), item->Flags.HasFlag(InventoryItem.ItemFlags.HighQuality), materias) with { MateriaCount = materiaCount, }; } public EquipmentStats CalculateGearStats(Item item, bool highQuality, IReadOnlyList<(uint MateriaId, byte Grade)> materias) { ArgumentNullException.ThrowIfNull(materias); Dictionary result = []; for (int i = 0; i < item.BaseParam.Count; ++i) AddEquipmentStat(result, item.BaseParam[i], item.BaseParamValue[i]); if (highQuality) { for (int i = 0; i < item.BaseParamSpecial.Count; ++i) AddEquipmentStat(result, item.BaseParamSpecial[i], item.BaseParamValueSpecial[i]); } foreach (var materia in materias) { if (_materiaStats.TryGetValue(materia.MateriaId, out var materiaStat)) AddMateriaStat(item, result, materiaStat, materia.Grade); } return new EquipmentStats(result, 0); } private static void AddEquipmentStat(Dictionary result, RowRef baseParam, short value) { if (baseParam.RowId == 0) return; if (result.TryGetValue((EBaseParam)baseParam.RowId, out var statInfo)) result[(EBaseParam)baseParam.RowId] = statInfo with { EquipmentValue = (short)(statInfo.EquipmentValue + value) }; else result[(EBaseParam)baseParam.RowId] = new StatInfo(value, 0, false); } private void AddMateriaStat(Item item, Dictionary result, MateriaInfo materiaInfo, short grade) { if (!result.TryGetValue(materiaInfo.BaseParam, out var statInfo)) result[materiaInfo.BaseParam] = statInfo = new StatInfo(0, 0, false); // overcap calculation is only done if a physical materia item is melded if (materiaInfo.HasItem) { short maximumValue = (short)(GetMaximumStatValue(item, materiaInfo.BaseParam) - statInfo.EquipmentValue); if (statInfo.MateriaValue + materiaInfo.Values[grade] > maximumValue) { result[materiaInfo.BaseParam] = statInfo with { MateriaValue = maximumValue, Overcapped = true, }; } else { result[materiaInfo.BaseParam] = statInfo with { MateriaValue = (short)(statInfo.MateriaValue + materiaInfo.Values[grade]) }; } } else { result[materiaInfo.BaseParam] = statInfo with { MateriaValue = (short)(statInfo.MateriaValue + materiaInfo.Values[grade]) }; } } public short GetMaximumStatValue(Item item, EBaseParam baseParamValue) { return (short)Math.Round( _itemLevelStatCaps[(item.LevelItem.RowId, baseParamValue)] * _equipSlotCategoryPct[(baseParamValue, (int)item.EquipSlotCategory.RowId)] / 1000f, MidpointRounding.AwayFromZero); } // From caraxi/SimpleTweaks public unsafe short CalculateAverageItemLevel(InventoryContainer* container) { uint sum = 0U; var calculatedSlots = 12; for (var i = 0; i < 13; i++) { if (i == 5) // belt continue; var inventoryItem = container->GetInventorySlot(i); if (inventoryItem == null || inventoryItem->ItemId == 0) continue; var item = _itemSheet.GetRowOrDefault(inventoryItem->ItemId); if (item == null) continue; // blue mage weapon if (item.Value.ItemUICategory.RowId == 105) { if (i == 0) calculatedSlots -= 1; calculatedSlots -= 1; continue; } // count main hand weapon twice if no offhand is equippable if (i == 0 && !CanHaveOffhand.Contains(item.Value.ItemUICategory.RowId)) { sum += item.Value.LevelItem.RowId; i++; } sum += item.Value.LevelItem.RowId; } return (short)(sum / calculatedSlots); } private sealed record MateriaInfo(EBaseParam BaseParam, Collection Values, bool HasItem); } // From caraxi/SimpleTweaks [Sheet("BaseParam")] [SuppressMessage("Performance", "CA1815", Justification = "Lumina doesn't implement any equality ops")] public readonly unsafe struct ExtendedBaseParam(ExcelPage page, uint offset, uint row) : IExcelRow { private const int ParamCount = 23; public uint RowId => row; 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); }