Initial Commit

master v0.1
Liza 2024-01-16 06:42:23 +01:00
commit 432eeafd3f
Signed by: liza
GPG Key ID: 7199F8D727D55F67
11 changed files with 632 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/.idea
*.user

16
Gearsetter.sln Normal file
View File

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

3
Gearsetter/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
/dist
/obj
/bin

147
Gearsetter/CachedItem.cs Normal file
View File

@ -0,0 +1,147 @@
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 == 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<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;
}
}
}

46
Gearsetter/ClassJob.cs Normal file
View File

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

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<Project>
<Target Name="PackagePlugin" AfterTargets="Build" Condition="'$(Configuration)' == 'Debug'">
<DalamudPackager
ProjectDir="$(ProjectDir)"
OutputPath="$(OutputPath)"
AssemblyName="$(AssemblyName)"
MakeZip="false"
VersionComponents="2"/>
</Target>
<Target Name="PackagePlugin" AfterTargets="Build" Condition="'$(Configuration)' == 'Release'">
<DalamudPackager
ProjectDir="$(ProjectDir)"
OutputPath="$(OutputPath)"
AssemblyName="$(AssemblyName)"
MakeZip="true"
VersionComponents="2"
Exclude="Gearsetter.deps.json"/>
</Target>
</Project>

View File

@ -0,0 +1,60 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0-windows</TargetFramework>
<Version>0.1</Version>
<LangVersion>11.0</LangVersion>
<Nullable>enable</Nullable>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<OutputPath>dist</OutputPath>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<DebugType>portable</DebugType>
<PathMap Condition="$(SolutionDir) != ''">$(SolutionDir)=X:\</PathMap>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
<DebugType>portable</DebugType>
</PropertyGroup>
<PropertyGroup>
<DalamudLibPath>$(appdata)\XIVLauncher\addon\Hooks\dev\</DalamudLibPath>
</PropertyGroup>
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))'">
<DalamudLibPath>$(DALAMUD_HOME)/</DalamudLibPath>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DalamudPackager" Version="2.1.12"/>
</ItemGroup>
<ItemGroup>
<Reference Include="Dalamud">
<HintPath>$(DalamudLibPath)Dalamud.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="ImGui.NET">
<HintPath>$(DalamudLibPath)ImGui.NET.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Lumina">
<HintPath>$(DalamudLibPath)Lumina.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Lumina.Excel">
<HintPath>$(DalamudLibPath)Lumina.Excel.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Newtonsoft.Json">
<HintPath>$(DalamudLibPath)Newtonsoft.Json.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="FFXIVClientStructs">
<HintPath>$(DalamudLibPath)FFXIVClientStructs.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
<Target Name="RenameLatestZip" AfterTargets="PackagePlugin" Condition="'$(Configuration)' == 'Release'">
<Exec Command="rename $(OutDir)$(AssemblyName)\latest.zip $(AssemblyName)-$(Version).zip"/>
</Target>
</Project>

View File

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

View File

@ -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<byte, DalamudLinkPayload> _linkPayloads;
private readonly Dictionary<uint, List<ClassJob>> _classJobCategories;
private readonly Dictionary<byte, byte> _classJobToArrayIndex;
private readonly Dictionary<uint, CachedItem> _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<Lumina.Excel.GeneratedSheets.ClassJob>()!
.Where(x => x.RowId > 0)
.ToDictionary(x => (byte)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)
{
if (territory == 128)
ShowUpgrades();
}
private void ProcessCommand(string command, string arguments) => ShowUpgrades();
private unsafe void ShowUpgrades()
{
var inventoryManager = InventoryManager.Instance();
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();
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<CachedItem> inventoryItems)
{
string name = GetGearsetName(gearset);
if (name.Contains('_') || name.Contains("Eureka") || name.Contains("Bozja"))
return false;
List<List<SeString>> 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<SeString> 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;
}
/// <summary>
/// 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).
/// </summary>
private unsafe string GetGearsetName(RaptureGearsetModule.GearsetEntry* gearset)
=> Encoding.UTF8.GetString(gearset->Name, 0x2F).Split((char)0)[0];
private unsafe List<SeString> HandleGearsetItem(string label, RaptureGearsetModule.GearsetEntry* gearset,
RaptureGearsetModule.GearsetItem gearsetItem, List<CachedItem> inventoryItems)
=> HandleGearsetItem(label, gearset, new[] { gearsetItem }, inventoryItems);
private unsafe List<SeString> HandleGearsetItem(string label, RaptureGearsetModule.GearsetEntry* gearset,
RaptureGearsetModule.GearsetItem[] gearsetItem, List<CachedItem> 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<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>();
}
private CachedItem? LookupItem(uint itemId)
{
if (_cachedItems.TryGetValue(itemId, out CachedItem? cachedItem))
return cachedItem;
try
{
var item = _dataManager.GetExcelSheet<Item>()!.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");
}
}

View File

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

7
global.json Normal file
View File

@ -0,0 +1,7 @@
{
"sdk": {
"version": "7.0.0",
"rollForward": "latestMinor",
"allowPrerelease": false
}
}