commit dedfd5412cb17efad334e440a20253d550620953 Author: Liza Carvelli Date: Tue Jan 16 06:42:23 2024 +0100 Initial Commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..05dc549 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/.idea +*.user diff --git a/Gearsetter.sln b/Gearsetter.sln new file mode 100644 index 0000000..763ec25 --- /dev/null +++ b/Gearsetter.sln @@ -0,0 +1,16 @@ + +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 +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {3E87693D-1FEE-486D-80E9-C6D95E68160F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {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 + EndGlobalSection +EndGlobal diff --git a/Gearsetter/.gitignore b/Gearsetter/.gitignore new file mode 100644 index 0000000..958518b --- /dev/null +++ b/Gearsetter/.gitignore @@ -0,0 +1,3 @@ +/dist +/obj +/bin diff --git a/Gearsetter/CachedItem.cs b/Gearsetter/CachedItem.cs new file mode 100644 index 0000000..5b5cfcd --- /dev/null +++ b/Gearsetter/CachedItem.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Lumina.Excel.GeneratedSheets; + +namespace Gearsetter; + +internal sealed class CachedItem : IEquatable +{ + 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 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 == 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 = 24, + + Craftsmanship = 70, + Control = 71, + Gathering = 72, + Perception = 73, + } + + private sealed class Stats + { + private readonly Dictionary _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; + } + } +} diff --git a/Gearsetter/ClassJob.cs b/Gearsetter/ClassJob.cs new file mode 100644 index 0000000..5f23038 --- /dev/null +++ b/Gearsetter/ClassJob.cs @@ -0,0 +1,46 @@ +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, +} diff --git a/Gearsetter/DalamudPackager.targets b/Gearsetter/DalamudPackager.targets new file mode 100644 index 0000000..97ade9a --- /dev/null +++ b/Gearsetter/DalamudPackager.targets @@ -0,0 +1,21 @@ + + + + + + + + + + diff --git a/Gearsetter/Gearsetter.csproj b/Gearsetter/Gearsetter.csproj new file mode 100644 index 0000000..d0ca416 --- /dev/null +++ b/Gearsetter/Gearsetter.csproj @@ -0,0 +1,60 @@ + + + net7.0-windows + 0.1 + 11.0 + enable + true + false + false + dist + true + portable + $(SolutionDir)=X:\ + true + portable + + + + $(appdata)\XIVLauncher\addon\Hooks\dev\ + + + + $(DALAMUD_HOME)/ + + + + + + + + + $(DalamudLibPath)Dalamud.dll + false + + + $(DalamudLibPath)ImGui.NET.dll + false + + + $(DalamudLibPath)Lumina.dll + false + + + $(DalamudLibPath)Lumina.Excel.dll + false + + + $(DalamudLibPath)Newtonsoft.Json.dll + false + + + $(DalamudLibPath)FFXIVClientStructs.dll + false + + + + + + + diff --git a/Gearsetter/Gearsetter.json b/Gearsetter/Gearsetter.json new file mode 100644 index 0000000..b0af1bd --- /dev/null +++ b/Gearsetter/Gearsetter.json @@ -0,0 +1,8 @@ +{ + "Name": "Gearsetter", + "Author": "Liza Carvelli", + "Punchline": "Find gear upgrades", + "Description": "", + "RepoUrl": "https://git.carvel.li/liza/Gearsetter", + "IconUrl": "https://plugins.carvel.li/icons/Gearsetter.png" +} diff --git a/Gearsetter/GearsetterPlugin.cs b/Gearsetter/GearsetterPlugin.cs new file mode 100644 index 0000000..39c978f --- /dev/null +++ b/Gearsetter/GearsetterPlugin.cs @@ -0,0 +1,309 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text; +using Dalamud.Game.Command; +using Dalamud.Game.Text.SeStringHandling; +using Dalamud.Game.Text.SeStringHandling.Payloads; +using Dalamud.Plugin; +using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Client.Game; +using FFXIVClientStructs.FFXIV.Client.Game.UI; +using FFXIVClientStructs.FFXIV.Client.UI.Misc; +using Lumina.Excel.GeneratedSheets; + +namespace Gearsetter; + +[SuppressMessage("ReSharper", "UnusedType.Global")] +public class GearsetterPlugin : IDalamudPlugin +{ + private static readonly InventoryType[] DefaultInventoryTypes = + { + 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 ICommandManager _commandManager; + private readonly IChatGui _chatGui; + private readonly IDataManager _dataManager; + private readonly IPluginLog _pluginLog; + private readonly IClientState _clientState; + + private readonly IReadOnlyDictionary _linkPayloads; + private readonly Dictionary> _classJobCategories; + private readonly Dictionary _classJobToArrayIndex; + private readonly Dictionary _cachedItems = new(); + + public GearsetterPlugin(DalamudPluginInterface pluginInterface, ICommandManager commandManager, IChatGui chatGui, + IDataManager dataManager, IPluginLog pluginLog, IClientState clientState) + { + _pluginInterface = pluginInterface; + _commandManager = commandManager; + _chatGui = chatGui; + _dataManager = dataManager; + _pluginLog = pluginLog; + _clientState = clientState; + + _commandManager.AddHandler("/gup", new CommandInfo(ProcessCommand)); + _linkPayloads = Enumerable.Range(0, 100) + .ToDictionary(x => (byte)x, x => _pluginInterface.AddChatLinkHandler((byte)x, ChangeGearset)).AsReadOnly(); + _clientState.TerritoryChanged += TerritoryChanged; + + _classJobToArrayIndex = dataManager.GetExcelSheet()! + .Where(x => x.RowId > 0) + .ToDictionary(x => (byte)x.RowId, x => (byte)x.ExpArrayIndex); + _classJobCategories = _dataManager.GetExcelSheet()! + .ToDictionary(x => x.RowId, x => + new Dictionary + { + { 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) + { + if (territory == 128) + ShowUpgrades(); + } + + private void ProcessCommand(string command, string arguments) => ShowUpgrades(); + + private unsafe void ShowUpgrades() + { + var inventoryManager = InventoryManager.Instance(); + List 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(); + if (gearsetModule == null) + return; + + bool anyUpgrade = false; + for (int i = 0; i < 100; ++i) + { + var gearset = gearsetModule->GetGearset(i); + if (gearset != null && gearset->Flags.HasFlag(RaptureGearsetModule.GearsetFlag.Exists)) + { + anyUpgrade |= HandleGearset(gearset, inventoryItems); + } + } + + if (!anyUpgrade) + _chatGui.Print("All your gearsets are OK."); + } + + private unsafe bool HandleGearset(RaptureGearsetModule.GearsetEntry* gearset, List inventoryItems) + { + string name = GetGearsetName(gearset); + if (name.Contains('_') || name.Contains("Eureka") || name.Contains("Bozja")) + return false; + + List> upgrades = new() + { + HandleGearsetItem("Main Hand", gearset, gearset->MainHand, inventoryItems), + HandleGearsetItem("Off Hand", gearset, gearset->OffHand, inventoryItems), + + HandleGearsetItem("Head", gearset, gearset->Head, inventoryItems), + HandleGearsetItem("Body", gearset, gearset->Body, inventoryItems), + HandleGearsetItem("Hands", gearset, gearset->Hands, inventoryItems), + HandleGearsetItem("Legs", gearset, gearset->Legs, inventoryItems), + HandleGearsetItem("Feet", gearset, gearset->Feet, inventoryItems), + + HandleGearsetItem("Ears", gearset, gearset->Ears, inventoryItems), + HandleGearsetItem("Neck", gearset, gearset->Neck, inventoryItems), + HandleGearsetItem("Wrists", gearset, gearset->Wrists, inventoryItems), + HandleGearsetItem("Rings", gearset, new[] { gearset->RingRight, gearset->RingLeft }, inventoryItems), + }; + + List flatUpgrades = upgrades.SelectMany(x => x).ToList(); + if (flatUpgrades.Count == 0) + return false; + + _chatGui.Print( + new SeStringBuilder() + .Append("Gearset ") + .AddUiForeground(1) + .Add(_linkPayloads[gearset->ID]) + .Append($"#{gearset->ID + 1}: ") + .Append(name) + .Add(RawPayload.LinkTerminator) + .AddUiForegroundOff() + .Build()); + + foreach (var upgrade in flatUpgrades) + _chatGui.Print(new SeString(new TextPayload(" - ")).Append(upgrade)); + + return true; + } + + /// + /// This probably includes the ilvl; at the very minimum attempting to print this directly to chat will act as if + /// the string ends after the name (and not render ANY text on the same line after the name). + /// + private unsafe string GetGearsetName(RaptureGearsetModule.GearsetEntry* gearset) + => Encoding.UTF8.GetString(gearset->Name, 0x2F).Split((char)0)[0]; + + private unsafe List HandleGearsetItem(string label, RaptureGearsetModule.GearsetEntry* gearset, + RaptureGearsetModule.GearsetItem gearsetItem, List inventoryItems) + => HandleGearsetItem(label, gearset, new[] { gearsetItem }, inventoryItems); + + private unsafe List HandleGearsetItem(string label, RaptureGearsetModule.GearsetEntry* gearset, + RaptureGearsetModule.GearsetItem[] gearsetItem, List inventoryItems) + { + gearsetItem = gearsetItem.Where(x => x.ItemID != 0).ToArray(); + if (gearsetItem.Length > 0) + { + ClassJob classJob = (ClassJob)gearset->ClassJob; + CachedItem[] currentItems = gearsetItem.Select(x => LookupItem(x.ItemID)).Where(x => x != null) + .Select(x => x!).ToArray(); + if (currentItems.Length == 0) + { + _pluginLog.Information($"Unable to find gearset items"); + return new List(); + } + + 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(); + } + + private CachedItem? LookupItem(uint itemId) + { + if (_cachedItems.TryGetValue(itemId, out CachedItem? cachedItem)) + return cachedItem; + + try + { + var item = _dataManager.GetExcelSheet()!.GetRow(itemId % 1_000_000)!; + cachedItem = new CachedItem + { + Item = item, + ItemId = item.RowId, + Hq = itemId > 1_000_000, + 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) + => RaptureGearsetModule.Instance()->EquipGearset((byte)commandId); + + public void Dispose() + { + _clientState.TerritoryChanged -= TerritoryChanged; + _pluginInterface.RemoveChatLinkHandler(); + _commandManager.RemoveHandler("/gup"); + } +} diff --git a/Gearsetter/packages.lock.json b/Gearsetter/packages.lock.json new file mode 100644 index 0000000..6cf1c73 --- /dev/null +++ b/Gearsetter/packages.lock.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "dependencies": { + "net7.0-windows7.0": { + "DalamudPackager": { + "type": "Direct", + "requested": "[2.1.12, )", + "resolved": "2.1.12", + "contentHash": "Sc0PVxvgg4NQjcI8n10/VfUQBAS4O+Fw2pZrAqBdRMbthYGeogzu5+xmIGCGmsEZ/ukMOBuAqiNiB5qA3MRalg==" + } + } + } +} \ No newline at end of file diff --git a/global.json b/global.json new file mode 100644 index 0000000..aaac9e0 --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "7.0.0", + "rollForward": "latestMinor", + "allowPrerelease": false + } +} \ No newline at end of file