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.
This commit is contained in:
Liza 2024-06-07 23:28:42 +02:00
parent e64e280851
commit 63f65e9589
Signed by: liza
GPG Key ID: 7199F8D727D55F67
14 changed files with 999 additions and 373 deletions

View File

@ -990,7 +990,7 @@ csharp_space_around_binary_operators = before_and_after
csharp_using_directive_placement = outside_namespace:silent csharp_using_directive_placement = outside_namespace:silent
csharp_prefer_simple_using_statement = true:suggestion csharp_prefer_simple_using_statement = true:suggestion
csharp_prefer_braces = true:silent 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_method_group_conversion = true:silent
csharp_style_prefer_top_level_statements = true:silent csharp_style_prefer_top_level_statements = true:silent
csharp_style_prefer_primary_constructors = true:suggestion csharp_style_prefer_primary_constructors = true:suggestion

View File

@ -1,149 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Lumina.Excel.GeneratedSheets;
namespace Gearsetter;
internal sealed class CachedItem : IEquatable<CachedItem>
{
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<ClassJob> 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<BaseParam, short> _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;
}
}
}

View File

@ -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,
}

View File

@ -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<EBaseParam> StatPriorityTanks { get; set; } = new();
public List<EBaseParam> StatPriorityHealer { get; set; } = new();
public List<EBaseParam> StatPriorityMelee { get; set; } = new();
public List<EBaseParam> StatPriorityPhysicalRanged { get; set; } = new();
public List<EBaseParam> StatPriorityCaster { get; set; } = new();
public List<EBaseParam> StatPriorityCrafter { get; set; } = new();
public List<EBaseParam> 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]
};
}
}

View File

@ -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,
}
}

View File

@ -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;
}

View File

@ -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
}

View File

