diff --git a/LLib b/LLib index abbbec4..e59d291 160000 --- a/LLib +++ b/LLib @@ -1 +1 @@ -Subproject commit abbbec4f26b1a8903b0cd7aa04f00d557602eaf3 +Subproject commit e59d291f04473eae0b76712397733e2e25349953 diff --git a/Workshoppa/Configuration.cs b/Workshoppa/Configuration.cs index 76971a5..fb28353 100644 --- a/Workshoppa/Configuration.cs +++ b/Workshoppa/Configuration.cs @@ -10,8 +10,9 @@ internal sealed class Configuration : IPluginConfiguration { public int Version { get; set; } = 1; - public CurrentItem? CurrentlyCraftedItem = null; - public List ItemQueue = new(); + public CurrentItem? CurrentlyCraftedItem { get; set; } = null; + public List ItemQueue { get; set; } = new(); + public bool EnableRepairKitCalculator { get; set; } = true; internal sealed class QueuedItem { diff --git a/Workshoppa/GameData/GameStrings.cs b/Workshoppa/GameData/GameStrings.cs new file mode 100644 index 0000000..b91f656 --- /dev/null +++ b/Workshoppa/GameData/GameStrings.cs @@ -0,0 +1,18 @@ +using System; +using System.Text.RegularExpressions; +using Dalamud.Plugin.Services; +using LLib; +using Lumina.Excel.GeneratedSheets; + +namespace Workshoppa.GameData; + +internal sealed class GameStrings +{ + public GameStrings(IDataManager dataManager, IPluginLog pluginLog) + { + PurchaseItem = dataManager.GetRegex(3406, addon => addon.Text, pluginLog) + ?? throw new Exception($"Unable to resolve {nameof(PurchaseItem)}"); + } + + public Regex PurchaseItem { get; } +} diff --git a/Workshoppa/Windows/ConfigWindow.cs b/Workshoppa/Windows/ConfigWindow.cs new file mode 100644 index 0000000..c935223 --- /dev/null +++ b/Workshoppa/Windows/ConfigWindow.cs @@ -0,0 +1,34 @@ +using System.Numerics; +using Dalamud.Interface.Windowing; +using Dalamud.Plugin; +using ImGuiNET; + +namespace Workshoppa.Windows; + +internal sealed class ConfigWindow : Window +{ + private readonly DalamudPluginInterface _pluginInterface; + private readonly Configuration _configuration; + + public ConfigWindow(DalamudPluginInterface pluginInterface, Configuration configuration) + : base("Workshoppa - Configuration###WorkshoppaConfigWindow") + + { + _pluginInterface = pluginInterface; + _configuration = configuration; + + Position = new Vector2(100, 100); + PositionCondition = ImGuiCond.FirstUseEver; + Flags = ImGuiWindowFlags.AlwaysAutoResize; + } + + public override void Draw() + { + bool enableRepairKitCalculator = _configuration.EnableRepairKitCalculator; + if (ImGui.Checkbox("Enable Repair Kit Calculator", ref enableRepairKitCalculator)) + { + _configuration.EnableRepairKitCalculator = enableRepairKitCalculator; + _pluginInterface.SavePluginConfig(_configuration); + } + } +} diff --git a/Workshoppa/Windows/RepairKitWindow.cs b/Workshoppa/Windows/RepairKitWindow.cs new file mode 100644 index 0000000..25f9d9a --- /dev/null +++ b/Workshoppa/Windows/RepairKitWindow.cs @@ -0,0 +1,300 @@ +using System; +using System.Linq; +using System.Numerics; +using Dalamud.Game.Addon.Lifecycle; +using Dalamud.Game.Addon.Lifecycle.AddonArgTypes; +using Dalamud.Interface; +using Dalamud.Interface.Colors; +using Dalamud.Interface.Components; +using Dalamud.Interface.Windowing; +using Dalamud.Plugin; +using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Client.Game; +using FFXIVClientStructs.FFXIV.Component.GUI; +using ImGuiNET; +using LLib; +using LLib.GameUI; +using ValueType = FFXIVClientStructs.FFXIV.Component.GUI.ValueType; + +namespace Workshoppa.Windows; + +internal sealed class RepairKitWindow : Window, IDisposable +{ + private const int DarkMatterCluster6ItemId = 10386; + + private readonly WorkshopPlugin _plugin; + private readonly DalamudPluginInterface _pluginInterface; + private readonly IPluginLog _pluginLog; + private readonly IGameGui _gameGui; + private readonly IAddonLifecycle _addonLifecycle; + private readonly Configuration _configuration; + + private ItemForSale? _itemForSale; + private PurchaseState? _purchaseState; + + public RepairKitWindow(WorkshopPlugin plugin, DalamudPluginInterface pluginInterface, IPluginLog pluginLog, IGameGui gameGui, IAddonLifecycle addonLifecycle, Configuration configuration) + : base("Repair Kits###WorkshoppaRepairKitWindow") + { + _plugin = plugin; + _pluginInterface = pluginInterface; + _pluginLog = pluginLog; + _gameGui = gameGui; + _addonLifecycle = addonLifecycle; + _configuration = configuration; + + Position = new Vector2(100, 100); + PositionCondition = ImGuiCond.Always; + Flags = ImGuiWindowFlags.AlwaysAutoResize | ImGuiWindowFlags.NoNav | ImGuiWindowFlags.NoCollapse; + + _addonLifecycle.RegisterListener(AddonEvent.PostSetup, "Shop", ShopPostSetup); + _addonLifecycle.RegisterListener(AddonEvent.PreFinalize, "Shop", ShopPreFinalize); + _addonLifecycle.RegisterListener(AddonEvent.PostUpdate, "Shop", ShopPostUpdate); + } + + public bool AutoBuyEnabled => _purchaseState != null; + + public bool IsAwaitingYesNo + { + get => _purchaseState?.IsAwaitingYesNo ?? false; + set => _purchaseState!.IsAwaitingYesNo = value; + } + + private unsafe void ShopPostSetup(AddonEvent type, AddonArgs args) + { + if (!_configuration.EnableRepairKitCalculator) + { + _itemForSale = null; + IsOpen = false; + return; + } + + UpdateShopStock((AtkUnitBase*)args.Addon); + if (_itemForSale != null) + IsOpen = true; + } + + private void ShopPreFinalize(AddonEvent type, AddonArgs args) + { + _purchaseState = null; + _plugin.RestoreYesAlready(); + + IsOpen = false; + } + + private unsafe void ShopPostUpdate(AddonEvent type, AddonArgs args) + { + if (!_configuration.EnableRepairKitCalculator) + { + _itemForSale = null; + IsOpen = false; + return; + } + + UpdateShopStock((AtkUnitBase*)args.Addon); + if (_itemForSale != null) + { + AtkUnitBase* addon = (AtkUnitBase*)args.Addon; + short x = 0, y = 0; + addon->GetPosition(&x, &y); + + short width = 0, height = 0; + addon->GetSize(&width, &height, true); + x += width; + + if ((short)Position!.Value.X != x || (short)Position!.Value.Y != y) + Position = new Vector2(x, y); + + IsOpen = true; + } + else + IsOpen = false; + } + + private unsafe void UpdateShopStock(AtkUnitBase* addon) + { + if (GetDarkMatterClusterCount() == 0) + { + _itemForSale = null; + return; + } + + if (addon->AtkValuesCount != 625) + { + _pluginLog.Error($"Unexpected amount of atkvalues for Shop addon ({addon->AtkValuesCount})"); + _itemForSale = null; + return; + } + + var atkValues = addon->AtkValues; + + // Check if on 'Current Stock' tab? + if (atkValues[0].UInt != 0) + { + _itemForSale = null; + return; + } + + uint itemCount = atkValues[2].UInt; + if (itemCount == 0) + { + _itemForSale = null; + return; + } + + _itemForSale = Enumerable.Range(0, (int)itemCount) + .Select(i => new ItemForSale + { + Position = i, + ItemName = atkValues[14 + i].ReadAtkString(), + Price = atkValues[75 + i].UInt, + OwnedItems = atkValues[136 + i].UInt, + ItemId = atkValues[441 + i].UInt, + }) + .FirstOrDefault(x => x.ItemId == DarkMatterCluster6ItemId); + if (_itemForSale != null && _purchaseState != null) + { + int ownedItems = (int)_itemForSale.OwnedItems; + if (_purchaseState.OwnedItems != ownedItems) + { + _purchaseState.OwnedItems = ownedItems; + _purchaseState.NextStep = DateTime.Now.AddSeconds(0.25); + } + } + } + + private int GetDarkMatterClusterCount() => GetItemCount(10335); + + private int GetGil() => GetItemCount(1); + + private unsafe int GetItemCount(uint itemId) + { + InventoryManager* inventoryManager = InventoryManager.Instance(); + return inventoryManager->GetInventoryItemCount(itemId, checkEquipped: false, checkArmory: false); + } + + private int GetMaxItemsToPurchase() + { + if (_itemForSale == null) + return 0; + + int gil = GetGil(); + return (int)(gil / _itemForSale!.Price); + } + + public override void Draw() + { + int darkMatterClusters = GetDarkMatterClusterCount(); + if (_itemForSale == null || darkMatterClusters == 0) + { + IsOpen = false; + return; + } + + LImGui.AddPatreonIcon(_pluginInterface); + + ImGui.Text("Inventory"); + ImGui.Indent(); + ImGui.Text($"Dark Matter Clusters: {darkMatterClusters:N0}"); + ImGui.Text($"Grade 6 Dark Matter: {_itemForSale.OwnedItems:N0}"); + ImGui.Unindent(); + + int missingItems = Math.Max(0, darkMatterClusters * 5 - (int)_itemForSale.OwnedItems); + ImGui.TextColored(missingItems == 0 ? ImGuiColors.HealerGreen : ImGuiColors.DalamudRed, $"Missing Grade 6 Dark Matter: {missingItems:N0}"); + + if (_purchaseState != null) + { + HandleNextPurchaseStep(); + + if (ImGuiComponents.IconButtonWithText(FontAwesomeIcon.Times, "Cancel Auto-Buy")) + { + _purchaseState = null; + _plugin.RestoreYesAlready(); + } + } + else + { + int toPurchase = Math.Min(GetMaxItemsToPurchase(), missingItems); + if (toPurchase > 0) + { + if (ImGuiComponents.IconButtonWithText(FontAwesomeIcon.DollarSign, "Auto-Buy missing Dark Matter")) + { + _purchaseState = new((int)_itemForSale.OwnedItems + toPurchase, (int)_itemForSale.OwnedItems); + _plugin.SaveYesAlready(); + + HandleNextPurchaseStep(); + } + } + } + } + + private unsafe void HandleNextPurchaseStep() + { + if (_itemForSale == null || _purchaseState == null) + return; + + if (!_plugin.HasFreeInventorySlot()) + { + _pluginLog.Warning($"No free inventory slots, can't buy more {_itemForSale.ItemName}"); + _purchaseState = null; + _plugin.RestoreYesAlready(); + } + else if (!_purchaseState.IsComplete) + { + if (_purchaseState.NextStep <= DateTime.Now && _gameGui.TryGetAddonByName("Shop", out AtkUnitBase* addonShop)) + { + int buyNow = Math.Min(_purchaseState.ItemsLeftToBuy, 99); + _pluginLog.Information($"Buying {buyNow}x {_itemForSale.ItemName}"); + + var buyItem = stackalloc AtkValue[] + { + new() { Type = ValueType.Int, Int = 0 }, + new() { Type = ValueType.Int, Int = _itemForSale.Position }, + new() { Type = ValueType.Int, Int = buyNow }, + new() { Type = 0, Int = 0 } + }; + addonShop->FireCallback(4, buyItem); + + _purchaseState.NextStep = DateTime.MaxValue; + _purchaseState.IsAwaitingYesNo = true; + } + } + else + { + _pluginLog.Information($"Stopping item purchase (desired = {_purchaseState.DesiredItems}, owned = {_purchaseState.OwnedItems})"); + _purchaseState = null; + _plugin.RestoreYesAlready(); + } + } + + public void Dispose() + { + _addonLifecycle.UnregisterListener(AddonEvent.PostSetup, "Shop", ShopPostSetup); + _addonLifecycle.UnregisterListener(AddonEvent.PreFinalize, "Shop", ShopPreFinalize); + _addonLifecycle.UnregisterListener(AddonEvent.PostUpdate, "PostUpdate", ShopPostUpdate); + } + + private sealed class ItemForSale + { + public required int Position { get; init; } + public required uint ItemId { get; init; } + public required string? ItemName { get; init; } + public required uint Price { get; init; } + public required uint OwnedItems { get; init; } + } + + private sealed class PurchaseState + { + public PurchaseState(int desiredItems, int ownedItems) + { + DesiredItems = desiredItems; + OwnedItems = ownedItems; + } + + public int DesiredItems { get; } + public int OwnedItems { get; set; } + public int ItemsLeftToBuy => Math.Max(0, DesiredItems - OwnedItems); + public bool IsComplete => ItemsLeftToBuy == 0; + public bool IsAwaitingYesNo { get; set; } + public DateTime NextStep { get; set; } = DateTime.MinValue; + } +} diff --git a/Workshoppa/WorkshopPlugin.GameFunctions.cs b/Workshoppa/WorkshopPlugin.GameFunctions.cs index 88629cc..6cb7166 100644 --- a/Workshoppa/WorkshopPlugin.GameFunctions.cs +++ b/Workshoppa/WorkshopPlugin.GameFunctions.cs @@ -215,4 +215,24 @@ partial class WorkshopPlugin return false; } + + public unsafe bool HasFreeInventorySlot() + { + var inventoryManger = InventoryManager.Instance(); + if (inventoryManger == null) + return false; + + for (InventoryType t = InventoryType.Inventory1; t <= InventoryType.Inventory4; ++t) + { + var container = inventoryManger->GetInventoryContainer(t); + for (int i = 0; i < container->Size; ++i) + { + var item = container->GetInventorySlot(i); + if (item == null || item->ItemID == 0) + return true; + } + } + + return false; + } } diff --git a/Workshoppa/WorkshopPlugin.SelectYesNo.cs b/Workshoppa/WorkshopPlugin.SelectYesNo.cs new file mode 100644 index 0000000..6eb7eb6 --- /dev/null +++ b/Workshoppa/WorkshopPlugin.SelectYesNo.cs @@ -0,0 +1,38 @@ +using System; +using Dalamud.Game.Addon.Lifecycle; +using Dalamud.Game.Addon.Lifecycle.AddonArgTypes; +using Dalamud.Memory; +using FFXIVClientStructs.FFXIV.Client.UI; + +namespace Workshoppa; + +partial class WorkshopPlugin +{ + private unsafe void SelectYesNoPostSetup(AddonEvent type, AddonArgs args) + { + _pluginLog.Verbose("SelectYesNo post-setup"); + + AddonSelectYesno* addonSelectYesNo = (AddonSelectYesno*)args.Addon; + string text = MemoryHelper.ReadSeString(&addonSelectYesNo->PromptText->NodeText).ToString().Replace("\n", "").Replace("\r", ""); + _pluginLog.Verbose($"YesNo prompt: '{text}'"); + + if (_repairKitWindow.IsOpen) + { + _pluginLog.Verbose($"Checking for Repair Kit YesNo ({_repairKitWindow.AutoBuyEnabled}, {_repairKitWindow.IsAwaitingYesNo})"); + if (_repairKitWindow.AutoBuyEnabled && _repairKitWindow.IsAwaitingYesNo && _gameStrings.PurchaseItem.IsMatch(text)) + { + _pluginLog.Information($"Selecting 'yes' ({text})"); + _repairKitWindow.IsAwaitingYesNo = false; + addonSelectYesNo->AtkUnitBase.FireCallbackInt(0); + } + else + { + _pluginLog.Verbose("Not a purchase confirmation match"); + } + } + else if (_mainWindow.IsOpen) + { + // TODO + } + } +} diff --git a/Workshoppa/WorkshopPlugin.cs b/Workshoppa/WorkshopPlugin.cs index 3c46553..2782b86 100644 --- a/Workshoppa/WorkshopPlugin.cs +++ b/Workshoppa/WorkshopPlugin.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; +using Dalamud.Game.Addon.Lifecycle; using Dalamud.Game.ClientState.Conditions; using Dalamud.Game.Command; using Dalamud.Interface.Windowing; @@ -28,11 +29,16 @@ public sealed partial class WorkshopPlugin : IDalamudPlugin private readonly IObjectTable _objectTable; private readonly ICommandManager _commandManager; private readonly IPluginLog _pluginLog; + private readonly IAddonLifecycle _addonLifecycle; private readonly Configuration _configuration; private readonly YesAlreadyIpc _yesAlreadyIpc; private readonly WorkshopCache _workshopCache; + private readonly GameStrings _gameStrings; + private readonly MainWindow _mainWindow; + private readonly ConfigWindow _configWindow; + private readonly RepairKitWindow _repairKitWindow; private Stage _currentStageInternal = Stage.Stopped; private DateTime _continueAt = DateTime.MinValue; @@ -40,7 +46,7 @@ public sealed partial class WorkshopPlugin : IDalamudPlugin public WorkshopPlugin(DalamudPluginInterface pluginInterface, IGameGui gameGui, IFramework framework, ICondition condition, IClientState clientState, IObjectTable objectTable, IDataManager dataManager, - ICommandManager commandManager, IPluginLog pluginLog) + ICommandManager commandManager, IPluginLog pluginLog, IAddonLifecycle addonLifecycle) { _pluginInterface = pluginInterface; _gameGui = gameGui; @@ -50,22 +56,31 @@ public sealed partial class WorkshopPlugin : IDalamudPlugin _objectTable = objectTable; _commandManager = commandManager; _pluginLog = pluginLog; + _addonLifecycle = addonLifecycle; var dalamudReflector = new DalamudReflector(_pluginInterface, _framework, _pluginLog); _yesAlreadyIpc = new YesAlreadyIpc(dalamudReflector); _configuration = (Configuration?)_pluginInterface.GetPluginConfig() ?? new Configuration(); _workshopCache = new WorkshopCache(dataManager, _pluginLog); + _gameStrings = new(dataManager, _pluginLog); _mainWindow = new(this, _pluginInterface, _clientState, _configuration, _workshopCache); _windowSystem.AddWindow(_mainWindow); + _configWindow = new(_pluginInterface, _configuration); + _windowSystem.AddWindow(_configWindow); + _repairKitWindow = new(this, _pluginInterface, _pluginLog, _gameGui, addonLifecycle, _configuration); + _windowSystem.AddWindow(_repairKitWindow); _pluginInterface.UiBuilder.Draw += _windowSystem.Draw; _pluginInterface.UiBuilder.OpenMainUi += OpenMainUi; + _pluginInterface.UiBuilder.OpenConfigUi += _configWindow.Toggle; _framework.Update += FrameworkUpdate; _commandManager.AddHandler("/ws", new CommandInfo(ProcessCommand) { HelpMessage = "Open UI" }); + + _addonLifecycle.RegisterListener(AddonEvent.PostSetup, "SelectYesno", SelectYesNoPostSetup); } internal Stage CurrentStage @@ -202,22 +217,31 @@ public sealed partial class WorkshopPlugin : IDalamudPlugin } private void ProcessCommand(string command, string arguments) - => _mainWindow.Toggle(MainWindow.EOpenReason.Command); + { + if (arguments is "c" or "config") + _configWindow.Toggle(); + else + _mainWindow.Toggle(MainWindow.EOpenReason.Command); + } private void OpenMainUi() => _mainWindow.Toggle(MainWindow.EOpenReason.PluginInstaller); public void Dispose() { + _addonLifecycle.UnregisterListener(AddonEvent.PostSetup, "SelectYesno", SelectYesNoPostSetup); _commandManager.RemoveHandler("/ws"); _pluginInterface.UiBuilder.Draw -= _windowSystem.Draw; + _pluginInterface.UiBuilder.OpenConfigUi -= _configWindow.Toggle; _pluginInterface.UiBuilder.OpenMainUi -= OpenMainUi; _framework.Update -= FrameworkUpdate; + _repairKitWindow.Dispose(); + RestoreYesAlready(); } - private void SaveYesAlready() + public void SaveYesAlready() { if (_yesAlreadyState.Saved) { @@ -229,7 +253,7 @@ public sealed partial class WorkshopPlugin : IDalamudPlugin _pluginLog.Information($"Previous yesalready state: {_yesAlreadyState.PreviousState}"); } - private void RestoreYesAlready() + public void RestoreYesAlready() { if (_yesAlreadyState.Saved) { diff --git a/Workshoppa/Workshoppa.csproj b/Workshoppa/Workshoppa.csproj index ee9b2c2..20824c7 100644 --- a/Workshoppa/Workshoppa.csproj +++ b/Workshoppa/Workshoppa.csproj @@ -1,7 +1,7 @@ net7.0-windows - 2.3 + 2.4 11.0 enable true