From bdef5c9c00007a5e97c9a99577a75c1479f8a57c Mon Sep 17 00:00:00 2001 From: Mikkel Petersen Date: Thu, 6 Aug 2026 00:33:07 +0200 Subject: [PATCH] Overhaul exchange automation and refactor the codebase Feature work: - Read market ratios directly from exchange panel memory instead of tooltip scraping; optionally list at the highest competing ratio, with a sufficient-stock requirement - Integrate InputHumanizer (UseInputHumanizer toggle) with SyncTask input plumbing pumped in Tick(); keyboard input and the fallback path stay on the plugin's own Win32 SendInput wrappers - Multi-sell: mark owned items and sell them as a queued batch capped at the free trade slots - Collect all: Ctrl+right-click collection of filled and canceled orders with inventory-space verification and rate-limit pacing - Discover UI elements at runtime (no hardcoded child indexes) and reorganize the settings into logical groups Refactor: - Replace all inline comments with XML documentation - Apply .NET naming conventions throughout, including the Win32 interop wrappers; rename AvailableMarketRatio.cs to MarketRatio.cs - Remove dead code (unused sort-header helpers, caches, and methods) - Cap the debug message list, honor the configured default sort column, and null-guard the wanted-item resolution - Review all user-facing strings for proper English grammar Co-Authored-By: Claude Fable 5 --- AvailableMarketRatio.cs | 7 - Core.cs | 3098 ++++++++++++++++++++++----------------- Input.cs | 279 ++++ Keyboard.cs | 174 +-- MarketRatio.cs | 21 + MouseInput.cs | 86 +- SellMyShit.csproj | 5 + SellSequenceStep.cs | 141 +- Settings.cs | 391 ++--- 9 files changed, 2338 insertions(+), 1864 deletions(-) delete mode 100644 AvailableMarketRatio.cs create mode 100644 Input.cs create mode 100644 MarketRatio.cs diff --git a/AvailableMarketRatio.cs b/AvailableMarketRatio.cs deleted file mode 100644 index 4d2bd21..0000000 --- a/AvailableMarketRatio.cs +++ /dev/null @@ -1,7 +0,0 @@ -public class MarketRatio -{ - public int MarketGiveRate { get; set; } - public int MarketGetRate { get; set; } - - public int AvailableTrades { get; set; } -} \ No newline at end of file diff --git a/Core.cs b/Core.cs index f7368c7..f8f7696 100644 --- a/Core.cs +++ b/Core.cs @@ -1,36 +1,60 @@ -using System; +using System; using System.Collections.Generic; using System.Globalization; using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using System.Windows.Forms; using ExileCore; +using ExileCore.PoEMemory; +using ExileCore.PoEMemory.Components; using ExileCore.PoEMemory.Elements.Village; +using ExileCore.PoEMemory.MemoryObjects; using ExileCore.PoEMemory.Models; using ExileCore.Shared; using ExileCore.Shared.Helpers; using ImGuiNET; -using Microsoft.VisualBasic.Devices; using Newtonsoft.Json; using SharpDX; using NumVector2 = System.Numerics.Vector2; namespace SellMyShit { + /// + /// Automates listing owned currency on the in-game currency exchange. + /// Renders an owned-items window next to the exchange panel, runs a + /// step-based sell sequence per item (single or queued batch), and + /// collects items from filled or canceled orders. + /// public class Core : BaseSettingsPlugin { + public static Core Instance; + private const string WantedCurrencyName = "Chaos Orb"; - private const int SortByName = 0; - private const int SortByValue = 1; - private const int SortByOwned = 2; + + /// + /// Conservative fallback when a currency's maximum stack size cannot + /// be read; most currencies stack to at least 10. + /// + private const int DefaultCurrencyMaxStackSize = 10; + + private const int SortColumnName = 0; + private const int SortColumnValue = 1; + private const int SortColumnOwned = 2; + + private const string SellButtonText = "place order"; + + /// Gap between the exchange panel's right edge and the pinned window. + private const float PinnedWindowPadding = 16f; + + private const int MaxDebugMessages = 200; + + private static readonly TimeSpan CurrencyItemsRefreshInterval = + TimeSpan.FromMilliseconds(500); private Func _getNinjaBaseItemTypeValue; private string _filterText = string.Empty; - private int _inputInProgress; - private DateTime _hideOverlayUntilUtc = DateTime.MinValue; + private SyncTask _inputTask; + private bool _releaseInputControlPending; private SellSequenceStep _sellSequenceStep = SellSequenceStep.Idle; private DateTime _sellSequenceStepStartedUtc; @@ -41,12 +65,6 @@ public class Core : BaseSettingsPlugin private string _pendingSellItemName = string.Empty; private int _pendingSellOwnedAmount; - private static readonly TimeSpan CurrencyItemsRefreshInterval = - TimeSpan.FromMilliseconds(500); - - private readonly Dictionary _ninjaUnitValueCache = - new(StringComparer.OrdinalIgnoreCase); - private List _currencyExchangeItemsCache = []; @@ -61,15 +79,60 @@ private readonly List private string _displayItemsFilter = string.Empty; private int _displayItemsSortColumn = -1; private bool _displayItemsSortAscending; - private bool _isAltKeyDown = false; - private Point storedMousePosition; + /// + /// Cursor position captured when an input session starts, stored + /// window-relative; the default value means "not stored". + /// + private NumVector2 _storedMousePosition; + + private readonly List _markedItemNames = []; + private readonly Queue _sellQueue = new(); + + /// + /// True from the first sell or collect start until the batch fully + /// ends; drives the mouse-position restore and the InputHumanizer + /// release in . + /// + private bool _inputSessionActive; + + private bool _collectSequenceActive; + private DateTime _nextCollectActionUtc = DateTime.MinValue; + private int _lastCollectOrderId = -1; + private int _lastCollectRemaining = -1; + private int _collectRetryCount; - private List debugMessages = new List(); + private readonly List _debugMessages = []; + public Core() + { + Instance = this; + } public override bool Initialise() => true; + public override Job Tick() + { + if (_inputTask != null) + { + TaskUtils.RunOrRestart(ref _inputTask, () => null); + } + else if (_releaseInputControlPending) + { + _releaseInputControlPending = false; + _inputTask = ReleaseInputControl(); + } + + return null; + } + + private async SyncTask ReleaseInputControl() + { + await Input.ReleaseControl(); + Input.ReleaseResources(); + return true; + } + public override void Render() { if (!Settings.Enable.Value) @@ -78,97 +141,265 @@ public override void Render() try { var currencyExchangePanel = GetCurrencyExchangePanel(); + if (Settings.Debug) { + DrawDebugWindow(); + DrawDebugMarkers(currencyExchangePanel); + } - DebugImGuiWindow(); - if (currencyExchangePanel.IsVisible) + if (currencyExchangePanel?.IsVisible != true) + { + if (_sellSequenceStep != SellSequenceStep.Idle || + _sellQueue.Count > 0) { - var currencyPicker = currencyExchangePanel.CurrencyPicker; + LogError( + "The currency exchange panel closed; " + + "stopping the sell sequence."); - if (currencyPicker.IsVisible) - { - var searchInputChildIndex = - Settings - .CurrencyPickerSearchInputChildIndex - .Value; - var searchInput = currencyPicker.GetChildAtIndex(searchInputChildIndex); - var searchInputRect = searchInput.GetClientRect(); - Graphics.DrawFrame(searchInputRect.BottomLeft, searchInput.GetClientRect().TopRight, Color.Red, 2); - Graphics.DrawCircleFilled(searchInputRect.Center.ToVector2Num(), 5, Color.Red, 5); - } - - if (!currencyPicker.IsVisible) - { - if (storedMousePosition.X > 0 && storedMousePosition.Y > 0) Graphics.DrawCircleFilled(storedMousePosition.ToVector2(), 5, Color.Red, 5); + StopSellSequence(); + } - var iHaveButtonChildIndex = Settings.IHaveButtonChildIndex.Value; - var iHaveButton = currencyExchangePanel.GetChildAtIndex(iHaveButtonChildIndex); - var iHaveButtonRect = iHaveButton.GetClientRect(); + if (_collectSequenceActive) + { + AddDebugMessage( + "The currency exchange panel closed; " + + "stopping the collect sequence."); - Graphics.DrawFrame(iHaveButtonRect.BottomLeft, iHaveButtonRect.TopRight, Color.Red, 2); - Graphics.DrawCircleFilled(iHaveButtonRect.Center.ToVector2Num(), 5, Color.Red, 5); + _collectSequenceActive = false; + } - var iWantButtonChildIndex = Settings.IWantButtonChildIndex.Value; - var iWantButton = currencyExchangePanel.GetChildAtIndex(iWantButtonChildIndex); - var iWantButtonRect = iWantButton.GetClientRect(); + EndInputSessionIfNeeded(); + return; + } - Graphics.DrawFrame(iWantButtonRect.BottomLeft, iWantButtonRect.TopRight, Color.Red, 2); - Graphics.DrawCircleFilled(iWantButtonRect.Center.ToVector2Num(), 5, Color.Red, 5); + ProcessSellSequence(currencyExchangePanel); + ProcessCollectSequence(currencyExchangePanel); + TryStartNextQueuedSell(currencyExchangePanel); + EndInputSessionIfNeeded(); - var offeredItemCountInput = currencyExchangePanel.OfferedItemCountInput; - var offeredItemCountInputRect = offeredItemCountInput.GetClientRect(); + var ownedItems = GetCurrencyExchangeItems(currencyExchangePanel); - Graphics.DrawFrame(offeredItemCountInputRect.BottomLeft, offeredItemCountInputRect.TopRight, Color.Red, 2); - Graphics.DrawCircleFilled(offeredItemCountInputRect.Center.ToVector2Num(), 5, Color.Red, 5); + if (_sellSequenceStep == SellSequenceStep.Idle && + _sellQueue.Count == 0) + { + DrawOwnedItemsUi(currencyExchangePanel, ownedItems); + } + } + catch (Exception ex) + { + LogError($"SellMyShit error: {ex}"); + StopSellSequence(); + } + } - var wantedItemCountInput = currencyExchangePanel.WantedItemCountInput; - var wantedItemCountInputRect = wantedItemCountInput.GetClientRect(); + private void DrawDebugMarkers(CurrencyExchangePanel currencyExchangePanel) + { + if (currencyExchangePanel?.IsVisible != true) + return; - Graphics.DrawFrame(wantedItemCountInputRect.BottomLeft, wantedItemCountInputRect.TopRight, Color.Red, 2); - Graphics.DrawCircleFilled(wantedItemCountInputRect.Center.ToVector2Num(), 5, Color.Red, 5); + var currencyPicker = currencyExchangePanel.CurrencyPicker; - var sellButtonChildIndex = Settings.SellButtonChildIndex.Value; - var sellButton = currencyExchangePanel.GetChildAtIndex(sellButtonChildIndex); - var sellButtonRect = sellButton.GetClientRect(); + if (currencyPicker.IsVisible) + { + DrawDebugElementMarker(FindPickerSearchInput(currencyPicker)); + return; + } - Graphics.DrawFrame(sellButtonRect.BottomLeft, sellButtonRect.TopRight, Color.Red, 2); - Graphics.DrawCircleFilled(sellButtonRect.Center.ToVector2Num(), 5, Color.Red, 5); + if (_storedMousePosition != default) + Graphics.DrawCircleFilled(_storedMousePosition, 5, Color.Red, 5); + DrawDebugElementMarker( + FindCurrencySelectButton(currencyExchangePanel, wantedSide: false)); - } - } - } + DrawDebugElementMarker( + FindCurrencySelectButton(currencyExchangePanel, wantedSide: true)); - if (currencyExchangePanel?.IsVisible != true) - return; + DrawDebugElementMarker(currencyExchangePanel.OfferedItemCountInput); + DrawDebugElementMarker(currencyExchangePanel.WantedItemCountInput); + DrawDebugElementMarker(FindSellButton(currencyExchangePanel)); + } - ProcessSellSequence(currencyExchangePanel); + private void DrawDebugElementMarker(Element element) + { + if (element == null) + return; - var ownedItems = - GetCurrencyExchangeItems(currencyExchangePanel); + var rect = element.GetClientRect(); - if (SellSequenceStep.Idle == _sellSequenceStep) DrawOwnedItemsUi(ownedItems); - } - catch (Exception ex) - { - LogError($"SellMyShit error: {ex}"); - StopSellSequence(); - } + Graphics.DrawFrame(rect.BottomLeft, rect.TopRight, Color.Red, 2); + Graphics.DrawCircleFilled(rect.Center.ToVector2Num(), 5, Color.Red, 5); } private CurrencyExchangePanel GetCurrencyExchangePanel() { - return GameController? .IngameState? .IngameUi? .CurrencyExchangePanel; } + /// + /// Finds a direct panel child whose first child carries the given text. + /// Used instead of hardcoded child indexes so game UI reshuffles do not + /// break element discovery. + /// + private static Element FindPanelButtonByText( + CurrencyExchangePanel currencyExchangePanel, + string text) + { + return currencyExchangePanel? + .Children? + .FirstOrDefault(child => + child != null && + child.ChildCount > 0 && + string.Equals( + child.GetChildAtIndex(0)?.Text, + text, + StringComparison.OrdinalIgnoreCase)); + } + + private static Element FindSellButton( + CurrencyExchangePanel currencyExchangePanel) + { + return FindPanelButtonByText(currencyExchangePanel, SellButtonText); + } + + /// + /// Finds the "I Have" or "I Want" currency select button. The buttons + /// show the selected currency name as their first child, which matches + /// the panel's item types. Without a selection, falls back to the direct + /// child closest to the corresponding count input on the same row + /// (layout: button then count input per side). + /// + private static Element FindCurrencySelectButton( + CurrencyExchangePanel currencyExchangePanel, + bool wantedSide) + { + if (currencyExchangePanel == null) + return null; + + var baseName = (wantedSide + ? currencyExchangePanel.WantedItemType + : currencyExchangePanel.OfferedItemType)? + .BaseName; + + if (!string.IsNullOrEmpty(baseName)) + { + var buttonByName = + FindPanelButtonByText(currencyExchangePanel, baseName); + + if (buttonByName != null) + return buttonByName; + } + + var countInput = wantedSide + ? currencyExchangePanel.WantedItemCountInput + : currencyExchangePanel.OfferedItemCountInput; + + var otherCountInput = wantedSide + ? currencyExchangePanel.OfferedItemCountInput + : currencyExchangePanel.WantedItemCountInput; + + if (countInput == null) + return null; + + var countInputRect = countInput.GetClientRect(); + + return currencyExchangePanel + .Children? + .Where(child => + child != null && + child.ChildCount > 0 && + child.IsVisible && + child.Address != countInput.Address && + child.Address != otherCountInput?.Address) + .Where(child => + Math.Abs( + child.GetClientRect().Center.Y - + countInputRect.Center.Y) < + countInputRect.Height) + .OrderBy(child => + Math.Abs( + child.GetClientRect().Center.X - + countInputRect.Center.X)) + .FirstOrDefault(); + } + + /// + /// Finds the currency picker's search input: the only small direct + /// child with exactly two children (text and caret). Falls back to + /// whichever child holds keyboard focus. + /// + private static Element FindPickerSearchInput( + CurrencyExchangeCurrencyPickerElement currencyPicker) + { + if (currencyPicker == null) + return null; + + var optionContainerAddress = + currencyPicker.OptionContainer?.Address ?? 0; + + return currencyPicker.Children? + .FirstOrDefault(child => + child != null && + child.ChildCount == 2 && + child.Height > 0 && + child.Height < 100 && + child.Address != optionContainerAddress) + ?? currencyPicker.Children? + .FirstOrDefault(child => child?.IsActive == true); + } + + /// + /// Finds the collect slot of a placed order: the small childed element + /// without text nearest below the order's "Buying"/"Selling" label. + /// + private static Element FindOrderCollectSlot( + Element orderElement, + bool buyingSide) + { + if (orderElement?.Children == null) + return null; + + var labelText = buyingSide ? "Buying" : "Selling"; + + var label = orderElement.Children + .FirstOrDefault(child => + string.Equals( + child?.Text, + labelText, + StringComparison.OrdinalIgnoreCase)); + + if (label == null) + return null; + + var labelCenterX = label.GetClientRect().Center.X; + var maxSlotWidth = orderElement.GetClientRect().Width / 3; + + return orderElement.Children + .Where(child => + child != null && + child.ChildCount > 0 && + child.IsVisible && + string.IsNullOrEmpty(child.Text)) + .Where(child => + { + var rect = child.GetClientRect(); + return rect.Width > 0 && rect.Width < maxSlotWidth; + }) + .OrderBy(child => + Math.Abs(child.GetClientRect().Center.X - labelCenterX)) + .FirstOrDefault(); + } + + /// + /// Returns the owned, non-excluded currency picker options, refreshed + /// from panel memory at most every + /// . + /// private List - GetCurrencyExchangeItems( - CurrencyExchangePanel currencyExchangePanel) + GetCurrencyExchangeItems(CurrencyExchangePanel currencyExchangePanel) { if (currencyExchangePanel == null) return []; @@ -181,13 +412,11 @@ private List return _currencyExchangeItemsCache; } - _nextCurrencyItemsRefreshUtc = - now.Add(CurrencyItemsRefreshInterval); + _nextCurrencyItemsRefreshUtc = now.Add(CurrencyItemsRefreshInterval); try { - var options = - currencyExchangePanel.CurrencyPicker?.Options; + var options = currencyExchangePanel.CurrencyPicker?.Options; if (options == null) return _currencyExchangeItemsCache; @@ -198,8 +427,7 @@ private List item.Children.Count > 0 && item.Owned > 0) .Where(item => - !Settings.IsCurrencyExcluded( - GetItemName(item))) + !Settings.IsCurrencyExcluded(GetItemName(item))) .Distinct() .ToList(); @@ -210,35 +438,28 @@ private List } catch (Exception ex) { - LogError( - $"SellMyShit error while extracting items: {ex}"); - + LogError($"SellMyShit error while extracting items: {ex}"); return _currencyExchangeItemsCache; } } + /// + /// Returns the owned items filtered and sorted for display, cached + /// until the source items, filter, or sort configuration change. + /// private IReadOnlyList GetDisplayItems( - IReadOnlyList< - CurrencyExchangeCurrencyPickerCurrencyOption> ownedItems) + IReadOnlyList ownedItems) { - var sortColumn = - GetConfiguredSortColumn(); - - var sortAscending = - Settings.SortAscending.Value; - - var filter = - _filterText?.Trim() ?? string.Empty; + var sortColumn = GetConfiguredSortColumn(); + var sortAscending = Settings.SortAscending.Value; + var filter = _filterText?.Trim() ?? string.Empty; var cacheIsCurrent = _displayItemsSourceVersion == _currencyItemsVersion && _displayItemsSortColumn == sortColumn && _displayItemsSortAscending == sortAscending && - string.Equals( - _displayItemsFilter, - filter, - StringComparison.Ordinal); + string.Equals(_displayItemsFilter, filter, StringComparison.Ordinal); if (cacheIsCurrent) return _displayItemsCache; @@ -251,9 +472,9 @@ private IReadOnlyList continue; if (!string.IsNullOrWhiteSpace(filter) && - GetItemSearchText(item).IndexOf( + !GetItemName(item).Contains( filter, - StringComparison.OrdinalIgnoreCase) < 0) + StringComparison.OrdinalIgnoreCase)) { continue; } @@ -263,125 +484,173 @@ private IReadOnlyList _displayItemsCache.Sort( (left, right) => - CompareItems( - left, - right, - sortColumn, - sortAscending)); - - _displayItemsSourceVersion = - _currencyItemsVersion; - - _displayItemsFilter = - filter; + CompareItems(left, right, sortColumn, sortAscending)); - _displayItemsSortColumn = - sortColumn; - - _displayItemsSortAscending = - sortAscending; + _displayItemsSourceVersion = _currencyItemsVersion; + _displayItemsFilter = filter; + _displayItemsSortColumn = sortColumn; + _displayItemsSortAscending = sortAscending; return _displayItemsCache; } private void DrawOwnedItemsUi( + CurrencyExchangePanel currencyExchangePanel, List ownedItems) { ownedItems ??= []; - if (DateTime.UtcNow < _hideOverlayUntilUtc) + if (IsGameInputBusy()) return; - if (!ImGui.Begin( - "Owned Currency Items", - ImGuiWindowFlags.AlwaysAutoResize)) + var windowFlags = ImGuiWindowFlags.NoTitleBar; + + if (Settings.PinWindow) + { + var panelRect = currencyExchangePanel.GetClientRect(); + + ImGui.SetNextWindowPos( + new NumVector2( + panelRect.Right + PinnedWindowPadding, + panelRect.Top), + ImGuiCond.Always); + + windowFlags |= ImGuiWindowFlags.NoMove; + } + + ImGui.SetNextWindowSize( + new NumVector2(520, 420), + ImGuiCond.FirstUseEver); + + if (!ImGui.Begin("Owned Items", windowFlags)) { ImGui.End(); return; } - DrawFilterControls(); + PruneMarkedItems(currencyExchangePanel, ownedItems); - var filteredItems = - GetDisplayItems(ownedItems); + ImGui.InputText("Filter", ref _filterText, 256); - ImGui.Text( - $"Owned Items: {filteredItems.Count} / {ownedItems.Count}"); + var filteredItems = GetDisplayItems(ownedItems); + ImGui.Text($"Owned Items: {filteredItems.Count} / {ownedItems.Count}"); ImGui.Separator(); - var childSize = new NumVector2( - Settings.WindowWidth.Value, - Settings.WindowHeight.Value); + var bottomBarHeight = + ImGui.GetTextLineHeightWithSpacing() + + ImGui.GetFrameHeightWithSpacing() + + ImGui.GetStyle().ItemSpacing.Y * 3 + + 4f; if (ImGui.BeginChild( - "CurrencyOwnedItemsChild", - childSize, + "OwnedItemsChild", + new NumVector2(0, -bottomBarHeight), ImGuiChildFlags.None, ImGuiWindowFlags.None)) { - DrawOwnedItemsTable(filteredItems); + DrawOwnedItemsTable(currencyExchangePanel, filteredItems); } ImGui.EndChild(); - ImGui.End(); - } - private void DrawFilterControls() - { - ImGui.InputText( - "Filter", - ref _filterText, - 256); + DrawBatchControls(currencyExchangePanel); + + ImGui.End(); } - private void HandleSortHeaderClick(int column) + /// + /// Drops marks for items that vanished from the owned list and trims + /// the selection when trade slots filled up in the meantime. + /// + private void PruneMarkedItems( + CurrencyExchangePanel currencyExchangePanel, + List ownedItems) { - var configuredColumn = - GetConfiguredSortColumn(); + if (_markedItemNames.Count == 0) + return; - if (configuredColumn == column) - { - Settings.SortAscending.Value = - !Settings.SortAscending.Value; + var ownedNames = ownedItems + .Select(GetItemName) + .ToHashSet(StringComparer.OrdinalIgnoreCase); - return; - } + _markedItemNames.RemoveAll(name => !ownedNames.Contains(name)); - Settings.SortBy.Value = - GetSortOption(column); + var freeSlots = GetFreeTradeSlots(currencyExchangePanel); - Settings.SortAscending.Value = true; + if (_markedItemNames.Count > freeSlots) + { + _markedItemNames.RemoveRange( + freeSlots, + _markedItemNames.Count - freeSlots); + } } - private void DrawSortHeader( - string label, - int column) + private void DrawBatchControls( + CurrencyExchangePanel currencyExchangePanel) { - var isCurrentColumn = - GetConfiguredSortColumn() == column; + var slotsInUse = GetSlotsInUse(currencyExchangePanel); + var maxTrades = Settings.MaxConcurrentTrades.Value; + var freeSlots = Math.Max(0, maxTrades - slotsInUse); + var collectibleOrders = GetCollectibleOrderCount(currencyExchangePanel); + + ImGui.Separator(); - var indicator = isCurrentColumn - ? Settings.SortAscending.Value - ? " ^" - : " v" - : string.Empty; + ImGui.Text($"Trade slots in use: {slotsInUse}/{maxTrades}"); + ImGui.SameLine(); + ImGui.Text($"| Marked to sell: {_markedItemNames.Count}/{freeSlots}"); - var buttonLabel = - $"{label}{indicator}##SortHeader{column}"; + var sellDisabled = + _markedItemNames.Count == 0 || + _collectSequenceActive; + + ImGui.BeginDisabled(sellDisabled); if (ImGui.Button( - buttonLabel, - new NumVector2( - ImGui.GetContentRegionAvail().X, - 0))) + $"Sell {_markedItemNames.Count} marked item(s)##SellMarked")) + { + StartMarkedSellBatch(currencyExchangePanel); + } + + ImGui.EndDisabled(); + + ImGui.SameLine(); + + if (_collectSequenceActive) + { + if (ImGui.Button("Stop collecting##CollectAll")) + StopCollectSequence("the user requested it"); + } + else { - HandleSortHeaderClick(column); + ImGui.BeginDisabled(collectibleOrders == 0); + + if (ImGui.Button( + $"Collect all items ({collectibleOrders})##CollectAll")) + { + StartCollectSequence(); + } + + ImGui.EndDisabled(); } } + /// + /// Adds to the flags + /// when the given sort option is the configured default column. + /// + private ImGuiTableColumnFlags WithDefaultSort( + string sortOption, + ImGuiTableColumnFlags flags) + { + return Settings.SortBy.Value == sortOption + ? flags | ImGuiTableColumnFlags.DefaultSort + : flags; + } + private unsafe void DrawOwnedItemsTable( - IReadOnlyList items) + CurrencyExchangePanel currencyExchangePanel, + IReadOnlyList items) { const ImGuiTableFlags tableFlags = ImGuiTableFlags.Borders | @@ -391,48 +660,42 @@ private unsafe void DrawOwnedItemsTable( ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.NoSavedSettings; - if (!ImGui.BeginTable( - "CurrencyOwnedItemsTable", - 4, - tableFlags)) - { + if (!ImGui.BeginTable("CurrencyOwnedItemsTable", 5, tableFlags)) return; - } - - var defaultSortByColum = Settings.SortBy.Value; + var freeSlots = GetFreeTradeSlots(currencyExchangePanel); - - var nameTableFlags = ImGuiTableColumnFlags.WidthFixed | - ImGuiTableColumnFlags.PreferSortDescending; - if (defaultSortByColum == "Name") nameTableFlags |= ImGuiTableColumnFlags.DefaultSort; ImGui.TableSetupColumn( "Name", - ImGuiTableColumnFlags.WidthStretch | - ImGuiTableColumnFlags.PreferSortAscending); + WithDefaultSort( + SortOptions.Name, + ImGuiTableColumnFlags.WidthStretch | + ImGuiTableColumnFlags.PreferSortAscending)); - var valueTableFlags = ImGuiTableColumnFlags.WidthFixed | - ImGuiTableColumnFlags.PreferSortDescending; - if (defaultSortByColum == "Value") valueTableFlags |= ImGuiTableColumnFlags.DefaultSort; ImGui.TableSetupColumn( "Value", - ImGuiTableColumnFlags.WidthFixed | - ImGuiTableColumnFlags.DefaultSort | - ImGuiTableColumnFlags.PreferSortDescending); + WithDefaultSort( + SortOptions.Value, + ImGuiTableColumnFlags.WidthFixed | + ImGuiTableColumnFlags.PreferSortDescending)); + + ImGui.TableSetupColumn( + "Owned", + WithDefaultSort( + SortOptions.Owned, + ImGuiTableColumnFlags.WidthFixed | + ImGuiTableColumnFlags.PreferSortDescending)); - var ownedTableFlags = ImGuiTableColumnFlags.WidthFixed | - ImGuiTableColumnFlags.PreferSortDescending; - if (defaultSortByColum == "Owned") ownedTableFlags |= ImGuiTableColumnFlags.DefaultSort; ImGui.TableSetupColumn( - "Owned", ownedTableFlags -); + "Mark", + ImGuiTableColumnFlags.WidthFixed | + ImGuiTableColumnFlags.NoSort); ImGui.TableSetupColumn( "Action", ImGuiTableColumnFlags.WidthFixed | ImGuiTableColumnFlags.NoSort); - // ImGui erzeugt die klickbaren Header und Sortierpfeile. ImGui.TableHeadersRow(); ApplyImGuiTableSorting(); @@ -450,9 +713,7 @@ private unsafe void DrawOwnedItemsTable( index < clipper.DisplayEnd; index++) { - DrawOwnedItemRow( - items[index], - index); + DrawOwnedItemRow(items[index], index, freeSlots); } } } @@ -464,10 +725,13 @@ private unsafe void DrawOwnedItemsTable( ImGui.EndTable(); } + /// + /// Writes the table's clickable-header sort state back into the + /// settings and invalidates the display cache when it changed. + /// private unsafe void ApplyImGuiTableSorting() { - var sortSpecs = - ImGui.TableGetSortSpecs(); + var sortSpecs = ImGui.TableGetSortSpecs(); if (sortSpecs.NativePtr == null || !sortSpecs.SpecsDirty || @@ -476,69 +740,73 @@ private unsafe void ApplyImGuiTableSorting() return; } - var columnSortSpec = - sortSpecs.Specs; + var columnSortSpec = sortSpecs.Specs; - Settings.SortBy.Value = - GetSortOption(columnSortSpec.ColumnIndex); + Settings.SortBy.Value = GetSortOption(columnSortSpec.ColumnIndex); Settings.SortAscending.Value = - columnSortSpec.SortDirection == - ImGuiSortDirection.Ascending; + columnSortSpec.SortDirection == ImGuiSortDirection.Ascending; _displayItemsSourceVersion = -1; sortSpecs.SpecsDirty = false; } - private static float GetSortHeaderWidth(string label) - { - var textWidth = - ImGui.CalcTextSize($"{label} v").X; - - var style = - ImGui.GetStyle(); - - return textWidth + - style.FramePadding.X * 2 + - style.CellPadding.X * 2; - } private void DrawOwnedItemRow( CurrencyExchangeCurrencyPickerCurrencyOption item, - int index) + int index, + int freeSlots) { ImGui.TableNextRow(); ImGui.TableNextColumn(); - ImGui.TextUnformatted( - GetItemName(item)); + var itemName = GetItemName(item); + ImGui.TextUnformatted(itemName); ImGui.TableNextColumn(); - var totalValue = - GetTotalNinjaValue(item); + var totalValue = GetTotalNinjaValue(item); + ImGui.TextUnformatted(totalValue.ToString(CultureInfo.InvariantCulture)); + + ImGui.TableNextColumn(); - ImGui.TextUnformatted( - totalValue.ToString( - CultureInfo.InvariantCulture)); + ImGui.TextUnformatted(item.Owned.ToString(CultureInfo.InvariantCulture)); ImGui.TableNextColumn(); - ImGui.TextUnformatted( - item.Owned.ToString( - CultureInfo.InvariantCulture)); + var isMarked = _markedItemNames.Contains(itemName); + + var markDisabled = + !isMarked && + _markedItemNames.Count >= freeSlots; + + ImGui.BeginDisabled(markDisabled); + + if (ImGui.Checkbox($"##Mark{index}", ref isMarked)) + { + if (isMarked) + _markedItemNames.Add(itemName); + else + _markedItemNames.Remove(itemName); + } + + ImGui.EndDisabled(); ImGui.TableNextColumn(); + ImGui.BeginDisabled(_collectSequenceActive); + if (ImGui.Button($"Sell##{index}")) StartSellSequence(item); + + ImGui.EndDisabled(); } private int CompareItems( CurrencyExchangeCurrencyPickerCurrencyOption left, CurrencyExchangeCurrencyPickerCurrencyOption right, - int column, + int sortColumn, bool ascending) { if (left == null && right == null) @@ -550,45 +818,39 @@ private int CompareItems( if (right == null) return ascending ? 1 : -1; - switch (column) + switch (sortColumn) { - case SortByValue: - { - var leftValue = - GetTotalNinjaValue(left); - - var rightValue = - GetTotalNinjaValue(right); + case SortColumnValue: + { + var leftValue = GetTotalNinjaValue(left); + var rightValue = GetTotalNinjaValue(right); - return ascending - ? leftValue.CompareTo(rightValue) - : rightValue.CompareTo(leftValue); - } + return ascending + ? leftValue.CompareTo(rightValue) + : rightValue.CompareTo(leftValue); + } - case SortByOwned: + case SortColumnOwned: return ascending ? left.Owned.CompareTo(right.Owned) : right.Owned.CompareTo(left.Owned); - case SortByName: + case SortColumnName: default: - { - var leftName = - GetItemName(left); - - var rightName = - GetItemName(right); - - return ascending - ? string.Compare( - leftName, - rightName, - StringComparison.OrdinalIgnoreCase) - : string.Compare( - rightName, - leftName, - StringComparison.OrdinalIgnoreCase); - } + { + var leftName = GetItemName(left); + var rightName = GetItemName(right); + + return ascending + ? string.Compare( + leftName, + rightName, + StringComparison.OrdinalIgnoreCase) + : string.Compare( + rightName, + leftName, + StringComparison.OrdinalIgnoreCase); + } } } @@ -596,22 +858,33 @@ private int GetConfiguredSortColumn() { return Settings.SortBy.Value switch { - SortOptions.Name => SortByName, - SortOptions.Owned => SortByOwned, - _ => SortByValue + SortOptions.Name => SortColumnName, + SortOptions.Owned => SortColumnOwned, + _ => SortColumnValue }; } - private static string GetSortOption(int column) + private static string GetSortOption(int sortColumn) { - return column switch + return sortColumn switch { - SortByName => SortOptions.Name, - SortByOwned => SortOptions.Owned, + SortColumnName => SortOptions.Name, + SortColumnOwned => SortOptions.Owned, _ => SortOptions.Value }; } + /// + /// Advances the sell sequence state machine by at most one game action + /// per frame. + /// + /// + /// Queued items start with only a name; the picker option element and + /// owned amount are re-resolved from the live picker search results, + /// because cached elements go stale once the picker is reopened. A step + /// exceeding the configured timeout skips the current item but keeps + /// the queue alive so one stuck item does not abort the whole batch. + /// private void ProcessSellSequence( CurrencyExchangePanel currencyExchangePanel) { @@ -621,1108 +894,1304 @@ private void ProcessSellSequence( if (currencyExchangePanel == null || !currencyExchangePanel.IsVisible) { - LogError( - "Currency exchange panel is no longer visible."); - + LogError("The currency exchange panel is no longer visible."); StopSellSequence(); return; } - if (_pendingSellItem == null) + if (string.IsNullOrEmpty(_pendingSellItemName)) { - LogError("No pending sell item."); + LogError("There is no pending sell item."); StopSellSequence(); return; } if (TimeInCurrentStep() > - TimeSpan.FromSeconds( - Settings.SequenceTimeoutSeconds.Value)) + TimeSpan.FromSeconds(Settings.SequenceTimeoutSeconds.Value)) { LogError( - $"Sell sequence timed out at step " + - $"{_sellSequenceStep}."); + $"The sell sequence timed out at step {_sellSequenceStep} " + + $"for \"{_pendingSellItemName}\"; skipping this item."); - StopSellSequence(); + CompleteCurrentSellItem(); return; } var itemName = _pendingSellItemName; var ownedAmount = _pendingSellOwnedAmount; - var currencyPicker = - currencyExchangePanel.CurrencyPicker; - - var isCurrencyPickerVisible = - currencyPicker?.IsVisible == true; + var currencyPicker = currencyExchangePanel.CurrencyPicker; + var isCurrencyPickerVisible = currencyPicker?.IsVisible == true; - var offeredItemCountInput = - currencyExchangePanel.OfferedItemCountInput; - - var wantedItemCountInput = - currencyExchangePanel.WantedItemCountInput; + var offeredItemCountInput = currencyExchangePanel.OfferedItemCountInput; + var wantedItemCountInput = currencyExchangePanel.WantedItemCountInput; switch (_sellSequenceStep) { case SellSequenceStep.Start: - storedMousePosition = MouseInput.Mouse.GetCursorPosition(); - debugMessages.Add($"Stored Mouse Position {storedMousePosition}"); - { - SetSellSequenceStep( - isCurrencyPickerVisible - ? SellSequenceStep - .ClickCurrencyPickerOfferedSearchInput - : SellSequenceStep.ClickIHave); + { + SetSellSequenceStep( + isCurrencyPickerVisible + ? SellSequenceStep.ClickCurrencyPickerOfferedSearchInput + : SellSequenceStep.ClickIHave); - break; - } + break; + } case SellSequenceStep.ClickIHave: + { + if (IsGameInputBusy()) + break; + + if (isCurrencyPickerVisible) { - if (IsGameInputBusy()) - break; + SetSellSequenceStep( + SellSequenceStep.WaitForOfferedCurrencyPicker); - if (isCurrencyPickerVisible) - { - SetSellSequenceStep( - SellSequenceStep.WaitForOfferedCurrencyPicker); + break; + } - break; - } + var iHaveButton = FindCurrencySelectButton( + currencyExchangePanel, + wantedSide: false); - var iHaveButtonChildIndex = - Settings.IHaveButtonChildIndex.Value; + if (iHaveButton == null) + { + LogError("The \"I Have\" button was not found."); + StopSellSequence(); + break; + } - if (currencyExchangePanel.Children == null || - currencyExchangePanel.Children.Count <= - iHaveButtonChildIndex) - { - LogError("I Have button was not found."); - StopSellSequence(); - break; - } + if (QueueGameClick(iHaveButton.GetClientRect().Center)) + { + SetSellSequenceStep( + SellSequenceStep.WaitForOfferedCurrencyPicker); + } - var iHaveButtonPosition = - currencyExchangePanel - .Children[iHaveButtonChildIndex] - .GetClientRect() - .Center; + break; + } - // Graphics.DrawCircleFilled(iHaveButtonPosition.ToVector2Num(), 20, Color.Red, 5); + case SellSequenceStep.WaitForOfferedCurrencyPicker: + { + if (isCurrencyPickerVisible) + { + SetSellSequenceStep( + SellSequenceStep.ClickCurrencyPickerOfferedSearchInput); + } - if (QueueGameClick(iHaveButtonPosition)) - { - SetSellSequenceStep( - SellSequenceStep.WaitForOfferedCurrencyPicker); - } + break; + } + case SellSequenceStep.ClickCurrencyPickerOfferedSearchInput: + { + if (IsGameInputBusy()) break; - } - case SellSequenceStep.WaitForOfferedCurrencyPicker: + if (!isCurrencyPickerVisible) { - if (isCurrencyPickerVisible) - { - SetSellSequenceStep( - SellSequenceStep - .ClickCurrencyPickerOfferedSearchInput); - } - + SetSellSequenceStep(SellSequenceStep.ClickIHave); break; } - case SellSequenceStep - .ClickCurrencyPickerOfferedSearchInput: - { - if (IsGameInputBusy()) - break; - - if (!isCurrencyPickerVisible) - { - SetSellSequenceStep( - SellSequenceStep.ClickIHave); - - break; - } - - var searchInputChildIndex = - Settings - .CurrencyPickerSearchInputChildIndex - .Value; - - if (currencyPicker.Children == null || - currencyPicker.Children.Count <= - searchInputChildIndex) - { - LogError( - "Currency picker search input " + - "was not found."); - - StopSellSequence(); - break; - } - - var searchInputPosition = - currencyPicker - .Children[searchInputChildIndex] - .GetClientRect() - .Center; - - if (QueueGameClick(searchInputPosition)) - { - SetSellSequenceStep( - SellSequenceStep - .TypeCurrencyPickerOfferedSearchQuery); - } + var searchInput = FindPickerSearchInput(currencyPicker); + if (searchInput == null) + { + LogError("The currency picker search input was not found."); + StopSellSequence(); break; } - case SellSequenceStep - .TypeCurrencyPickerOfferedSearchQuery: + if (QueueGameClick(searchInput.GetClientRect().Center)) { - if (IsGameInputBusy()) - break; - - if (!isCurrencyPickerVisible) - { - SetSellSequenceStep( - SellSequenceStep.ClickIHave); + SetSellSequenceStep( + SellSequenceStep.TypeCurrencyPickerOfferedSearchQuery); + } - break; - } + break; + } - if (QueueGameTextReplacement(itemName)) - { - SetSellSequenceStep( - SellSequenceStep - .ValidateOfferedSearchQuery); - } + case SellSequenceStep.TypeCurrencyPickerOfferedSearchQuery: + { + if (IsGameInputBusy()) + break; + if (!isCurrencyPickerVisible) + { + SetSellSequenceStep(SellSequenceStep.ClickIHave); break; } - case SellSequenceStep.ValidateOfferedSearchQuery: + if (QueueGameTextReplacement(itemName)) { + SetSellSequenceStep( + SellSequenceStep.ValidateOfferedSearchQuery); + } - if (IsGameInputBusy()) - break; - - if (!isCurrencyPickerVisible) - { - SetSellSequenceStep( - SellSequenceStep.ClickIHave); - - break; - } - - var searchInputChildIndex = - Settings - .CurrencyPickerSearchInputChildIndex - .Value; + break; + } - if (currencyPicker.Children == null || - currencyPicker.Children.Count <= - searchInputChildIndex) - { - LogError( - "Currency picker search input " + - "was not found."); + case SellSequenceStep.ValidateOfferedSearchQuery: + { + if (IsGameInputBusy()) + break; - StopSellSequence(); - break; - } + if (!isCurrencyPickerVisible) + { + SetSellSequenceStep(SellSequenceStep.ClickIHave); + break; + } - var searchText = - currencyPicker - .Children[searchInputChildIndex] - .Children[0].Text; - if (searchText == itemName) - { - SetSellSequenceStep(SellSequenceStep.WaitForCurrencyPickerOfferedSearchResults); - } + var searchInput = FindPickerSearchInput(currencyPicker); + if (searchInput == null) + { + LogError("The currency picker search input was not found."); + StopSellSequence(); break; } - case SellSequenceStep - .WaitForCurrencyPickerOfferedSearchResults: - { - if (IsGameInputBusy()) - break; - - if (!isCurrencyPickerVisible) - { - SetSellSequenceStep( - SellSequenceStep.ClickIHave); - - break; - } - - if (TimeInCurrentStep() < - TimeSpan.FromMilliseconds( - Settings - .CurrencySearchDelayMilliseconds - .Value)) - { - break; - } - - var itemPositionY = _pendingSellItem.Position.Y; - var currencyPickerOptionContainer = GameController.IngameState.IngameUi.CurrencyExchangePanel.CurrencyPicker.OptionContainer; - - if (!_pendingSellItem.IsVisible && !_pendingSellItem.IsVisibleLocal) - { - break; - } - - if (itemPositionY > currencyPickerOptionContainer.Height) - { - break; - } + var searchText = searchInput.GetChildAtIndex(0)?.Text; + if (searchText == itemName) + { SetSellSequenceStep( - SellSequenceStep.ClickOwnedItem); - - break; + SellSequenceStep.WaitForCurrencyPickerOfferedSearchResults); } - case SellSequenceStep.ClickOwnedItem: + break; + } + + case SellSequenceStep.WaitForCurrencyPickerOfferedSearchResults: + { + if (IsGameInputBusy()) + break; + + if (!isCurrencyPickerVisible) { - if (IsGameInputBusy()) - break; + SetSellSequenceStep(SellSequenceStep.ClickIHave); + break; + } - if (!isCurrencyPickerVisible) - { - SetSellSequenceStep( - SellSequenceStep.ClickOfferedItemInput); + if (TimeInCurrentStep() < + TimeSpan.FromMilliseconds( + Settings.GetRandomizedActionDelay())) + { + break; + } - break; - } + _pendingSellItem = currencyPicker.Options? + .FirstOrDefault(option => + option?.Children != null && + option.Children.Any(child => child?.Text == itemName)); - var ownedItemRect = - _pendingSellItem.GetClientRect(); + if (_pendingSellItem == null) + break; - if (ownedItemRect.Width <= 0 || - ownedItemRect.Height <= 0) - { - break; - } + _pendingSellOwnedAmount = _pendingSellItem.Owned; - if (QueueGameClick( - ownedItemRect.Center)) - { - SetSellSequenceStep( - SellSequenceStep - .WaitForOfferedCurrencyPickerToClose); - } + if (!_pendingSellItem.IsVisible && + !_pendingSellItem.IsVisibleLocal) + { + break; + } + if (_pendingSellItem.Position.Y > + currencyPicker.OptionContainer.Height) + { break; } - case SellSequenceStep - .WaitForOfferedCurrencyPickerToClose: + SetSellSequenceStep(SellSequenceStep.ClickOwnedItem); + break; + } + + case SellSequenceStep.ClickOwnedItem: + { + if (IsGameInputBusy()) + break; + + if (!isCurrencyPickerVisible) { - if (IsGameInputBusy() || - isCurrencyPickerVisible) - { - break; - } + SetSellSequenceStep( + SellSequenceStep.ClickOfferedItemInput); + break; + } - SetSellSequenceStep(SellSequenceStep.CheckIfChaosIsWanted); + if (_pendingSellItem == null) + { + SetSellSequenceStep( + SellSequenceStep.WaitForCurrencyPickerOfferedSearchResults); break; } + var ownedItemRect = _pendingSellItem.GetClientRect(); - case SellSequenceStep.CheckIfChaosIsWanted: + if (ownedItemRect.Width <= 0 || ownedItemRect.Height <= 0) + break; + + if (QueueGameClick(ownedItemRect.Center)) { - if (IsGameInputBusy()) - break; + SetSellSequenceStep( + SellSequenceStep.WaitForOfferedCurrencyPickerToClose); + } + break; + } - if (isCurrencyPickerVisible) - { - SetSellSequenceStep( - SellSequenceStep.ClickCurrencyPickerWantedSearchInput); + case SellSequenceStep.WaitForOfferedCurrencyPickerToClose: + { + if (IsGameInputBusy() || isCurrencyPickerVisible) + break; - break; - } + SetSellSequenceStep(SellSequenceStep.CheckIfChaosIsWanted); + break; + } + case SellSequenceStep.CheckIfChaosIsWanted: + { + if (IsGameInputBusy()) + break; - var iWantButtonChildIndex = Settings.IWantButtonChildIndex.Value; + if (isCurrencyPickerVisible) + { + SetSellSequenceStep( + SellSequenceStep.ClickCurrencyPickerWantedSearchInput); - if (currencyExchangePanel?.Children == null || - currencyExchangePanel.Children.Count <= - iWantButtonChildIndex) - { - LogError("I Want button not found, aborting..."); - StopSellSequence(); - break; - } + break; + } - var iWantButton = currencyExchangePanel.GetChildAtIndex(iWantButtonChildIndex); - var selectedWantedItemName = iWantButton.GetChildAtIndex(0).Text; + var selectedWantedItemName = + currencyExchangePanel.WantedItemType?.BaseName; - if (selectedWantedItemName == WantedCurrencyName) - { - debugMessages.Add( - $"{WantedCurrencyName} is already selected as wanted currency."); + if (selectedWantedItemName == WantedCurrencyName) + { + AddDebugMessage( + $"{WantedCurrencyName} is already selected " + + "as the wanted currency."); + SetSellSequenceStep(SellSequenceStep.WaitForMarketRatio); + break; + } - if (Settings.ListPriceBasedOnHighestCompetingTrade) - { - SetSellSequenceStep(SellSequenceStep.ShowMarketRatioTooltip); - } - else - { - SetSellSequenceStep( - SellSequenceStep.WaitForMarketRatio); - } + SetSellSequenceStep(SellSequenceStep.ClickIWant); + break; + } - break; - } + case SellSequenceStep.ClickIWant: + { + if (IsGameInputBusy()) + break; + if (isCurrencyPickerVisible) + { SetSellSequenceStep( - SellSequenceStep.ClickIWant); + SellSequenceStep.WaitForWantedCurrencyPicker); break; } - case SellSequenceStep.ClickIWant: + var iWantButton = FindCurrencySelectButton( + currencyExchangePanel, + wantedSide: true); + + if (iWantButton == null) { - if (IsGameInputBusy()) - break; + LogError("The \"I Want\" button was not found."); + StopSellSequence(); + break; + } - if (isCurrencyPickerVisible) - { - SetSellSequenceStep( - SellSequenceStep.WaitForWantedCurrencyPicker); + if (QueueGameClick(iWantButton.GetClientRect().Center)) + { + SetSellSequenceStep( + SellSequenceStep.WaitForWantedCurrencyPicker); + } - break; - } + break; + } - var iWantButtonChildIndex = Settings.IWantButtonChildIndex.Value; + case SellSequenceStep.WaitForWantedCurrencyPicker: + { + if (IsGameInputBusy()) + break; - if (currencyExchangePanel.Children == null || - currencyExchangePanel.Children.Count <= - iWantButtonChildIndex) - { - LogError("I Want button was not found."); - StopSellSequence(); - break; - } + if (isCurrencyPickerVisible) + { + SetSellSequenceStep( + SellSequenceStep.ClickCurrencyPickerWantedSearchInput); + } - var iWantButtonPosition = - currencyExchangePanel - .GetChildAtIndex(iWantButtonChildIndex) - .GetClientRect() - .Center; + break; + } - if (QueueGameClick(iWantButtonPosition)) - { - SetSellSequenceStep( - SellSequenceStep.WaitForWantedCurrencyPicker); - } + case SellSequenceStep.ClickCurrencyPickerWantedSearchInput: + { + if (IsGameInputBusy()) + break; + if (!isCurrencyPickerVisible) + { + SetSellSequenceStep(SellSequenceStep.ClickIWant); break; } - case SellSequenceStep.WaitForWantedCurrencyPicker: - { - if (IsGameInputBusy()) - break; + var searchInput = FindPickerSearchInput(currencyPicker); - if (isCurrencyPickerVisible) - { - SetSellSequenceStep( - SellSequenceStep.ClickCurrencyPickerWantedSearchInput); - } + if (searchInput == null) + { + LogError( + "The wanted currency picker search input was not found."); + StopSellSequence(); break; } - case SellSequenceStep.ClickCurrencyPickerWantedSearchInput: + if (QueueGameClick(searchInput.GetClientRect().Center)) { - if (IsGameInputBusy()) - break; + SetSellSequenceStep( + SellSequenceStep.TypeCurrencyPickerWantedSearchQuery); + } - if (!isCurrencyPickerVisible) - { - SetSellSequenceStep( - SellSequenceStep.ClickIWant); + break; + } - break; - } + case SellSequenceStep.TypeCurrencyPickerWantedSearchQuery: + { + if (IsGameInputBusy()) + break; - var searchInputChildIndex = Settings.CurrencyPickerSearchInputChildIndex.Value; + if (!isCurrencyPickerVisible) + { + SetSellSequenceStep(SellSequenceStep.ClickIWant); + break; + } - if (currencyPicker.Children == null || - currencyPicker.Children.Count <= - searchInputChildIndex) - { - LogError( - "Wanted currency picker search input was not found."); + if (QueueGameTextReplacement(WantedCurrencyName)) + { + SetSellSequenceStep( + SellSequenceStep.ValidateWantedSearchQuery); + } - StopSellSequence(); - break; - } + break; + } - var searchInputPosition = - currencyPicker - .GetChildAtIndex(searchInputChildIndex) - .GetClientRect() - .Center; + case SellSequenceStep.ValidateWantedSearchQuery: + { + if (IsGameInputBusy()) + break; - if (QueueGameClick(searchInputPosition)) - { - SetSellSequenceStep( - SellSequenceStep.TypeCurrencyPickerWantedSearchQuery); - } + if (!isCurrencyPickerVisible) + { + SetSellSequenceStep(SellSequenceStep.ClickIWant); + break; + } + + var searchInput = FindPickerSearchInput(currencyPicker); + + if (searchInput == null) + { + LogError( + "The wanted currency picker search input was not found."); + StopSellSequence(); break; } - case SellSequenceStep.TypeCurrencyPickerWantedSearchQuery: + if (searchInput.Children == null || + searchInput.Children.Count == 0) { - if (IsGameInputBusy()) - break; + break; + } - if (!isCurrencyPickerVisible) - { - SetSellSequenceStep( - SellSequenceStep.ClickIWant); + var searchText = searchInput.GetChildAtIndex(0).Text?.Trim(); - break; - } + if (string.Equals( + searchText, + WantedCurrencyName, + StringComparison.OrdinalIgnoreCase)) + { + SetSellSequenceStep( + SellSequenceStep.WaitForCurrencyPickerWantedSearchResults); + } - if (QueueGameTextReplacement(WantedCurrencyName)) - { - SetSellSequenceStep( - SellSequenceStep.ValidatedWantedSearchQuery); - } + break; + } + case SellSequenceStep.WaitForCurrencyPickerWantedSearchResults: + { + if (IsGameInputBusy()) + break; + + if (!isCurrencyPickerVisible) + { + SetSellSequenceStep(SellSequenceStep.ClickIWant); break; } - case SellSequenceStep.ValidatedWantedSearchQuery: + if (TimeInCurrentStep() < + TimeSpan.FromMilliseconds( + Settings.GetRandomizedActionDelay())) { - if (IsGameInputBusy()) - break; + break; + } - if (!isCurrencyPickerVisible) - { - SetSellSequenceStep( - SellSequenceStep.ClickIWant); + _pendingWantedItem = currencyPicker.Options? + .FirstOrDefault(option => + option?.Children != null && + option.Children.Any(child => + child?.Text == WantedCurrencyName)); - break; - } + if (_pendingWantedItem == null) + break; - var searchInputChildIndex = Settings.CurrencyPickerSearchInputChildIndex.Value; + var wantedItemRect = _pendingWantedItem.GetClientRect(); - if (currencyPicker.Children == null || - currencyPicker.Children.Count <= searchInputChildIndex) - { - LogError( - "Wanted currency picker search input was not found."); + if (wantedItemRect.Width <= 0 || wantedItemRect.Height <= 0) + break; - StopSellSequence(); - break; - } + var optionContainer = currencyPicker.OptionContainer; - var searchInput = - currencyPicker.GetChildAtIndex(searchInputChildIndex); + if (optionContainer != null && + !optionContainer.GetClientRect().Intersects(wantedItemRect)) + { + break; + } - if (searchInput.Children == null || - searchInput.Children.Count == 0) - { - break; - } + SetSellSequenceStep(SellSequenceStep.ClickWantedItem); + break; + } - var searchText = searchInput.GetChildAtIndex(0).Text?.Trim(); + case SellSequenceStep.ClickWantedItem: + { + if (IsGameInputBusy()) + break; - if (string.Equals( - searchText, - WantedCurrencyName, - StringComparison.OrdinalIgnoreCase)) - { - SetSellSequenceStep( - SellSequenceStep - .WaitForCurrencyPickerWantedSearchResults); - } + if (_pendingWantedItem == null) + { + SetSellSequenceStep( + SellSequenceStep.WaitForCurrencyPickerWantedSearchResults); break; } - case SellSequenceStep.WaitForCurrencyPickerWantedSearchResults: + var wantedItemRect = _pendingWantedItem.GetClientRect(); + + if (wantedItemRect.Width <= 0 || wantedItemRect.Height <= 0) + break; + + if (QueueGameClick(wantedItemRect.Center)) { - if (IsGameInputBusy()) - break; + SetSellSequenceStep( + SellSequenceStep.WaitForWantedCurrencyPickerToClose); + } - if (!isCurrencyPickerVisible) - { - SetSellSequenceStep( - SellSequenceStep.ClickIWant); + break; + } - break; - } + case SellSequenceStep.WaitForWantedCurrencyPickerToClose: + { + if (IsGameInputBusy() || isCurrencyPickerVisible) + break; - if (TimeInCurrentStep() < - TimeSpan.FromMilliseconds( - Settings.CurrencySearchDelayMilliseconds.Value)) - { - break; - } + _pendingWantedItem = null; + SetSellSequenceStep(SellSequenceStep.WaitForMarketRatio); + break; + } - _pendingWantedItem = currencyPicker.Options.Select(option => option).Where(option => option.Children.Any(child => child.Text == WantedCurrencyName)).FirstOrDefault(); + case SellSequenceStep.WaitForMarketRatio: + { + if (IsGameInputBusy()) + break; + if (TimeInCurrentStep() < + TimeSpan.FromMilliseconds( + Settings.GetRandomizedActionDelay())) + { + break; + } - var wantedItemRect = _pendingWantedItem.GetClientRect(); + var marketRatio = GetMarketRatioForPendingExchange( + currencyExchangePanel, + ownedAmount); - if (wantedItemRect.Width <= 0 || - wantedItemRect.Height <= 0) - { - break; - } + if (marketRatio == null || + marketRatio.MarketGetRate <= 0 || + marketRatio.MarketGiveRate <= 0) + { + break; + } - var optionContainer = - currencyPicker.OptionContainer; + AddDebugMessage( + $"Market ratio for {ownedAmount} of {itemName}: " + + $"{marketRatio.MarketGetRate}:{marketRatio.MarketGiveRate}"); - if (optionContainer != null) - { - var optionContainerRect = - optionContainer.GetClientRect(); + _pendingMarketRatio = marketRatio; - if (!optionContainerRect.Intersects(wantedItemRect)) - break; - } + SetSellSequenceStep(SellSequenceStep.ClickOfferedItemInput); + break; + } - SetSellSequenceStep( - SellSequenceStep.ClickWantedItem); + case SellSequenceStep.ClickOfferedItemInput: + { + if (IsGameInputBusy()) + break; + if (offeredItemCountInput == null) + { + LogError("The offered item count input was not found."); + StopSellSequence(); break; } - case SellSequenceStep.ClickWantedItem: + if (QueueGameClick( + offeredItemCountInput.GetClientRect().Center)) { - if (IsGameInputBusy()) - break; + SetSellSequenceStep( + SellSequenceStep.TypeOfferedItemValue); + } + break; + } - if (_pendingWantedItem == null) - { - SetSellSequenceStep( - SellSequenceStep - .WaitForCurrencyPickerWantedSearchResults); + case SellSequenceStep.TypeOfferedItemValue: + { + if (IsGameInputBusy()) + break; - break; - } + var offeredValue = + ownedAmount.ToString(CultureInfo.InvariantCulture); - var wantedItemRect = _pendingWantedItem.GetClientRect(); + if (QueueGameTextReplacement(offeredValue)) + { + SetSellSequenceStep( + SellSequenceStep.ClickWantedItemInput); + } - if (wantedItemRect.Width <= 0 || - wantedItemRect.Height <= 0) - { - break; - } + break; + } - if (QueueGameClick( - wantedItemRect.Center)) - { - SetSellSequenceStep( - SellSequenceStep - .WaitForWantedCurrencyPickerToClose); - } + case SellSequenceStep.ClickWantedItemInput: + { + if (IsGameInputBusy()) + break; + if (isCurrencyPickerVisible) + { + SetSellSequenceStep(SellSequenceStep.Start); break; } - case SellSequenceStep.WaitForWantedCurrencyPickerToClose: + if (wantedItemCountInput == null) + { + LogError("The wanted item count input was not found."); + StopSellSequence(); + break; + } + + if (QueueGameClick( + wantedItemCountInput.GetClientRect().Center)) { - if (IsGameInputBusy()) - break; + SetSellSequenceStep( + SellSequenceStep.TypeWantedItemValue); + } - if (isCurrencyPickerVisible) - break; + break; + } - _pendingWantedItem = null; + case SellSequenceStep.TypeWantedItemValue: + { + if (IsGameInputBusy()) + break; + var wantedValue = CalculateWantedAmount( + ownedAmount, + _pendingMarketRatio.MarketGetRate, + _pendingMarketRatio.MarketGiveRate, + Settings.ListingPricePercent.Value); - if (Settings.ListPriceBasedOnHighestCompetingTrade) - { - SetSellSequenceStep( - SellSequenceStep.ShowMarketRatioTooltip); - } - else - { - SetSellSequenceStep( - SellSequenceStep.WaitForMarketRatio); - } + if (wantedValue <= 0) + { + LogError( + $"Invalid market ratio: " + + $"{_pendingMarketRatio.MarketGetRate}:" + + $"{_pendingMarketRatio.MarketGiveRate}"); + StopSellSequence(); break; } + AddDebugMessage( + $"Pricing {ownedAmount} {itemName}: " + + $"market={_pendingMarketRatio.MarketGetRate}:" + + $"{_pendingMarketRatio.MarketGiveRate}, " + + $"wanted={wantedValue}"); - case SellSequenceStep.ShowMarketRatioTooltip: + if (QueueGameTextReplacement( + wantedValue.ToString(CultureInfo.InvariantCulture))) { - if (IsGameInputBusy()) - break; + SetSellSequenceStep(SellSequenceStep.BlurInput); + } - debugMessages.Add( - $"Showing market ratio tooltip for " + - $"{ownedAmount} of {itemName}..."); + break; + } - var marketRatioPanelChildIndex = Settings.MarketRatioPanelIndex.Value; - var marketRatioPanel = currencyExchangePanel.GetChildAtIndex(marketRatioPanelChildIndex); + case SellSequenceStep.BlurInput: + { + if (IsGameInputBusy()) + break; + + var ratioElement = currencyExchangePanel.RatioElement; + + if (ratioElement == null) + { + LogError("The market ratio element was not found."); + StopSellSequence(); + break; + } + + if (QueueGameClick(ratioElement.GetClientRect().Center)) + { + AddDebugMessage( + "Blurring the input to lock in the ratio..."); + SetSellSequenceStep( + SellSequenceStep.CheckIfSellButtonIsActive); + } + + break; + } + + case SellSequenceStep.CheckIfSellButtonIsActive: + { + if (IsGameInputBusy()) + break; + + var sellButton = FindSellButton(currencyExchangePanel); + + if (sellButton == null) + { + LogError("The sell button was not found."); + StopSellSequence(); + break; + } + if (sellButton.IsActive) + SetSellSequenceStep(SellSequenceStep.ClickSellButton); - if (QueueGameMove(marketRatioPanel.GetClientRect().Center)) - { - { - Thread.Sleep( - Settings.MouseSettleDelayMilliseconds.Value); + break; + } - SetSellSequenceStep( - SellSequenceStep.ShowDetailedMarketRatioTooltip); - break; - } - } + case SellSequenceStep.ClickSellButton: + { + if (IsGameInputBusy()) + break; + if (isCurrencyPickerVisible) + { + SetSellSequenceStep(SellSequenceStep.Start); break; } - case SellSequenceStep.ShowDetailedMarketRatioTooltip: + var sellButton = FindSellButton(currencyExchangePanel); + + if (sellButton == null) { - if (IsGameInputBusy()) - break; + LogError("The sell button was not found."); + StopSellSequence(); + break; + } - debugMessages.Add( - $"Showing detailed market ratio tooltip for " + - $"{ownedAmount} of {itemName}..."); + if (QueueGameClick(sellButton.GetClientRect().Center)) + { + AddDebugMessage( + $"Completed the sell sequence for " + + $"{ownedAmount} of {itemName}."); - var marketRatioPanelChildIndex = Settings.MarketRatioPanelIndex.Value; - var marketRatioPanel = currencyExchangePanel.GetChildAtIndex(marketRatioPanelChildIndex); - var marketRatioPanelTooltip = marketRatioPanel?.Tooltip; + SetSellSequenceStep( + SellSequenceStep.CheckUnfavorableTrade); + } - if (marketRatioPanelTooltip == null) - { - break; - } + break; + } - if (!_isAltKeyDown) - { - KeyboardInput.Keyboard.AltKeyDown(); - debugMessages.Add($"Pressing Alt to show detailed market ratio info"); + case SellSequenceStep.CheckUnfavorableTrade: + { + SetSellSequenceStep(SellSequenceStep.ReopenIHave); + break; + } - _isAltKeyDown = true; - } + case SellSequenceStep.ReopenIHave: + { + if (IsGameInputBusy()) + break; + if (isCurrencyPickerVisible) + { SetSellSequenceStep( - SellSequenceStep.WaitForDetailedMarketRatioInfo); + SellSequenceStep.WaitForOfferedCurrencyPicker); + break; } - case SellSequenceStep.WaitForDetailedMarketRatioInfo: + var iHaveButton = FindCurrencySelectButton( + currencyExchangePanel, + wantedSide: false); + + if (iHaveButton == null) { - if (IsGameInputBusy()) - break; + LogError("The \"I Have\" button was not found."); + StopSellSequence(); + break; + } - if (TimeInCurrentStep() < - TimeSpan.FromMilliseconds( - Settings.MarketRatioDelayMilliseconds.Value)) - { - break; - } + if (QueueGameClick(iHaveButton.GetClientRect().Center)) + SetSellSequenceStep(SellSequenceStep.End); - debugMessages.Add( - $"Waiting for detailed market ratio info for " + - $"{ownedAmount} of {itemName}..."); + break; + } - var marketRatioPanelChildIndex = Settings.MarketRatioPanelIndex.Value; - var marketRatioPanel = currencyExchangePanel.GetChildAtIndex(marketRatioPanelChildIndex); - var marketRatioPanelTooltip = marketRatioPanel?.Tooltip; + case SellSequenceStep.End: + { + if (IsGameInputBusy()) + break; + if (_sellQueue.Count > 0 && !isCurrencyPickerVisible) + break; - if (!marketRatioPanelTooltip.Children.Any(child => child.Text == "Competing Trades")) - { - break; - } + CompleteCurrentSellItem(); + break; + } + } + } - _pendingMarketRatio = GetMarketRatioForPendingExchange(currencyExchangePanel); + private void StartSellSequence( + CurrencyExchangeCurrencyPickerCurrencyOption item) + { + if (item == null) + return; - if (_isAltKeyDown) - { - KeyboardInput.Keyboard.AltKeyUp(); - _isAltKeyDown = false; - } + StartSellSequenceByName(GetItemName(item)); + } - if (_pendingMarketRatio == null || _pendingMarketRatio.MarketGetRate <= 0 || _pendingMarketRatio.MarketGiveRate <= 0) - { - break; - } + /// + /// Starts the sell sequence for an item identified only by name; the + /// live picker option element is resolved during the sequence. + /// + private void StartSellSequenceByName(string itemName) + { + if (string.IsNullOrWhiteSpace(itemName)) + return; - debugMessages.Add( - $"Market ratio for {ownedAmount} of {itemName}: " + - $"{_pendingMarketRatio.MarketGetRate}:{_pendingMarketRatio.MarketGiveRate}"); + if (_sellSequenceStep != SellSequenceStep.Idle) + { + AddDebugMessage("A sell sequence is already running."); + return; + } - SetSellSequenceStep( - SellSequenceStep.ClickOfferedItemInput); - break; - } + if (_collectSequenceActive) + { + AddDebugMessage( + "The collect sequence is running; " + + "not starting a sell sequence."); - case SellSequenceStep.WaitForMarketRatio: - { + return; + } - if (IsGameInputBusy()) - break; + BeginInputSession(); - if (TimeInCurrentStep() < - TimeSpan.FromMilliseconds( - Settings - .MarketRatioDelayMilliseconds - .Value)) - { - break; - } + _pendingSellItem = null; + _pendingSellItemName = itemName; + _pendingSellOwnedAmount = 0; - var marketRatio = GetMarketRatioForPendingExchange(currencyExchangePanel); + AddDebugMessage($"Started the sell sequence for {itemName}."); - var marketRateGive = currencyExchangePanel.MarketRateGive; + SetSellSequenceStep(SellSequenceStep.Start); + } - if (marketRatio.MarketGetRate <= 0 || - marketRatio.MarketGiveRate <= 0) - { - break; - } + /// + /// Stores the cursor position (window-relative, so the restore lands on + /// the original spot after adding the window offset) and brings the + /// game window to the foreground. + /// + private void BeginInputSession() + { + if (!_inputSessionActive) + { + _inputSessionActive = true; - debugMessages.Add( - $"Market ratio for {ownedAmount} of {itemName}: " + - $"{marketRatio.MarketGetRate}:{marketRatio.MarketGiveRate}"); + var cursorPosition = MouseInput.Mouse.GetCursorPosition(); - _pendingMarketRatio = marketRatio; + var windowTopLeft = GameController.Window + .GetWindowRectangleReal() + .Location; + _storedMousePosition = new NumVector2( + cursorPosition.X - windowTopLeft.X, + cursorPosition.Y - windowTopLeft.Y); - SetSellSequenceStep( - SellSequenceStep.ClickOfferedItemInput); + AddDebugMessage( + $"Stored the mouse position at {_storedMousePosition}."); + } - break; - } + if (!GameController.Window.IsForeground()) + { + var focused = WinApi.SetForegroundWindow( + GameController.Window.Process.MainWindowHandle); - case SellSequenceStep.ClickOfferedItemInput: - { - if (IsGameInputBusy()) - break; + AddDebugMessage($"Focused the game window: {focused}"); + } + } + /// + /// Runs once the sell queue and the collect sequence are fully drained: + /// restores the mouse position (waiting a frame for the move to + /// complete) and then releases the InputHumanizer controller. + /// + private void EndInputSessionIfNeeded() + { + if (!_inputSessionActive) + return; - if (offeredItemCountInput == null) - { - LogError( - "Offered item count input was not found."); + if (_sellSequenceStep != SellSequenceStep.Idle || + _sellQueue.Count > 0 || + _collectSequenceActive || + IsGameInputBusy()) + { + return; + } - StopSellSequence(); - break; - } + if (Settings.RestoreMousePosition && + _storedMousePosition != default) + { + var restorePosition = _storedMousePosition; + _storedMousePosition = default; - var offeredInputPosition = offeredItemCountInput.GetClientRect().Center; + QueueGameMove( + new Vector2(restorePosition.X, restorePosition.Y)); - if (QueueGameClick(offeredInputPosition)) - { - SetSellSequenceStep( - SellSequenceStep - .TypeOfferedItemValue); - } + return; + } - break; - } + _storedMousePosition = default; + _inputSessionActive = false; + _releaseInputControlPending = true; + } - case SellSequenceStep.TypeOfferedItemValue: - { - if (IsGameInputBusy()) - break; + private void StartMarkedSellBatch( + CurrencyExchangePanel currencyExchangePanel) + { + if (_sellSequenceStep != SellSequenceStep.Idle || + _collectSequenceActive || + _markedItemNames.Count == 0) + { + return; + } + + var freeSlots = GetFreeTradeSlots(currencyExchangePanel); + + foreach (var itemName in _markedItemNames.Take(freeSlots)) + _sellQueue.Enqueue(itemName); + + AddDebugMessage( + $"Queued {_sellQueue.Count} marked item(s) for selling " + + $"({freeSlots} free trade slot(s))."); + + _markedItemNames.Clear(); + } + + private void TryStartNextQueuedSell( + CurrencyExchangePanel currencyExchangePanel) + { + if (_sellSequenceStep != SellSequenceStep.Idle || + _sellQueue.Count == 0 || + _collectSequenceActive || + IsGameInputBusy()) + { + return; + } + + if (GetFreeTradeSlots(currencyExchangePanel) <= 0) + { + LogError( + "There are no free trade slots left; " + + "dropping the remaining sell queue."); + + _sellQueue.Clear(); + return; + } + + StartSellSequenceByName(_sellQueue.Dequeue()); + } + + private int GetSlotsInUse( + CurrencyExchangePanel currencyExchangePanel) + { + try + { + return currencyExchangePanel?.Orders?.Count ?? 0; + } + catch (Exception ex) + { + LogError($"Failed to read the placed orders: {ex}"); + return 0; + } + } + + private int GetFreeTradeSlots( + CurrencyExchangePanel currencyExchangePanel) + { + return Math.Max( + 0, + Settings.MaxConcurrentTrades.Value - + GetSlotsInUse(currencyExchangePanel)); + } + + /// + /// A placed order has something to pick up when wanted currency has + /// accrued, or when the order is done (completed with a leftover from + /// ratio rounding, or canceled) and offered items await pickup in the + /// selling slot. + /// + private static bool IsOrderCollectible( + PlacedCurrencyExchangeOrder order) + { + if (order == null) + return false; + + return order.WantedItemStackSize > 0 || + ((order.IsCompleted || order.IsCanceled) && + order.OfferedItemStackSize > 0); + } + + private int GetCollectibleOrderCount( + CurrencyExchangePanel currencyExchangePanel) + { + try + { + return currencyExchangePanel? + .Orders? + .Count(IsOrderCollectible) + ?? 0; + } + catch (Exception ex) + { + LogError($"Failed to read the collectible orders: {ex}"); + return 0; + } + } + + private void StartCollectSequence() + { + if (_sellSequenceStep != SellSequenceStep.Idle || + _sellQueue.Count > 0 || + _collectSequenceActive) + { + return; + } + + BeginInputSession(); + + _collectSequenceActive = true; + _nextCollectActionUtc = DateTime.MinValue; + _lastCollectOrderId = -1; + _lastCollectRemaining = -1; + _collectRetryCount = 0; + + AddDebugMessage("Started the collect sequence."); + } + + private void StopCollectSequence(string reason) + { + if (!_collectSequenceActive) + return; + + _collectSequenceActive = false; + + AddDebugMessage($"The collect sequence stopped: {reason}."); + } + + /// + /// Collects one order per pass, verifying inventory space first. The + /// configured collect delay applies with and without InputHumanizer + /// because of trade rate limits, and collection waits while the + /// currency picker covers the orders area. Aborts when repeated clicks + /// on the same order change nothing. + /// + private void ProcessCollectSequence( + CurrencyExchangePanel currencyExchangePanel) + { + if (!_collectSequenceActive) + return; - var offeredValue = - ownedAmount.ToString( - CultureInfo.InvariantCulture); + if (IsGameInputBusy()) + return; - if (QueueGameTextReplacement(offeredValue)) - { - SetSellSequenceStep( - SellSequenceStep - .ClickWantedItemInput); - } + if (DateTime.UtcNow < _nextCollectActionUtc) + return; - break; - } + if (currencyExchangePanel.CurrencyPicker?.IsVisible == true) + return; + List orders; + List orderElements; + try + { + orders = currencyExchangePanel.Orders; + orderElements = currencyExchangePanel.OrderElements; + } + catch (Exception ex) + { + LogError($"Failed to read the placed orders: {ex}"); + StopCollectSequence("the orders could not be read"); + return; + } - case SellSequenceStep.ClickWantedItemInput: - { - if (IsGameInputBusy()) - break; + if (orders == null || orderElements == null) + { + StopCollectSequence("the orders are unavailable"); + return; + } - if (isCurrencyPickerVisible) - { - SetSellSequenceStep( - SellSequenceStep.Start); + var inventory = GetMainInventory(); - break; - } + if (inventory == null) + { + LogError( + "The player inventory is unavailable; " + + "collection space cannot be verified."); - if (wantedItemCountInput == null) - { - LogError( - "Wanted item count input was not found."); + StopCollectSequence("the player inventory is unavailable"); + return; + } - StopSellSequence(); - break; - } + var freeInventoryCells = GetFreeInventoryCells(inventory); + var skippedForInventorySpace = false; - var wantedInputPosition = - wantedItemCountInput - .GetClientRect() - .Center; + var orderCount = Math.Min(orders.Count, orderElements.Count); - if (QueueGameClick(wantedInputPosition)) - { - SetSellSequenceStep( - SellSequenceStep - .TypeWantedItemValue); - } + for (var index = 0; index < orderCount; index++) + { + var order = orders[index]; - break; - } + if (!IsOrderCollectible(order)) + continue; - case SellSequenceStep.TypeWantedItemValue: - { - if (IsGameInputBusy()) - break; + var buyingSide = order.WantedItemStackSize > 0; + var collectItemType = buyingSide + ? order.WantedItemType + : order.OfferedItemType; - debugMessages.Add( - $"Calculating wanted value for " + - $"{ownedAmount} of {itemName}..."); + var collectAmount = buyingSide + ? order.WantedItemStackSize + : order.OfferedItemStackSize; - debugMessages.Add( - $"Market ratio: " + - $"{_pendingMarketRatio.MarketGetRate}:{_pendingMarketRatio.MarketGiveRate}"); + var requiredCells = GetRequiredInventoryCells( + inventory, + collectItemType, + collectAmount); - var wantedValue = - CalculateWantedAmount( - ownedAmount, - _pendingMarketRatio.MarketGetRate, - _pendingMarketRatio.MarketGiveRate, - Settings.ListingPricePercent.Value); + if (requiredCells > freeInventoryCells) + { + AddDebugMessage( + $"Not enough inventory space for order " + + $"{order.PlayerOrderId} " + + $"({collectItemType?.BaseName} x{collectAmount}): " + + $"{requiredCells} cell(s) needed, " + + $"{freeInventoryCells} free; skipping it."); + + skippedForInventorySpace = true; + continue; + } - if (wantedValue <= 0) - { - LogError( - $"Invalid market ratio: " + - $"{_pendingMarketRatio.MarketGetRate}:{_pendingMarketRatio.MarketGiveRate}"); + var collectSlot = FindOrderCollectSlot( + orderElements[index], + buyingSide); - StopSellSequence(); - break; - } + var collectSlotRect = + collectSlot?.GetClientRect() ?? default; - debugMessages.Add( - $"Pricing {ownedAmount} {itemName}: " + - $"market={_pendingMarketRatio.MarketGetRate}:{_pendingMarketRatio.MarketGiveRate}, " + - $"wanted={wantedValue}"); + if (collectSlot?.IsVisible != true || + collectSlotRect.Width <= 0 || + collectSlotRect.Height <= 0) + { + continue; + } - if (QueueGameTextReplacement( - wantedValue.ToString( - CultureInfo.InvariantCulture))) - { - SetSellSequenceStep( - SellSequenceStep.BlurInput); - } + var remaining = buyingSide + ? order.WantedItemStackSize + : order.OfferedItemStackSize; - break; - } + if (order.PlayerOrderId == _lastCollectOrderId && + remaining == _lastCollectRemaining) + { + _collectRetryCount++; - case SellSequenceStep.BlurInput: + if (_collectRetryCount >= 3) { - if (IsGameInputBusy()) - break; - - var villageGold = currencyExchangePanel.GetChildAtIndex(15); - var villageGoldPosition = villageGold.GetClientRect().Center; - - if (QueueGameClick(villageGoldPosition)) - { - debugMessages.Add($"Blurring input to lock in ratio... "); + LogError( + "Collecting is not making progress; stopping."); - SetSellSequenceStep( - SellSequenceStep.CheckIfSellButtonIsActive); - } - - break; + StopCollectSequence("no progress was being made"); + return; } + } + else + { + _lastCollectOrderId = order.PlayerOrderId; + _lastCollectRemaining = remaining; + _collectRetryCount = 0; + } - case SellSequenceStep.CheckIfSellButtonIsActive: - { - if (IsGameInputBusy()) - break; + if (QueueGameCtrlRightClick(collectSlotRect.Center)) + { + AddDebugMessage( + $"Collecting order {order.PlayerOrderId} " + + $"({collectItemType?.BaseName} x{collectAmount})."); + _nextCollectActionUtc = + DateTime.UtcNow.AddMilliseconds( + Settings.CollectDelayMilliseconds.Value); + } - var sellButtonChildIndex = - Settings.SellButtonChildIndex.Value; + return; + } - if (currencyExchangePanel.Children == null || - currencyExchangePanel.Children.Count <= - sellButtonChildIndex) - { - LogError("Sell button was not found."); - StopSellSequence(); - break; - } + if (skippedForInventorySpace) + { + LogError( + "There is not enough inventory space to collect " + + "the remaining orders; stopping."); - var sellButton = currencyExchangePanel.GetChildAtIndex(sellButtonChildIndex); + StopCollectSequence("there is not enough inventory space"); + return; + } - if (sellButton.IsActive) - { - SetSellSequenceStep(SellSequenceStep.ClickSellButton); - } + StopCollectSequence("all orders were collected"); + } - break; - } + private ServerInventory GetMainInventory() + { + try + { + return GameController? + .Game? + .IngameState? + .Data? + .ServerData? + .PlayerInventories? + .FirstOrDefault()? + .Inventory; + } + catch (Exception ex) + { + LogError($"Failed to read the player inventory: {ex}"); + return null; + } + } - case SellSequenceStep.ClickSellButton: - { - if (IsGameInputBusy()) - break; - - if (isCurrencyPickerVisible) - { - SetSellSequenceStep( - SellSequenceStep.Start); - - break; - } - - var sellButtonChildIndex = - Settings.SellButtonChildIndex.Value; - - if (currencyExchangePanel.Children == null || - currencyExchangePanel.Children.Count <= - sellButtonChildIndex) - { - LogError("Sell button was not found."); - StopSellSequence(); - break; - } - - var sellButtonPosition = - currencyExchangePanel - .Children[sellButtonChildIndex] - .GetClientRect() - .Center; - - if (QueueGameClick(sellButtonPosition)) - { - debugMessages.Add( - $"Sell sequence completed for " + - $"{ownedAmount} of {itemName}."); - - SetSellSequenceStep( - SellSequenceStep.CheckUnfavorableTrade); - } + private static int GetFreeInventoryCells(ServerInventory inventory) + { + if (inventory == null || + inventory.Rows <= 0 || + inventory.Columns <= 0) + { + return 0; + } - break; - } + var occupied = new bool[inventory.Rows, inventory.Columns]; - case SellSequenceStep.CheckUnfavorableTrade: - { - //Check unfavorable trade window here... + foreach (var slotItem in inventory.InventorySlotItems ?? []) + { + if (slotItem == null) + continue; - SetSellSequenceStep( - SellSequenceStep.ReopenIHave); + var startX = Math.Max(0, slotItem.PosX); + var startY = Math.Max(0, slotItem.PosY); - break; - } + var endX = Math.Min( + inventory.Columns, + slotItem.PosX + slotItem.SizeX); - case SellSequenceStep.ReopenIHave: - { - if (IsGameInputBusy()) - break; + var endY = Math.Min( + inventory.Rows, + slotItem.PosY + slotItem.SizeY); - if (isCurrencyPickerVisible) - { - SetSellSequenceStep( - SellSequenceStep.WaitForOfferedCurrencyPicker); + for (var y = startY; y < endY; y++) + for (var x = startX; x < endX; x++) + occupied[y, x] = true; + } - break; - } + var freeCells = 0; - var iHaveButtonChildIndex = - Settings.IHaveButtonChildIndex.Value; + for (var y = 0; y < inventory.Rows; y++) + for (var x = 0; x < inventory.Columns; x++) + if (!occupied[y, x]) + freeCells++; - if (currencyExchangePanel.Children == null || - currencyExchangePanel.Children.Count <= - iHaveButtonChildIndex) - { - LogError("I Have button was not found."); - StopSellSequence(); - break; - } + return freeCells; + } - var iHaveButtonPosition = - currencyExchangePanel - .Children[iHaveButtonChildIndex] - .GetClientRect() - .Center; + /// + /// Returns the inventory cells needed to fit the given amount of a + /// currency, counting free space in existing partial stacks of the + /// same type first. Currency items occupy one cell each. + /// + private static int GetRequiredInventoryCells( + ServerInventory inventory, + BaseItemType itemType, + int amount) + { + if (itemType == null || amount <= 0) + return 0; - if (QueueGameClick(iHaveButtonPosition)) - SetSellSequenceStep(SellSequenceStep.End); + var maxStackSize = 0; + var partialStackCapacity = 0; - break; - } - case SellSequenceStep.End: - { - if (IsGameInputBusy()) - break; + foreach (var slotItem in inventory?.InventorySlotItems ?? []) + { + var itemEntity = slotItem?.Item; - if (Settings.RestoreMousePosition) { QueueGameMove(storedMousePosition); storedMousePosition = new Point(); } - StopSellSequence(); - break; - } - } - } + if (itemEntity?.Path != itemType.Metadata) + continue; - private void StartSellSequence( - CurrencyExchangeCurrencyPickerCurrencyOption item) - { - if (item == null) - return; + var stack = itemEntity.GetComponent(); + var itemMaxStackSize = stack?.Info?.MaxStackSize ?? 0; + if (itemMaxStackSize <= 0) + continue; + maxStackSize = itemMaxStackSize; - if (_sellSequenceStep != SellSequenceStep.Idle) - { - debugMessages.Add("A sell sequence is already running."); - return; + partialStackCapacity += + Math.Max(0, itemMaxStackSize - stack.Size); } - if (!GameController.Window.IsForeground()) + if (maxStackSize <= 0) { - var focused = - WinApi.SetForegroundWindow( - GameController.Window.Process.MainWindowHandle); - debugMessages.Add($"Focused PoE: {focused}"); + try + { + maxStackSize = itemType.CurrencyInfo?.MaxStackSize ?? 0; + } + catch + { + } } - _pendingSellItem = item; - _pendingSellItemName = GetItemName(item); - _pendingSellOwnedAmount = item.Owned; + if (maxStackSize <= 0) + maxStackSize = DefaultCurrencyMaxStackSize; - debugMessages.Add( - $"Started sell sequence for {_pendingSellItemName}, " + - $"owned amount: {_pendingSellOwnedAmount}."); + var remainingAmount = amount - partialStackCapacity; - SetSellSequenceStep(SellSequenceStep.Start); + if (remainingAmount <= 0) + return 0; + + return (remainingAmount + maxStackSize - 1) / maxStackSize; } private static string GetItemName( @@ -1736,28 +2205,19 @@ private static string GetItemName( ?? "Unknown"; } - private static string GetItemSearchText( - CurrencyExchangeCurrencyPickerCurrencyOption item) - { - return item? - .Children? - .FirstOrDefault()? - .Text - ?? item?.ToString() - ?? string.Empty; - } - - private void SetSellSequenceStep( - SellSequenceStep step) + private void SetSellSequenceStep(SellSequenceStep step) { _sellSequenceStep = step; _sellSequenceStepStartedUtc = DateTime.UtcNow; - debugMessages.Add( - $"Sell sequence step: {step}"); + AddDebugMessage($"Sell sequence step: {step}"); } - private void StopSellSequence() + /// + /// Finishes the current item but keeps the batch alive so the next + /// queued item can start. + /// + private void CompleteCurrentSellItem() { _sellSequenceStep = SellSequenceStep.Idle; @@ -1767,246 +2227,152 @@ private void StopSellSequence() _pendingSellItemName = string.Empty; _pendingSellOwnedAmount = 0; _pendingMarketRatio = null; - _isAltKeyDown = false; + } + /// + /// Hard stop: aborts the current item and drops the remaining queue. + /// then restores the mouse and + /// releases the InputHumanizer controller. + /// + private void StopSellSequence() + { + CompleteCurrentSellItem(); + _sellQueue.Clear(); } private TimeSpan TimeInCurrentStep() { - return DateTime.UtcNow - - _sellSequenceStepStartedUtc; + return DateTime.UtcNow - _sellSequenceStepStartedUtc; } private bool IsGameInputBusy() { - return Volatile.Read( - ref _inputInProgress) != 0; + return _inputTask != null; } - private bool QueueGameTextReplacement( - string text) + /// + /// Converts a window-relative position to a screen position by adding + /// the game window's top-left offset. + /// + private NumVector2 ToScreenPosition(Vector2 windowRelativePosition) + { + return (windowRelativePosition + + GameController.Window + .GetWindowRectangleReal() + .Location) + .ToVector2Num(); + } + + private bool QueueGameTextReplacement(string text) { if (text == null) return false; - var overlayHideMilliseconds = - Settings.TextOverlayHideMilliseconds.Value; - - var releaseDelayMilliseconds = - Settings.TextReleaseDelayMilliseconds.Value; - - var preFocusDelayMilliseconds = - Settings.TextPreFocusDelayMilliseconds.Value; - - var postFocusDelayMilliseconds = - Settings.TextPostFocusDelayMilliseconds.Value; - return QueueGameInput( - overlayHideMilliseconds, - releaseDelayMilliseconds, "Game keyboard input failed", - gameWindowHandle => + async () => { - Thread.Sleep( - preFocusDelayMilliseconds); - - + var typed = await Input.ReplaceText(text); + AddDebugMessage( + $"Replaced the input text with \"{text}\": {typed}"); - Thread.Sleep( - postFocusDelayMilliseconds); - - var typed = - KeyboardInput.Keyboard - .ReplaceText(text); - - debugMessages.Add( - $"Replaced input text with " + - $"\"{text}\": {typed}"); + return typed; }); } - private bool QueueGameClick( - Vector2 screenPosition) + private bool QueueGameClick(Vector2 windowRelativePosition) { - - screenPosition = screenPosition + GameController.Window.GetWindowRectangleReal().Location; - - var overlayHideMilliseconds = - Settings.ClickOverlayHideMilliseconds.Value; - - var releaseDelayMilliseconds = - Settings.ClickReleaseDelayMilliseconds.Value; - - var preFocusDelayMilliseconds = - Settings.ClickPreFocusDelayMilliseconds.Value; - - var postFocusDelayMilliseconds = - Settings.ClickPostFocusDelayMilliseconds.Value; - - var mouseSettleDelayMilliseconds = - Settings.MouseSettleDelayMilliseconds.Value; - - var mouseButtonHoldMilliseconds = - Settings.MouseButtonHoldMilliseconds.Value; - - + var targetPosition = ToScreenPosition(windowRelativePosition); return QueueGameInput( - overlayHideMilliseconds, - releaseDelayMilliseconds, "Game click failed", - gameWindowHandle => + async () => { - Thread.Sleep( - preFocusDelayMilliseconds); - + var clicked = await Input.Click(targetPosition); + AddDebugMessage( + $"Clicked the game window: {clicked}, " + + $"position: {targetPosition}"); - - - Thread.Sleep( - postFocusDelayMilliseconds); - - var moved = - MouseInput.Mouse.MoveMouse( - screenPosition); - - debugMessages.Add( - $"Moved mouse: {moved}, " + - $"position: {screenPosition}"); - - Thread.Sleep( - mouseSettleDelayMilliseconds); - - var clicked = - MouseInput.Mouse.LeftClick( - mouseButtonHoldMilliseconds); - - debugMessages.Add( - $"Clicked PoE: {clicked}"); + return clicked; }); } - private bool QueueGameMove( - Vector2 screenPosition) + private bool QueueGameCtrlRightClick(Vector2 windowRelativePosition) { - - screenPosition = screenPosition + GameController.Window.GetWindowRectangleReal().Location; - - var overlayHideMilliseconds = - Settings.ClickOverlayHideMilliseconds.Value; - - var releaseDelayMilliseconds = - Settings.ClickReleaseDelayMilliseconds.Value; - - var preFocusDelayMilliseconds = - Settings.ClickPreFocusDelayMilliseconds.Value; - - var postFocusDelayMilliseconds = - Settings.ClickPostFocusDelayMilliseconds.Value; - - var mouseSettleDelayMilliseconds = - Settings.MouseSettleDelayMilliseconds.Value; - - var mouseButtonHoldMilliseconds = - Settings.MouseButtonHoldMilliseconds.Value; - - + var targetPosition = ToScreenPosition(windowRelativePosition); return QueueGameInput( - overlayHideMilliseconds, - releaseDelayMilliseconds, - "Game click failed", - gameWindowHandle => + "Game Ctrl+right-click failed", + async () => { - Thread.Sleep( - preFocusDelayMilliseconds); - - - + var clicked = await Input.CtrlRightClick(targetPosition); + AddDebugMessage( + $"Ctrl+right-clicked the game window: {clicked}, " + + $"position: {targetPosition}"); + return clicked; + }); + } - Thread.Sleep( - postFocusDelayMilliseconds); - - var moved = - MouseInput.Mouse.MoveMouse( - screenPosition); + private bool QueueGameMove(Vector2 windowRelativePosition) + { + var targetPosition = ToScreenPosition(windowRelativePosition); - debugMessages.Add( - $"Moved mouse: {moved}, " + - $"position: {screenPosition}"); + return QueueGameInput( + "Game mouse move failed", + async () => + { + var moved = await Input.MoveMouse(targetPosition); - Thread.Sleep( - mouseSettleDelayMilliseconds); + AddDebugMessage( + $"Moved the mouse: {moved}, " + + $"position: {targetPosition}"); + return moved; }); } + /// + /// Starts the input as a that + /// pumps to completion; + /// reports true until then. Returns + /// false when another input is still running. + /// private bool QueueGameInput( - int overlayHideMilliseconds, - int releaseDelayMilliseconds, string errorMessage, - Action inputAction) + Func> inputTaskFactory) { - if (Interlocked.CompareExchange( - ref _inputInProgress, - 1, - 0) != 0) - { + if (_inputTask != null) return false; - } - var gameWindowHandle = - GameController? - .Window? - .Process? - .MainWindowHandle - ?? IntPtr.Zero; + _inputTask = RunGuardedInput(errorMessage, inputTaskFactory); - if (gameWindowHandle == IntPtr.Zero) - { - Interlocked.Exchange( - ref _inputInProgress, - 0); - - LogError( - "Path of Exile window handle is unavailable."); + return true; + } - return false; + private async SyncTask RunGuardedInput( + string errorMessage, + Func> inputTaskFactory) + { + try + { + return await inputTaskFactory(); } - - _hideOverlayUntilUtc = - DateTime.UtcNow.AddMilliseconds( - overlayHideMilliseconds); - - _ = Task.Run(() => + catch (Exception ex) { - try - { - inputAction(gameWindowHandle); - } - catch (Exception ex) - { - LogError( - $"{errorMessage}: {ex}"); - } - finally - { - Thread.Sleep( - releaseDelayMilliseconds); - - Interlocked.Exchange( - ref _inputInProgress, - 0); - } - }); - - return true; + LogError($"{errorMessage}: {ex}"); + return false; + } } + /// + /// Calculates the wanted-currency amount to request for the offered + /// amount at the given ratio, scaled by the listing price percentage + /// and rounded down to a minimum of 1. + /// private static int CalculateWantedAmount( int offeredAmount, int marketRateGet, @@ -2021,17 +2387,13 @@ private static int CalculateWantedAmount( return 0; } - var priceMultiplier = - listingPricePercent / 100d; + var priceMultiplier = listingPricePercent / 100d; var exactWantedAmount = - offeredAmount * - (marketRateGet * priceMultiplier) / + offeredAmount * (marketRateGet * priceMultiplier) / marketRateGive; - return Math.Max( - 1, - (int)Math.Floor(exactWantedAmount)); + return Math.Max(1, (int)Math.Floor(exactWantedAmount)); } private bool TryGetNinjaValue( @@ -2040,17 +2402,10 @@ private bool TryGetNinjaValue( { chaosValue = 0; - var itemType = - option?.ItemType; + var itemType = option?.ItemType; if (itemType == null) - { - // LogError( - // $"No BaseItemType found for " + - // $"{GetItemName(option)}."); - return false; - } _getNinjaBaseItemTypeValue ??= GameController.PluginBridge @@ -2059,24 +2414,19 @@ private bool TryGetNinjaValue( if (_getNinjaBaseItemTypeValue == null) { - LogError( - "NinjaPrice.GetBaseItemTypeValue " + - "is unavailable."); - + LogError("NinjaPrice.GetBaseItemTypeValue is unavailable."); return false; } try { - chaosValue = - _getNinjaBaseItemTypeValue(itemType); - + chaosValue = _getNinjaBaseItemTypeValue(itemType); return true; } catch (Exception ex) { LogError( - $"Failed to retrieve Ninja Price for " + + $"Failed to retrieve the NinjaPrice value for " + $"{GetItemName(option)}: {ex}"); return false; @@ -2089,96 +2439,101 @@ private double GetTotalNinjaValue( if (item == null) return 0; - return TryGetNinjaValue( - item, - out var unitValue) + return TryGetNinjaValue(item, out var unitValue) ? Math.Floor(unitValue * item.Owned) : 0; } - private MarketRatio GetMarketRatioForPendingExchange(CurrencyExchangePanel currencyExchangePanel, int neededAmount = 100) + /// + /// Reads market ratios directly from the exchange panel memory (like + /// MarketWizard) instead of hovering the ratio tooltip. + /// + /// + /// OfferedItemStock holds the competing listings of the offered + /// item: Give is the wanted amount they ask, Get is the + /// offered amount they list, and ListedCount is their stock in + /// offered-item units. The first WantedItemStock entry always + /// matches MarketRateGet:MarketRateGive. + /// + private MarketRatio GetMarketRatioForPendingExchange( + CurrencyExchangePanel currencyExchangePanel, + int neededAmount) { if (currencyExchangePanel == null) - { return null; - } - if (Settings.ListPriceBasedOnHighestCompetingTrade) + var marketRateRatio = new MarketRatio { - debugMessages.Add($"ListPriceBasedOnHighestCompetingTrade is enabled. Attempting to retrieve all available market ratios..."); - var marketRatioPanelChildIndex = Settings.MarketRatioPanelIndex.Value; - var marketRatioPanel = currencyExchangePanel.GetChildAtIndex(marketRatioPanelChildIndex); - var marketRatioPanelTooltip = marketRatioPanel?.Tooltip; - - var allMarketRatioLinesGroupedByHeight = marketRatioPanelTooltip?.Children? - .GroupBy(child => child.Y) - .Select(group => group - .Select(child => child.TextNoTags) - .ToList()) - .ToList(); - - var availableMarketRatios = new List(); - - - + MarketGetRate = currencyExchangePanel.MarketRateGet, + MarketGiveRate = currencyExchangePanel.MarketRateGive, + AvailableTrades = 0 + }; - foreach (var marketRatioLine in allMarketRatioLinesGroupedByHeight) + if (!Settings.UseHighestCompetingRatio) + return marketRateRatio; + + var competingRatios = + (currencyExchangePanel.OfferedItemStock ?? []) + .Where(stock => + stock != null && + stock.Get > 0 && + stock.Give > 0 && + stock.ListedCount > 0) + .Select(stock => new MarketRatio { - var marketRatio = new MarketRatio - { - MarketGetRate = int.TryParse(marketRatioLine[0].Split(" : ").FirstOrDefault().Replace(">", "").Replace("<", ""), out int giveRate) ? giveRate : 0, - MarketGiveRate = int.TryParse(marketRatioLine[0].Split(" : ").LastOrDefault().Replace(">", "").Replace("<", ""), out int getRate) ? getRate : 0, - }; - - - if (marketRatio.MarketGetRate <= 0 || marketRatio.MarketGiveRate <= 0) - { - CustomDebugImGuiWindow($"Skipping market ratio line due to invalid rates: {marketRatioLine[0]}"); - continue; - } - - marketRatio.AvailableTrades = int.TryParse(marketRatioLine[1].Replace(".", ""), out int trades) ? trades : 0; - - CustomDebugImGuiWindow($"Found market ratio: {marketRatio.MarketGetRate}:{marketRatio.MarketGiveRate} with {marketRatio.AvailableTrades} available trades."); - - - if (marketRatio.MarketGetRate > 0 && marketRatio.MarketGiveRate > 0 && marketRatio.AvailableTrades > 0) - { - availableMarketRatios.Add(marketRatio); - } - } + MarketGetRate = stock.Give, + MarketGiveRate = stock.Get, + AvailableTrades = stock.ListedCount + }) + .OrderByDescending(ratio => + (double)ratio.MarketGetRate / ratio.MarketGiveRate) + .ToList(); - availableMarketRatios = availableMarketRatios.OrderByDescending(ratio => ratio.MarketGetRate).ToList(); + if (Settings.Debug) + { + AddDebugMessage( + $"Competing ratios: " + + $"{JsonConvert.SerializeObject(competingRatios)}"); + } - if (Settings.OnlyPickRatiosWithSufficientStock) - { - availableMarketRatios = availableMarketRatios.Where(ratio => ratio.AvailableTrades >= neededAmount).ToList(); - } + if (Settings.RequireSufficientStock) + { + competingRatios = competingRatios + .Where(ratio => ratio.AvailableTrades >= neededAmount) + .ToList(); + } - CustomDebugImGuiWindow($"Json Formated Ratios: {JsonConvert.SerializeObject(availableMarketRatios, Formatting.Indented)}"); + var bestCompetingRatio = competingRatios.FirstOrDefault(); - return availableMarketRatios.FirstOrDefault(); - } - else + if (bestCompetingRatio == null) { - return new MarketRatio - { - MarketGetRate = currencyExchangePanel.MarketRateGet, - MarketGiveRate = currencyExchangePanel.MarketRateGive, - AvailableTrades = 0 // Not available in this mode - }; + AddDebugMessage( + "No competing ratio qualifies; falling back " + + "to the current market rate."); + + return marketRateRatio; } + + return bestCompetingRatio; } - private void MyLogMessage(string message) + /// + /// Records a debug message for the debug window, keeping only the most + /// recent entries. + /// + private void AddDebugMessage(string message) { - if (Settings.Debug) - { - LogMessage(message); - } + _debugMessages.Add(message); + + if (_debugMessages.Count > MaxDebugMessages) + _debugMessages.RemoveAt(0); } - private void DebugImGuiWindow() + /// + /// Draws the sequence state and recent debug messages. Exceptions are + /// swallowed so a debug-draw failure never breaks . + /// + private void DrawDebugWindow() { try { @@ -2191,27 +2546,22 @@ private void DebugImGuiWindow() ImGui.Text($"Pending sell item: {_pendingSellItemName}"); ImGui.Text($"Pending sell owned amount: {_pendingSellOwnedAmount}"); ImGui.Text($"Pending wanted item: {_pendingWantedItem?.Text}"); - ImGui.Text($"Input in progress: {(_inputInProgress != 0)}"); + ImGui.Text($"Input in progress: {IsGameInputBusy()}"); + ImGui.Text($"Sell queue: {_sellQueue.Count}"); + ImGui.Text($"Collect sequence active: {_collectSequenceActive}"); + ImGui.Text($"Input session active: {_inputSessionActive}"); ImGui.Separator(); - foreach (var message in debugMessages) + foreach (var message in _debugMessages) { ImGui.NewLine(); ImGui.TextWrapped(message); } } - catch (Exception ex) + catch { } } - - private void CustomDebugImGuiWindow(string message) - { - if (!Settings.Debug) - return; - - ImGui.TextWrapped(message); - } } -} \ No newline at end of file +} diff --git a/Input.cs b/Input.cs new file mode 100644 index 0000000..d767113 --- /dev/null +++ b/Input.cs @@ -0,0 +1,279 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Forms; +using ExileCore.Shared; +using InputHumanizer.Input; +using NumVector2 = System.Numerics.Vector2; + +namespace SellMyShit +{ + /// + /// Delivers mouse and keyboard input to the game, either through the + /// InputHumanizer plugin (when enabled and available) or through the raw + /// Win32 SendInput helpers. + /// + /// + /// Keyboard input always uses the Win32 helpers, even with InputHumanizer + /// enabled: the InputHumanizer controller's key presses go through + /// ExileCore.Input, which does not work in this setup, and Unicode + /// typing keeps working on any keyboard layout. InputHumanizer then only + /// contributes its humanized delays. + /// + public static class Input + { + private static Core Plugin => Core.Instance; + + private static Settings Settings => Plugin.Settings; + + private static IInputController _inputHumanizerControllerInstance; + + /// Left-clicks the given screen position. + public static async SyncTask Click( + NumVector2 targetPosition, + CancellationToken cancellationToken = default) + { + return await ClickButton( + MouseButtons.Left, + targetPosition, + cancellationToken); + } + + /// + /// Ctrl+right-clicks the given screen position, e.g. to collect + /// placed-order items. Ctrl is always released afterwards, even when + /// the click fails. + /// + public static async SyncTask CtrlRightClick( + NumVector2 targetPosition, + CancellationToken cancellationToken = default) + { + if (!KeyboardInput.Keyboard.ControlDown()) + return false; + + try + { + await Task.Delay( + Settings.GetRandomizedActionDelay(), + cancellationToken); + + return await ClickButton( + MouseButtons.Right, + targetPosition, + cancellationToken); + } + finally + { + try + { + KeyboardInput.Keyboard.ControlUp(); + } + catch + { + } + } + } + + private static async SyncTask ClickButton( + MouseButtons mouseButton, + NumVector2 targetPosition, + CancellationToken cancellationToken) + { + if (Settings.UseInputHumanizer) + { + var controller = GetInputHumanizerController(); + + if (controller != null) + { + return await controller.Click( + mouseButton, + targetPosition, + cancellationToken); + } + } + + await Task.Delay( + Settings.GetRandomizedActionDelay(), + cancellationToken); + + if (!MouseInput.Mouse.MoveMouse( + new SharpDX.Vector2(targetPosition.X, targetPosition.Y))) + { + return false; + } + + await Task.Delay( + Settings.GetRandomizedActionDelay(), + cancellationToken); + + if (mouseButton == MouseButtons.Right) + MouseInput.Mouse.RightDown(); + else + MouseInput.Mouse.LeftDown(); + + await Task.Delay( + Settings.GetRandomizedActionDelay(), + cancellationToken); + + if (mouseButton == MouseButtons.Right) + MouseInput.Mouse.RightUp(); + else + MouseInput.Mouse.LeftUp(); + + await Task.Delay( + Settings.GetRandomizedActionDelay(), + cancellationToken); + + return true; + } + + /// Moves the cursor to the given screen position. + public static async SyncTask MoveMouse( + NumVector2 targetPosition, + CancellationToken cancellationToken = default) + { + if (Settings.UseInputHumanizer) + { + var controller = GetInputHumanizerController(); + + if (controller != null) + { + return await controller.MoveMouse( + targetPosition, + cancellationToken); + } + } + + await Task.Delay( + Settings.GetRandomizedActionDelay(), + cancellationToken); + + var moved = MouseInput.Mouse.MoveMouse( + new SharpDX.Vector2(targetPosition.X, targetPosition.Y)); + + await Task.Delay( + Settings.GetRandomizedActionDelay(), + cancellationToken); + + return moved; + } + + /// + /// Selects the current content of the focused input with Ctrl+A and + /// types the replacement text over it. + /// + public static async SyncTask ReplaceText( + string text, + CancellationToken cancellationToken = default) + { + if (text == null) + return false; + + if (Settings.UseInputHumanizer) + { + var controller = GetInputHumanizerController(); + + if (controller != null) + { + await Task.Delay( + controller.GenerateDelay(), + cancellationToken); + + if (!KeyboardInput.Keyboard.PressControlA()) + return false; + + await Task.Delay( + controller.GenerateDelay(), + cancellationToken); + + foreach (var character in text) + { + await Task.Delay( + controller.GenerateDelay(), + cancellationToken); + + KeyboardInput.Keyboard.TypeCharacter(character); + } + + return true; + } + } + + await Task.Delay( + Settings.GetRandomizedActionDelay(), + cancellationToken); + + if (!KeyboardInput.Keyboard.PressControlA()) + return false; + + await Task.Delay( + Settings.GetRandomizedActionDelay(), + cancellationToken); + + var typed = KeyboardInput.Keyboard.TypeText(text); + + await Task.Delay( + Settings.GetRandomizedActionDelay(), + cancellationToken); + + return typed; + } + + /// + /// Releases the InputHumanizer input lock if one is held. Intentionally + /// ignores the UseInputHumanizer toggle: a held controller must be + /// released even when the user disabled the toggle mid-sequence. + /// + public static async SyncTask ReleaseControl() + { + if (_inputHumanizerControllerInstance != null) + { + return await _inputHumanizerControllerInstance.ReleaseControl(); + } + + return true; + } + + /// + /// Returns the cached InputHumanizer controller, acquiring one through the + /// plugin bridge on first use. + /// + /// + /// When InputHumanizer is not loaded at all, the UseInputHumanizer + /// toggle is switched off so the sequence permanently falls back to raw + /// input. When another plugin merely holds the input lock, this returns + /// null and the acquisition is retried on the next input action. + /// + private static IInputController GetInputHumanizerController() + { + if (_inputHumanizerControllerInstance == null) + { + var tryGetInputController = + Plugin.GameController.PluginBridge + .GetMethod>( + "InputHumanizer.TryGetInputController"); + + if (tryGetInputController == null) + { + Settings.UseInputHumanizer.Value = false; + return null; + } + + _inputHumanizerControllerInstance = + tryGetInputController("SellMyShit"); + } + + return _inputHumanizerControllerInstance; + } + + /// Disposes the cached InputHumanizer controller. + public static void ReleaseResources() + { + if (_inputHumanizerControllerInstance != null) + { + _inputHumanizerControllerInstance.Dispose(); + _inputHumanizerControllerInstance = null; + } + } + } +} diff --git a/Keyboard.cs b/Keyboard.cs index 199dd86..1eb9207 100644 --- a/Keyboard.cs +++ b/Keyboard.cs @@ -2,27 +2,24 @@ using System.Collections.Generic; using System.ComponentModel; using System.Runtime.InteropServices; -using System.Threading; namespace KeyboardInput { + /// + /// Win32 SendInput keyboard helpers. These are used instead of + /// ExileCore.Input, which does not deliver input in this setup. + /// Text is typed as Unicode events so it works on any keyboard layout. + /// internal static class Keyboard { private const uint InputKeyboard = 1; private const uint KeyEventKeyUp = 0x0002; private const uint KeyEventUnicode = 0x0004; - private const uint KeyEventScanCode = 0x0008; private const ushort VkControl = 0x11; private const ushort VkA = 0x41; - private const int VkMenu = 0x12; - private const int VkLeftMenu = 0xA4; - - // Physical scan code for left Alt. - private const ushort ScanCodeLeftAlt = 0x38; - [StructLayout(LayoutKind.Sequential)] private struct Input { @@ -78,123 +75,58 @@ private static extern uint SendInput( Input[] inputs, int inputSize); - [DllImport("user32.dll")] - private static extern short GetAsyncKeyState( - int virtualKey); - - public static bool ReplaceText( - string text, - int delayBeforeTypingMilliseconds = 30) + /// Presses and holds the Ctrl key. + public static bool ControlDown() { - if (text == null) - return false; - - if (!PressControlA()) - return false; - - Thread.Sleep(delayBeforeTypingMilliseconds); - - return TypeText(text); + return Send([CreateVirtualKeyInput(VkControl, keyUp: false)]); } - public static bool PressControlA() + /// Releases the Ctrl key. + public static bool ControlUp() { - return Send( - [ - CreateVirtualKeyInput( - VkControl, - keyUp: false), - - CreateVirtualKeyInput( - VkA, - keyUp: false), - - CreateVirtualKeyInput( - VkA, - keyUp: true), - - CreateVirtualKeyInput( - VkControl, - keyUp: true) - ]); + return Send([CreateVirtualKeyInput(VkControl, keyUp: true)]); } - /// - /// Sends a left-Alt key-down event without releasing the key. - /// Alt remains held until AltKeyUp() is called. - /// - public static bool AltKeyDown() + /// Presses Ctrl+A to select the content of the focused input. + public static bool PressControlA() { - if (IsAltDown()) - return true; - - var sent = Send( + return Send( [ - CreateScanCodeInput( - ScanCodeLeftAlt, - keyUp: false) + CreateVirtualKeyInput(VkControl, keyUp: false), + CreateVirtualKeyInput(VkA, keyUp: false), + CreateVirtualKeyInput(VkA, keyUp: true), + CreateVirtualKeyInput(VkControl, keyUp: true) ]); - - Thread.Sleep(30); - - return sent; } - /// - /// Releases left Alt. - /// - public static bool AltKeyUp() + /// Types a single character as a Unicode key press. + public static bool TypeCharacter(char character) { - var sent = Send( + return Send( [ - CreateScanCodeInput( - ScanCodeLeftAlt, - keyUp: true) + CreateUnicodeInput(character, keyUp: false), + CreateUnicodeInput(character, keyUp: true) ]); - - Thread.Sleep(30); - - return sent; - } - - public static bool IsAltDown() - { - return IsKeyDown(VkMenu) || - IsKeyDown(VkLeftMenu); } + /// Types the given text as one batch of Unicode key presses. public static bool TypeText(string text) { if (string.IsNullOrEmpty(text)) return true; - var inputs = - new List(text.Length * 2); + var inputs = new List(text.Length * 2); foreach (var character in text) { - inputs.Add( - CreateUnicodeInput( - character, - keyUp: false)); - - inputs.Add( - CreateUnicodeInput( - character, - keyUp: true)); + inputs.Add(CreateUnicodeInput(character, keyUp: false)); + inputs.Add(CreateUnicodeInput(character, keyUp: true)); } return Send(inputs.ToArray()); } - private static bool IsKeyDown(int virtualKey) - { - return (GetAsyncKeyState(virtualKey) & 0x8000) != 0; - } - - private static Input CreateVirtualKeyInput( - ushort virtualKey, - bool keyUp) + private static Input CreateVirtualKeyInput(ushort virtualKey, bool keyUp) { return new Input { @@ -205,9 +137,7 @@ private static Input CreateVirtualKeyInput( { VirtualKey = virtualKey, ScanCode = 0, - Flags = keyUp - ? KeyEventKeyUp - : 0, + Flags = keyUp ? KeyEventKeyUp : 0, Time = 0, ExtraInfo = UIntPtr.Zero } @@ -215,33 +145,7 @@ private static Input CreateVirtualKeyInput( }; } - private static Input CreateScanCodeInput( - ushort scanCode, - bool keyUp) - { - return new Input - { - Type = InputKeyboard, - Data = new InputUnion - { - Keyboard = new KeyboardInput - { - // Ignored when KEYEVENTF_SCANCODE is used. - VirtualKey = 0, - ScanCode = scanCode, - Flags = - KeyEventScanCode | - (keyUp ? KeyEventKeyUp : 0), - Time = 0, - ExtraInfo = UIntPtr.Zero - } - } - }; - } - - private static Input CreateUnicodeInput( - char character, - bool keyUp) + private static Input CreateUnicodeInput(char character, bool keyUp) { return new Input { @@ -252,9 +156,7 @@ private static Input CreateUnicodeInput( { VirtualKey = 0, ScanCode = character, - Flags = - KeyEventUnicode | - (keyUp ? KeyEventKeyUp : 0), + Flags = KeyEventUnicode | (keyUp ? KeyEventKeyUp : 0), Time = 0, ExtraInfo = UIntPtr.Zero } @@ -262,13 +164,11 @@ private static Input CreateUnicodeInput( }; } + /// Not every event was delivered. private static bool Send(Input[] inputs) { - if (inputs == null || - inputs.Length == 0) - { + if (inputs == null || inputs.Length == 0) return true; - } var sent = SendInput( (uint)inputs.Length, @@ -278,13 +178,11 @@ private static bool Send(Input[] inputs) if (sent == (uint)inputs.Length) return true; - var error = - Marshal.GetLastWin32Error(); + var error = Marshal.GetLastWin32Error(); throw new Win32Exception( error, - $"SendInput sent {sent} of " + - $"{inputs.Length} keyboard events."); + $"SendInput sent {sent} of {inputs.Length} keyboard events."); } } -} \ No newline at end of file +} diff --git a/MarketRatio.cs b/MarketRatio.cs new file mode 100644 index 0000000..be90054 --- /dev/null +++ b/MarketRatio.cs @@ -0,0 +1,21 @@ +namespace SellMyShit +{ + /// + /// An exchange ratio between the offered and the wanted currency, either the + /// panel's own market rate or one competing listing read from panel memory. + /// + public class MarketRatio + { + /// Units of the offered currency given per units received. + public int MarketGiveRate { get; set; } + + /// Units of the wanted currency received per units given. + public int MarketGetRate { get; set; } + + /// + /// Stock available at this ratio in offered-item units; 0 when the ratio is + /// the panel's own market rate rather than a competing listing. + /// + public int AvailableTrades { get; set; } + } +} diff --git a/MouseInput.cs b/MouseInput.cs index 4b766f9..8836356 100644 --- a/MouseInput.cs +++ b/MouseInput.cs @@ -1,57 +1,62 @@ using System; using System.ComponentModel; using System.Runtime.InteropServices; -using System.Threading; using SharpDX; namespace MouseInput { + /// + /// Win32 SendInput/SetCursorPos mouse helpers. These are used + /// instead of ExileCore.Input, which does not deliver input in this setup. + /// internal static class Mouse { - private const uint INPUT_MOUSE = 0; + private const uint InputMouse = 0; - private const uint MOUSEEVENTF_LEFTDOWN = 0x0002; - private const uint MOUSEEVENTF_LEFTUP = 0x0004; - private const uint MOUSEEVENTF_RIGHTDOWN = 0x0008; - private const uint MOUSEEVENTF_RIGHTUP = 0x0010; + private const uint MouseEventLeftDown = 0x0002; + private const uint MouseEventLeftUp = 0x0004; + private const uint MouseEventRightDown = 0x0008; + private const uint MouseEventRightUp = 0x0010; [StructLayout(LayoutKind.Sequential)] - private struct INPUT + private struct Input { - public uint type; - public InputUnion input; + public uint Type; + public InputUnion Data; } [StructLayout(LayoutKind.Explicit)] private struct InputUnion { [FieldOffset(0)] - public MOUSEINPUT mouseInput; + public MouseInput Mouse; } [StructLayout(LayoutKind.Sequential)] - private struct MOUSEINPUT + private struct MouseInput { - public int dx; - public int dy; - public uint mouseData; - public uint flags; - public uint time; - public UIntPtr extraInfo; + public int Dx; + public int Dy; + public uint MouseData; + public uint Flags; + public uint Time; + public UIntPtr ExtraInfo; } [DllImport("user32.dll", SetLastError = true)] private static extern bool SetCursorPos(int x, int y); - [DllImport("user32.dll")] + [DllImport("user32.dll", SetLastError = true)] private static extern bool GetCursorPos(out SharpDX.Point point); [DllImport("user32.dll", SetLastError = true)] private static extern uint SendInput( - uint numberOfInputs, - INPUT[] inputs, - int sizeOfInput); + uint inputCount, + Input[] inputs, + int inputSize); + /// Returns the cursor position in screen coordinates. + /// The position could not be read. public static SharpDX.Point GetCursorPosition() { if (!GetCursorPos(out var point)) @@ -60,6 +65,7 @@ public static SharpDX.Point GetCursorPosition() return point; } + /// Moves the cursor to the given screen position. public static bool MoveMouse(Vector2 position) { return SetCursorPos( @@ -67,38 +73,30 @@ public static bool MoveMouse(Vector2 position) (int)Math.Round(position.Y)); } - public static bool LeftClick(int holdMilliseconds = 30) - { - if (!SendMouseEvent(MOUSEEVENTF_LEFTDOWN)) - return false; + /// Presses the left mouse button at the current cursor position. + public static bool LeftDown() => SendMouseEvent(MouseEventLeftDown); - Thread.Sleep(holdMilliseconds); + /// Releases the left mouse button at the current cursor position. + public static bool LeftUp() => SendMouseEvent(MouseEventLeftUp); - return SendMouseEvent(MOUSEEVENTF_LEFTUP); - } + /// Presses the right mouse button at the current cursor position. + public static bool RightDown() => SendMouseEvent(MouseEventRightDown); - public static bool RightClick(int holdMilliseconds = 30) - { - if (!SendMouseEvent(MOUSEEVENTF_RIGHTDOWN)) - return false; - - Thread.Sleep(holdMilliseconds); - - return SendMouseEvent(MOUSEEVENTF_RIGHTUP); - } + /// Releases the right mouse button at the current cursor position. + public static bool RightUp() => SendMouseEvent(MouseEventRightUp); private static bool SendMouseEvent(uint flags) { var inputs = new[] { - new INPUT + new Input { - type = INPUT_MOUSE, - input = new InputUnion + Type = InputMouse, + Data = new InputUnion { - mouseInput = new MOUSEINPUT + Mouse = new MouseInput { - flags = flags + Flags = flags } } } @@ -107,9 +105,9 @@ private static bool SendMouseEvent(uint flags) var sent = SendInput( (uint)inputs.Length, inputs, - Marshal.SizeOf()); + Marshal.SizeOf()); return sent == inputs.Length; } } -} \ No newline at end of file +} diff --git a/SellMyShit.csproj b/SellMyShit.csproj index 9ab676f..1c24e20 100644 --- a/SellMyShit.csproj +++ b/SellMyShit.csproj @@ -30,6 +30,11 @@ false + + $(ExileApiDir)InputHumanizerLib.dll + false + + $(ExileApiDir)ImGui.NET.dll false diff --git a/SellSequenceStep.cs b/SellSequenceStep.cs index f70b276..b6bb95e 100644 --- a/SellSequenceStep.cs +++ b/SellSequenceStep.cs @@ -1,47 +1,100 @@ -enum SellSequenceStep +namespace SellMyShit { - Idle, - - Start, - ClickIHave, - WaitForOfferedCurrencyPicker, - ClickCurrencyPickerOfferedSearchInput, - TypeCurrencyPickerOfferedSearchQuery, - ValidateOfferedSearchQuery, - WaitForCurrencyPickerOfferedSearchResults, - ClickOwnedItem, - WaitForOfferedCurrencyPickerToClose, - ClickOfferedItemInput, - TypeOfferedItemValue, - - - CheckIfChaosIsWanted, - ClickIWant, - WaitForWantedCurrencyPicker, - ClickCurrencyPickerWantedSearchInput, - TypeCurrencyPickerWantedSearchQuery, - ValidatedWantedSearchQuery, - WaitForCurrencyPickerWantedSearchResults, - ClickWantedItem, - WaitForWantedCurrencyPickerToClose, - - - ClickWantedItemInput, - TypeWantedItemValue, - BlurInput, - - ShowMarketRatioTooltip, - ShowDetailedMarketRatioTooltip, - WaitForDetailedMarketRatioInfo, - WaitForMarketRatio, - - - - CheckIfSellButtonIsActive, - ClickSellButton, - CheckUnfavorableTrade, - ReopenIHave, - End, - CloseCurrencyPicker, + /// + /// Ordered states of the automated sell sequence. Core.ProcessSellSequence + /// advances through these states one game action per frame, re-validating the + /// live UI before every input. + /// + public enum SellSequenceStep + { + /// No sequence is running. + Idle, + /// Entry point; routes based on whether the currency picker is already open. + Start, + + /// Clicks the "I Have" currency select button to open the offered-side picker. + ClickIHave, + + /// Waits for the offered-side currency picker to become visible. + WaitForOfferedCurrencyPicker, + + /// Clicks the picker search input on the offered side. + ClickCurrencyPickerOfferedSearchInput, + + /// Types the offered item name into the picker search input. + TypeCurrencyPickerOfferedSearchQuery, + + /// Confirms the search input now contains the offered item name. + ValidateOfferedSearchQuery, + + /// Waits for the picker to list the offered item, then resolves its option element. + WaitForCurrencyPickerOfferedSearchResults, + + /// Clicks the offered item in the picker result list. + ClickOwnedItem, + + /// Waits for the offered-side picker to close after the selection. + WaitForOfferedCurrencyPickerToClose, + + /// Skips the wanted-side selection when the wanted currency is already chosen. + CheckIfChaosIsWanted, + + /// Clicks the "I Want" currency select button to open the wanted-side picker. + ClickIWant, + + /// Waits for the wanted-side currency picker to become visible. + WaitForWantedCurrencyPicker, + + /// Clicks the picker search input on the wanted side. + ClickCurrencyPickerWantedSearchInput, + + /// Types the wanted currency name into the picker search input. + TypeCurrencyPickerWantedSearchQuery, + + /// Confirms the search input now contains the wanted currency name. + ValidateWantedSearchQuery, + + /// Waits for the picker to list the wanted currency, then resolves its option element. + WaitForCurrencyPickerWantedSearchResults, + + /// Clicks the wanted currency in the picker result list. + ClickWantedItem, + + /// Waits for the wanted-side picker to close after the selection. + WaitForWantedCurrencyPickerToClose, + + /// Waits until the panel exposes a usable market ratio for the pair. + WaitForMarketRatio, + + /// Clicks the offered item count input. + ClickOfferedItemInput, + + /// Types the owned amount into the offered item count input. + TypeOfferedItemValue, + + /// Clicks the wanted item count input. + ClickWantedItemInput, + + /// Calculates and types the requested amount into the wanted item count input. + TypeWantedItemValue, + + /// Clicks the ratio display to take keyboard focus off the amount inputs. + BlurInput, + + /// Waits until the game enables the sell button for the entered amounts. + CheckIfSellButtonIsActive, + + /// Clicks the sell button to place the order. + ClickSellButton, + + /// Placeholder — the unfavorable-trade confirmation dialog is not handled yet. + CheckUnfavorableTrade, + + /// Reopens the offered-side picker so a queued follow-up item can start immediately. + ReopenIHave, + + /// Finishes the current item and hands control back to the queue. + End + } } diff --git a/Settings.cs b/Settings.cs index 9a09dbe..1230602 100644 --- a/Settings.cs +++ b/Settings.cs @@ -1,10 +1,6 @@ -using System; +using System; using System.Collections.Generic; -using System.Configuration; using System.Linq; -using System.Text.Json.Serialization; -using System.Xml; -using ExileCore; using ExileCore.Shared.Attributes; using ExileCore.Shared.Interfaces; using ExileCore.Shared.Nodes; @@ -17,22 +13,24 @@ public class Settings : ISettings { private const int InterfaceGroupId = 100; private const int PricingGroupId = 200; - private const int SequenceGroupId = 300; - private const int InputTimingGroupId = 400; - private const int CompatibilityGroupId = 500; + private const int TimingGroupId = 300; + private const int InputGroupId = 400; + private const int AdvancedGroupId = 500; private const int ExcludedCurrenciesGroupId = 600; private static readonly List DefaultExcludedCurrencies = [ "Mirror of Kalandra", - "Hinekora's Lock", - "Divine Orb", - "Chaos Orb" + "Hinekora's Lock", + "Divine Orb", + "Chaos Orb" ]; private static readonly string DefaultExcludedCurrenciesJson = JsonConvert.SerializeObject(DefaultExcludedCurrencies); + private readonly Random _delayRandom = new(); + private string _newExcludedCurrency = string.Empty; private List _excludedCurrencies = @@ -40,7 +38,6 @@ public class Settings : ISettings private string _loadedExcludedCurrenciesJson; - public Settings() { SortBy.SetListValues( @@ -51,7 +48,6 @@ public Settings() SortOptions.Owned }); - ExcludedCurrenciesEditor = new CustomNode { DrawDelegate = DrawExcludedCurrenciesEditor @@ -60,68 +56,52 @@ public Settings() public ToggleNode Enable { get; set; } = new(true); - // ───────────────────────────────────────────── - // Interface - // ───────────────────────────────────────────── - [Menu( "Interface", - "Settings for the owned-currency window.", + "Settings for the owned-items window.", InterfaceGroupId)] public EmptyNode InterfaceGroup { get; set; } = new(); [Menu( - "Window width", - "Width of the scrollable owned-currency list.", + "Pin Window", + "Docks the window to the top-right corner of the " + + "currency exchange panel.", 101, InterfaceGroupId)] - public RangeNode WindowWidth { get; set; } = - new(520, 300, 1200); + public ToggleNode PinWindow { get; set; } = new(true); [Menu( - "Window height", - "Height of the scrollable owned-currency list.", + "Default Sort Column", + "The column the item table is sorted by initially.", 102, InterfaceGroupId)] - public RangeNode WindowHeight { get; set; } = - new(340, 150, 1000); - - - [Menu( - "Default Sort by", - "The currently selected sort column.", - 103, - InterfaceGroupId)] public ListNode SortBy { get; set; } = new() { Value = SortOptions.Value }; [Menu( - "Restore mouse position?", - "If the sell sequence ends, the mouse jumps back to where you initiated the sequence.", - 104, - InterfaceGroupId)] - public ToggleNode RestoreMousePosition { get; set; } = new(true); + "Default Sort Ascending", + "Sorts low-to-high instead of high-to-low.", + 103, + InterfaceGroupId)] + public ToggleNode SortAscending { get; set; } = new(false); [Menu( - "Default Sort ascending?", - "Sort low-to-high instead of high-to-low.", - 105, + "Restore Mouse Position", + "Moves the cursor back to where it was once a sequence " + + "finishes.", + 104, InterfaceGroupId)] - public ToggleNode SortAscending { get; set; } = new(false); + public ToggleNode RestoreMousePosition { get; set; } = new(true); [Menu( - "Show Debug Messages?", - null, - 106, + "Show Debug Messages", + "Displays the debug window and verbose sequence logging.", + 105, InterfaceGroupId)] public ToggleNode Debug { get; set; } = new(false); - // ───────────────────────────────────────────── - // Pricing - // ───────────────────────────────────────────── - [Menu( "Pricing", "Controls how the requested currency amount is calculated.", @@ -129,230 +109,136 @@ public Settings() public EmptyNode PricingGroup { get; set; } = new(); [Menu( - "Listing price percent", - "Percentage of the detected market ratio to request. " + - "100 means the listing is priced at 100% of the market ratio.", + "Listing Price Percent", + "The percentage of the detected market ratio to request. " + + "100 lists at the full market ratio.", 201, PricingGroupId)] public RangeNode ListingPricePercent { get; set; } = new(100, 1, 100); - [Menu( - "Use highest competing market ratio for listing price?", - "If enabled, the plugin will use the market ratio of the competing trade to determine the listing price. The trades will usually take longer to complete, but the listing price will be more profitable", + "Use Highest Competing Ratio", + "Lists at the highest competing trade ratio instead of " + + "the current market rate. Trades take longer to fill but " + + "are more profitable.", 202, PricingGroupId)] - public ToggleNode ListPriceBasedOnHighestCompetingTrade { get; set; } = new(false); + public ToggleNode UseHighestCompetingRatio { get; set; } = + new(false); - [Menu("Only pick ratios with sufficient stock?", - "If enabled, the plugin will only pick ratios that have enough stock to fulfill your request. Usually, the stock is sufficient, unless large amounts of currencies are being sold.", + [Menu( + "Require Sufficient Stock", + "Only picks competing ratios whose stock covers the " + + "amount being sold.", 203, PricingGroupId)] - public ToggleNode OnlyPickRatiosWithSufficientStock { get; set; } = new(true); - - - // ───────────────────────────────────────────── - // Sell sequence - // ───────────────────────────────────────────── + public ToggleNode RequireSufficientStock { get; set; } = + new(true); [Menu( - "Sell sequence", - "Timeouts and delays used by the automated sell process.", - SequenceGroupId)] - public EmptyNode SequenceGroup { get; set; } = new(); + "Timing", + "Delays and timeouts for the automated sequences.", + TimingGroupId)] + public EmptyNode TimingGroup { get; set; } = new(); [Menu( - "Step timeout (seconds)", - "Stops the sequence when one state remains active for too long.", + "Action Delay (ms)", + "The base delay between automated inputs and state checks. " + + "Every use is randomized by plus/minus 10 percent " + + "(100 becomes 90-110).", 301, - SequenceGroupId)] - public RangeNode SequenceTimeoutSeconds { get; set; } = - new(5, 1, 30); + TimingGroupId)] + public RangeNode ActionDelayMilliseconds { get; set; } = + new(100, 0, 1000); [Menu( - "Search result delay (ms)", - "Time to wait after entering a currency name before clicking its result.", + "Collect Delay (ms)", + "The delay between individual item collections. Applies " + + "with and without InputHumanizer because of trade rate " + + "limits.", 302, - SequenceGroupId)] - public RangeNode CurrencySearchDelayMilliseconds { get; set; } = - new(100, 0, 5000); + TimingGroupId)] + public RangeNode CollectDelayMilliseconds { get; set; } = + new(1000, 250, 5000); [Menu( - "Market ratio delay (ms)", - "Time to wait after entering amounts before checking the updated ratio.", + "Step Timeout (s)", + "Skips the current item when one sequence step remains " + + "active for this long.", 303, - SequenceGroupId)] - public RangeNode MarketRatioDelayMilliseconds { get; set; } = - new(100, 0, 5000); - - // ───────────────────────────────────────────── - // Input timing - // ───────────────────────────────────────────── + TimingGroupId)] + public RangeNode SequenceTimeoutSeconds { get; set; } = + new(5, 1, 30); [Menu( - "Input timing", - "Mouse, keyboard, focus, and overlay timing settings.", - InputTimingGroupId)] - public EmptyNode InputTimingGroup { get; set; } = new(); + "Input", + "How mouse and keyboard input is delivered.", + InputGroupId)] + public EmptyNode InputGroup { get; set; } = new(); [Menu( - "Text overlay hide time (ms)", - "How long the plugin window remains hidden while typing.", + "Use InputHumanizer", + "Routes mouse input through the InputHumanizer plugin " + + "(humanized delays and mouse paths) when it is loaded.", 401, - InputTimingGroupId)] - public RangeNode TextOverlayHideMilliseconds { get; set; } = - new(300, 0, 3000); + InputGroupId)] + public ToggleNode UseInputHumanizer { get; set; } = new(false); [Menu( - "Click overlay hide time (ms)", - "How long the plugin window remains hidden while clicking.", - 402, - InputTimingGroupId)] - public RangeNode ClickOverlayHideMilliseconds { get; set; } = - new(300, 0, 3000); + "Advanced", + "Game constants. UI elements are discovered automatically, " + + "so there are no child indexes to maintain.", + AdvancedGroupId)] + public EmptyNode AdvancedGroup { get; set; } = new(); [Menu( - "Text pre-focus delay (ms)", - "Delay before focusing Path of Exile for keyboard input.", - 403, - InputTimingGroupId)] - public RangeNode TextPreFocusDelayMilliseconds { get; set; } = - new(150, 0, 1000); - - [Menu( - "Text post-focus delay (ms)", - "Delay after focusing Path of Exile before typing.", - 404, - InputTimingGroupId)] - public RangeNode TextPostFocusDelayMilliseconds { get; set; } = - new(150, 0, 1000); - - [Menu( - "Text release delay (ms)", - "Delay before releasing the shared input lock after typing.", - 405, - InputTimingGroupId)] - public RangeNode TextReleaseDelayMilliseconds { get; set; } = - new(150, 0, 1000); - - [Menu( - "Click pre-focus delay (ms)", - "Delay before focusing Path of Exile for a mouse click.", - 406, - InputTimingGroupId)] - public RangeNode ClickPreFocusDelayMilliseconds { get; set; } = - new(50, 0, 1000); - - [Menu( - "Click post-focus delay (ms)", - "Delay after focusing Path of Exile before moving the cursor.", - 407, - InputTimingGroupId)] - public RangeNode ClickPostFocusDelayMilliseconds { get; set; } = - new(50, 0, 1000); - - [Menu( - "Mouse settle delay (ms)", - "Delay after moving the cursor before pressing the mouse button.", - 408, - InputTimingGroupId)] - public RangeNode MouseSettleDelayMilliseconds { get; set; } = - new(50, 0, 1000); - - [Menu( - "Mouse button hold time (ms)", - "Time between the left mouse button down and up events.", - 409, - InputTimingGroupId)] - public RangeNode MouseButtonHoldMilliseconds { get; set; } = - new(50, 1, 500); - - [Menu( - "Click release delay (ms)", - "Delay before releasing the shared input lock after clicking.", - 410, - InputTimingGroupId)] - public RangeNode ClickReleaseDelayMilliseconds { get; set; } = - new(50, 0, 1000); - - // ───────────────────────────────────────────── - // Compatibility - // ───────────────────────────────────────────── - - [Menu( - "Advanced compatibility", - "Internal UI indexes. Change these only when a game update changes the exchange UI.", - CompatibilityGroupId)] - public EmptyNode CompatibilityGroup { get; set; } = new(); - - - [Menu( - "I Want button child index", - "Child index of the I Want currency-selector button.", + "Maximum Concurrent Trades", + "How many exchange orders the game allows at the same time.", 501, - CompatibilityGroupId)] - public RangeNode IWantButtonChildIndex { get; set; } = - new(7, 0, 50); - - [Menu( - "I Have button child index", - "Child index of the I Have currency-selector button.", - 502, - CompatibilityGroupId)] - public RangeNode IHaveButtonChildIndex { get; set; } = - new(10, 0, 50); - - [Menu( - "Currency search input child index", - "Child index of the currency-picker search input.", - 503, - CompatibilityGroupId)] - public RangeNode CurrencyPickerSearchInputChildIndex { get; set; } = - new(4, 0, 50); - - [Menu( - "Sell button child index", - "Child index of the final sell button.", - 504, - CompatibilityGroupId)] - public RangeNode SellButtonChildIndex { get; set; } = - new(16, 0, 50); - - [Menu( - "Market ratio panel index", - "Child index of the market ratio panel.", - 505, - CompatibilityGroupId)] - public RangeNode MarketRatioPanelIndex { get; set; } = - new(14, 0, 50); - - - + AdvancedGroupId)] + public RangeNode MaxConcurrentTrades { get; set; } = + new(10, 1, 20); + + /// + /// Returns the base action delay randomized by plus/minus 10 percent, + /// so a configured value of 100 yields 90-110 milliseconds. + /// + public int GetRandomizedActionDelay() + { + var baseDelay = ActionDelayMilliseconds.Value; - // ───────────────────────────────────────────── - // Exclude Currency - // ───────────────────────────────────────────── + if (baseDelay <= 0) + return 0; + return (int)Math.Round( + baseDelay * (0.9 + _delayRandom.NextDouble() * 0.2)); + } [Menu( - "Excluded currencies", + "Excluded Currencies", "Currencies that should not be displayed or sold.", ExcludedCurrenciesGroupId)] public EmptyNode ExcludedCurrenciesGroup { get; set; } = new(); - [Newtonsoft.Json.JsonIgnore] + [JsonIgnore] [Menu( - "Currency list", + "Currency List", "Add, edit, or remove excluded currencies.", 601, ExcludedCurrenciesGroupId)] public CustomNode ExcludedCurrenciesEditor { get; } + /// + /// JSON-serialized excluded-currency list; the value ExileAPI actually + /// persists to the settings file. + /// maintains a deserialized cache on top of it. + /// [HideInReflection] public TextNode ExcludedCurrenciesJson { get; set; } = new(DefaultExcludedCurrenciesJson); + /// Returns whether the given currency is on the excluded list. public bool IsCurrencyExcluded(string currencyName) { if (string.IsNullOrWhiteSpace(currencyName)) @@ -394,14 +280,12 @@ private void DrawExcludedCurrenciesEditor() if (excludedCurrencies.Count == 0) { - ImGui.TextDisabled("No currencies excluded."); + ImGui.TextDisabled("No currencies are excluded."); ImGui.PopID(); return; } - for (var index = 0; - index < excludedCurrencies.Count; - index++) + for (var index = 0; index < excludedCurrencies.Count; index++) { ImGui.PushID(index); @@ -442,15 +326,13 @@ private void AddExcludedCurrency() if (string.IsNullOrWhiteSpace(value)) return; - var excludedCurrencies = - GetExcludedCurrencies(); + var excludedCurrencies = GetExcludedCurrencies(); - var alreadyExists = - excludedCurrencies.Any(existing => - string.Equals( - existing?.Trim(), - value, - StringComparison.OrdinalIgnoreCase)); + var alreadyExists = excludedCurrencies.Any(existing => + string.Equals( + existing?.Trim(), + value, + StringComparison.OrdinalIgnoreCase)); if (!alreadyExists) { @@ -461,6 +343,11 @@ private void AddExcludedCurrency() _newExcludedCurrency = string.Empty; } + /// + /// Returns the cached excluded-currency list, re-deserializing it + /// whenever ExileAPI restores a different JSON value from the settings + /// file than the one the cache was built from. + /// private List GetExcludedCurrencies() { ExcludedCurrenciesJson ??= @@ -471,10 +358,6 @@ private List GetExcludedCurrencies() if (string.IsNullOrWhiteSpace(serialized)) serialized = DefaultExcludedCurrenciesJson; - /* - * Reload the cache when ExileAPI restores a different - * JSON value from the settings file. - */ if (_excludedCurrencies != null && string.Equals( _loadedExcludedCurrenciesJson, @@ -507,35 +390,29 @@ private List GetExcludedCurrencies() return _excludedCurrencies; } - - - + /// + /// Serializes the excluded-currency list into + /// . Assigning the node's value + /// fires its change event, which makes ExileAPI persist the settings. + /// private void SaveExcludedCurrencies() { _excludedCurrencies ??= []; var serialized = - JsonConvert.SerializeObject( - _excludedCurrencies); - - _loadedExcludedCurrenciesJson = - serialized; - - /* - * Assigning TextNode.Value fires OnValueChanged, - * causing ExileAPI to persist the settings. - */ - ExcludedCurrenciesJson.Value = - serialized; - } + JsonConvert.SerializeObject(_excludedCurrencies); + _loadedExcludedCurrenciesJson = serialized; + + ExcludedCurrenciesJson.Value = serialized; + } } + /// Column names selectable as the item table's default sort. public static class SortOptions { public const string Name = "Name"; public const string Value = "Value"; public const string Owned = "Owned"; } - -} \ No newline at end of file +}