@ -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<uint, List<EClassJob>> _classJobCategories;
private readonly IReadOnlyList<ItemList> _allItemLists;
public GameDataHolder(IDataManager dataManager, Configuration configuration)
{
_configuration = configuration;
_classJobCategories = dataManager.GetExcelSheet<ClassJobCategory>()!
.ToDictionary(x => x.RowId, x =>
new Dictionary<EClassJob, bool>
{
{ 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<ClassJob>()!
.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<ClassJob>()!
.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<ItemUICategory>()!
.Where(x => x.RowId > 0)
.OrderBy(x => x.OrderMajor)
.ThenBy(x => x.OrderMinor)
.Select(x => (x.RowId, x.Name.ToString()))
.ToList();
_allItemLists =
dataManager.GetExcelSheet<Item>()!
.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<EClassJob, EBaseParam> PrimaryStats { get; }
public Dictionary<EBaseParam, string> 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<ItemList> 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<EClassJob> 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);
}
}

View File

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework> <TargetFramework>net8.0-windows</TargetFramework>
<Version>0.3</Version> <Version>0.4</Version>
<LangVersion>12</LangVersion> <LangVersion>12</LangVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies> <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>

View File

@ -6,11 +6,15 @@ using System.Text;
using Dalamud.Game.Command; using Dalamud.Game.Command;
using Dalamud.Game.Text.SeStringHandling; using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Game.Text.SeStringHandling.Payloads; using Dalamud.Game.Text.SeStringHandling.Payloads;
using Dalamud.Interface.Windowing;
using Dalamud.Plugin; using Dalamud.Plugin;
using Dalamud.Plugin.Services; using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.Game; using FFXIVClientStructs.FFXIV.Client.Game;
using FFXIVClientStructs.FFXIV.Client.Game.UI; using FFXIVClientStructs.FFXIV.Client.Game.UI;
using FFXIVClientStructs.FFXIV.Client.UI.Misc; using FFXIVClientStructs.FFXIV.Client.UI.Misc;
using Gearsetter.GameData;
using Gearsetter.Model;
using Gearsetter.Windows;
using Lumina.Excel.GeneratedSheets; using Lumina.Excel.GeneratedSheets;
namespace Gearsetter; namespace Gearsetter;
@ -18,37 +22,19 @@ namespace Gearsetter;
[SuppressMessage("ReSharper", "UnusedType.Global")] [SuppressMessage("ReSharper", "UnusedType.Global")]
public sealed class GearsetterPlugin : IDalamudPlugin public sealed class GearsetterPlugin : IDalamudPlugin
{ {
private static readonly InventoryType[] DefaultInventoryTypes = private readonly WindowSystem _windowSystem = new(nameof(GearsetterPlugin));
[
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 DalamudPluginInterface _pluginInterface; private readonly DalamudPluginInterface _pluginInterface;
private readonly ICommandManager _commandManager; private readonly ICommandManager _commandManager;
private readonly IChatGui _chatGui; private readonly IChatGui _chatGui;
private readonly IDataManager _dataManager; private readonly IDataManager _dataManager;
private readonly IPluginLog _pluginLog; private readonly IPluginLog _pluginLog;
private readonly IClientState _clientState; private readonly IClientState _clientState;
private readonly Configuration _configuration;
private readonly GameDataHolder _gameDataHolder;
private readonly EquipmentBrowserWindow _equipmentBrowserWindow;
private readonly IReadOnlyDictionary<byte, DalamudLinkPayload> _linkPayloads; private readonly IReadOnlyDictionary<byte, DalamudLinkPayload> _linkPayloads;
private readonly Dictionary<uint, List<ClassJob>> _classJobCategories; private readonly Dictionary<EClassJob, byte> _classJobToArrayIndex;
private readonly Dictionary<byte, byte> _classJobToArrayIndex;
private readonly Dictionary<uint, CachedItem> _cachedItems = new();
public GearsetterPlugin(DalamudPluginInterface pluginInterface, ICommandManager commandManager, IChatGui chatGui, public GearsetterPlugin(DalamudPluginInterface pluginInterface, ICommandManager commandManager, IChatGui chatGui,
IDataManager dataManager, IPluginLog pluginLog, IClientState clientState) IDataManager dataManager, IPluginLog pluginLog, IClientState clientState)
@ -62,63 +48,34 @@ public sealed class GearsetterPlugin : IDalamudPlugin
_pluginLog = pluginLog; _pluginLog = pluginLog;
_clientState = clientState; _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) _linkPayloads = Enumerable.Range(0, 100)
.ToDictionary(x => (byte)x, x => _pluginInterface.AddChatLinkHandler((byte)x, ChangeGearset)).AsReadOnly(); .ToDictionary(x => (byte)x, x => _pluginInterface.AddChatLinkHandler((byte)x, ChangeGearset)).AsReadOnly();
_clientState.TerritoryChanged += TerritoryChanged; _clientState.TerritoryChanged += TerritoryChanged;
_pluginInterface.UiBuilder.Draw += _windowSystem.Draw;
_classJobToArrayIndex = dataManager.GetExcelSheet<Lumina.Excel.GeneratedSheets.ClassJob>()! _classJobToArrayIndex = dataManager.GetExcelSheet<ClassJob>()!
.Where(x => x.RowId > 0) .Where(x => x.RowId > 0 && Enum.IsDefined(typeof(EClassJob), x.RowId))
.ToDictionary(x => (byte)x.RowId, x => (byte)x.ExpArrayIndex); .ToDictionary(x => (EClassJob)x.RowId, x => (byte)x.ExpArrayIndex);
_classJobCategories = _dataManager.GetExcelSheet<ClassJobCategory>()!
.ToDictionary(x => x.RowId, x =>
new Dictionary<ClassJob, bool>
{
{ 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());
} }
private void TerritoryChanged(ushort territory) private void TerritoryChanged(ushort territory)
@ -127,26 +84,15 @@ public sealed class GearsetterPlugin : IDalamudPlugin
ShowUpgrades(); 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() private unsafe void ShowUpgrades()
{ {
var inventoryManager = InventoryManager.Instance(); var inventoryItems = GetAllInventoryItems();
List<CachedItem> 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 gearsetModule = RaptureGearsetModule.Instance(); var gearsetModule = RaptureGearsetModule.Instance();
if (gearsetModule == null) if (gearsetModule == null)
@ -166,7 +112,8 @@ public sealed class GearsetterPlugin : IDalamudPlugin
_chatGui.Print("All your gearsets are OK."); _chatGui.Print("All your gearsets are OK.");
} }
private unsafe bool HandleGearset(RaptureGearsetModule.GearsetEntry* gearset, List<CachedItem> inventoryItems) private unsafe bool HandleGearset(RaptureGearsetModule.GearsetEntry* gearset,
Dictionary<(uint ItemId, bool Hq), int> inventoryItems)
{ {
string name = GetGearsetName(gearset); string name = GetGearsetName(gearset);
if (name.Contains('_', StringComparison.Ordinal) || if (name.Contains('_', StringComparison.Ordinal) ||
@ -176,19 +123,20 @@ public sealed class GearsetterPlugin : IDalamudPlugin
List<List<SeString>> upgrades = new() List<List<SeString>> upgrades = new()
{ {
HandleGearsetItem("Main Hand", gearset, gearset->MainHand, inventoryItems), HandleGearsetItem("Main Hand", gearset, [gearset->ItemsSpan[0]], inventoryItems, EEquipSlotCategory.None),
HandleGearsetItem("Off Hand", gearset, gearset->OffHand, inventoryItems), HandleOffHand(gearset, inventoryItems),
HandleGearsetItem("Head", gearset, gearset->Head, inventoryItems), HandleGearsetItem("Head", gearset, [gearset->ItemsSpan[2]], inventoryItems, EEquipSlotCategory.Head),
HandleGearsetItem("Body", gearset, gearset->Body, inventoryItems), HandleGearsetItem("Body", gearset, [gearset->ItemsSpan[3]], inventoryItems, EEquipSlotCategory.Body),
HandleGearsetItem("Hands", gearset, gearset->Hands, inventoryItems), HandleGearsetItem("Hands", gearset, [gearset->ItemsSpan[4]], inventoryItems, EEquipSlotCategory.Hands),
HandleGearsetItem("Legs", gearset, gearset->Legs, inventoryItems), HandleGearsetItem("Legs", gearset, [gearset->ItemsSpan[6]], inventoryItems, EEquipSlotCategory.Legs),
HandleGearsetItem("Feet", gearset, gearset->Feet, inventoryItems), HandleGearsetItem("Feet", gearset, [gearset->ItemsSpan[7]], inventoryItems, EEquipSlotCategory.Feet),
HandleGearsetItem("Ears", gearset, gearset->Ears, inventoryItems), HandleGearsetItem("Ears", gearset, [gearset->ItemsSpan[8]], inventoryItems, EEquipSlotCategory.Ears),
HandleGearsetItem("Neck", gearset, gearset->Neck, inventoryItems), HandleGearsetItem("Neck", gearset, [gearset->ItemsSpan[9]], inventoryItems, EEquipSlotCategory.Neck),
HandleGearsetItem("Wrists", gearset, gearset->Wrists, inventoryItems), HandleGearsetItem("Wrists", gearset, [gearset->ItemsSpan[10]], inventoryItems, EEquipSlotCategory.Wrists),
HandleGearsetItem("Rings", gearset, new[] { gearset->RingRight, gearset->RingLeft }, inventoryItems), HandleGearsetItem("Rings", gearset, [gearset->ItemsSpan[11], gearset->ItemsSpan[12]], inventoryItems,
EEquipSlotCategory.Rings),
}; };
List<SeString> flatUpgrades = upgrades.SelectMany(x => x).ToList(); List<SeString> 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]; => Encoding.UTF8.GetString(gearset->Name, 0x2F).Split((char)0)[0];
private unsafe List<SeString> HandleGearsetItem(string label, RaptureGearsetModule.GearsetEntry* gearset, private unsafe List<SeString> HandleGearsetItem(string label, RaptureGearsetModule.GearsetEntry* gearset,
RaptureGearsetModule.GearsetItem gearsetItem, List<CachedItem> inventoryItems) RaptureGearsetModule.GearsetItem[] gearsetItem, Dictionary<(uint ItemId, bool Hq), int> inventoryItems,
=> HandleGearsetItem(label, gearset, new[] { gearsetItem }, inventoryItems); EEquipSlotCategory equipSlotCategory)
private unsafe List<SeString> HandleGearsetItem(string label, RaptureGearsetModule.GearsetEntry* gearset,
RaptureGearsetModule.GearsetItem[] gearsetItem, List<CachedItem> inventoryItems)
{ {
gearsetItem = gearsetItem.Where(x => x.ItemID != 0).ToArray(); EClassJob classJob = (EClassJob)gearset->ClassJob;
if (gearsetItem.Length > 0) var itemLists = _gameDataHolder.GetItemLists(classJob);
if (gearsetItem.Any(x => x.ItemID > 0))
{ {
ClassJob classJob = (ClassJob)gearset->ClassJob; var firstEquippedItem = gearsetItem.First(x => x.ItemID > 0);
CachedItem[] currentItems = gearsetItem.Select(x => LookupItem(x.ItemID)).Where(x => x != null) equipSlotCategory = (EEquipSlotCategory)(_dataManager.GetExcelSheet<Item>()!
.Select(x => x!).ToArray(); .GetRow(firstEquippedItem.ItemID % 1_000_000)
if (currentItems.Length == 0) ?.EquipSlotCategory?.Row ?? 0);
{
_pluginLog.Information($"Unable to find gearset items");
return new List<SeString>();
}
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();
} }
return new List<SeString>(); if (equipSlotCategory == EEquipSlotCategory.None)
{
_pluginLog.Warning($"Unable to find item to determine equip slot category");
return new List<SeString>();
}
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<SeString>();
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<SeString> HandleOffHand(RaptureGearsetModule.GearsetEntry* gearset,
Dictionary<(uint ItemId, bool Hq), int> inventoryItems)
{ {
if (_cachedItems.TryGetValue(itemId, out CachedItem? cachedItem)) var mainHand = gearset->ItemsSpan[0];
return cachedItem; if (mainHand.ItemID == 0)
return new List<SeString>();
try // if it's a twohanded weapon, ignore it
{ EEquipSlotCategory equipSlotCategory =
var item = _dataManager.GetExcelSheet<Item>()!.GetRow(itemId % 1_000_000)!; (EEquipSlotCategory)(_dataManager.GetExcelSheet<Item>()!.GetRow(mainHand.ItemID % 1_000_000)?.RowId ?? 0);
cachedItem = new CachedItem if (equipSlotCategory != EEquipSlotCategory.OneHandedMainHand)
{ return new List<SeString>();
Item = item,
ItemId = item.RowId, return HandleGearsetItem("Off Hand", gearset, [gearset->ItemsSpan[1]], inventoryItems,
Hq = itemId > 1_000_000, EEquipSlotCategory.Shield);
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;
}
} }
private CachedItem? LookupItem(uint itemId, bool hq)
=> LookupItem(itemId + (hq ? 1_000_000u : 0));
private unsafe void ChangeGearset(uint commandId, SeString seString) private unsafe void ChangeGearset(uint commandId, SeString seString)
=> RaptureGearsetModule.Instance()->EquipGearset((byte)commandId); => 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() public void Dispose()
{ {
_pluginInterface.UiBuilder.Draw -= _windowSystem.Draw;
_clientState.TerritoryChanged -= TerritoryChanged; _clientState.TerritoryChanged -= TerritoryChanged;
_pluginInterface.RemoveChatLinkHandler(); _pluginInterface.RemoveChatLinkHandler();
_commandManager.RemoveHandler("/gbrowser");
_commandManager.RemoveHandler("/gup"); _commandManager.RemoveHandler("/gup");
} }
} }

View File

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

View File

@ -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<EBaseParam, short> _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;
}
}
}

View File

@ -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<uint, byte> PreferredItems = new Dictionary<uint, byte>()
{
{ 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<EquipmentItem> Items { get; set; }
public IReadOnlyList<EBaseParam> SubstatPriorities { get; set; } = new List<EBaseParam>();
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<EClassJob, EBaseParam> 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();
}
}
}

View File

@ -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;
}
}