480 lines
17 KiB
C#
480 lines
17 KiB
C#
using Poe2Trade.Core;
|
|
using Poe2Trade.Game;
|
|
using Poe2Trade.GameLog;
|
|
using Poe2Trade.Screen;
|
|
using Serilog;
|
|
|
|
namespace Poe2Trade.Inventory;
|
|
|
|
public class InventoryManager : IInventoryManager
|
|
{
|
|
private static readonly string SalvageTemplate = Path.Combine("assets", "salvage.png");
|
|
|
|
|
|
public event Action? Updated;
|
|
public InventoryTracker Tracker { get; } = new();
|
|
|
|
private bool _atOwnHideout = true;
|
|
private string _sellerAccount = "";
|
|
private string? _currentFolder;
|
|
private string? _currentTab;
|
|
private readonly IGameController _game;
|
|
private readonly IScreenReader _screen;
|
|
private readonly IClientLogWatcher _logWatcher;
|
|
private readonly SavedSettings _config;
|
|
|
|
public byte[]? LastScreenshot { get; private set; }
|
|
public bool IsAtOwnHideout => _atOwnHideout;
|
|
public string SellerAccount => _sellerAccount;
|
|
|
|
public InventoryManager(IGameController game, IScreenReader screen, IClientLogWatcher logWatcher, SavedSettings config)
|
|
{
|
|
_game = game;
|
|
_screen = screen;
|
|
_logWatcher = logWatcher;
|
|
_config = config;
|
|
}
|
|
|
|
public void SetLocation(bool atHome, string? seller = null)
|
|
{
|
|
_atOwnHideout = atHome;
|
|
_sellerAccount = seller ?? "";
|
|
}
|
|
|
|
public async Task ScanInventory(PostAction defaultAction = PostAction.Stash)
|
|
{
|
|
Log.Information("Scanning inventory...");
|
|
await _game.FocusGame();
|
|
await Helpers.Sleep(Delays.PostFocus);
|
|
await _game.OpenInventory();
|
|
|
|
var result = await _screen.Grid.Scan("inventory");
|
|
LastScreenshot = await _screen.CaptureRegion(GridLayouts.Inventory.Region);
|
|
|
|
var cells = new bool[5, 12];
|
|
foreach (var cell in result.Occupied)
|
|
{
|
|
if (cell.Row < 5 && cell.Col < 12)
|
|
cells[cell.Row, cell.Col] = true;
|
|
}
|
|
Tracker.InitFromScan(cells, result.Items, defaultAction);
|
|
Updated?.Invoke();
|
|
|
|
await _game.PressEscape();
|
|
await Helpers.Sleep(Delays.PostFocus);
|
|
}
|
|
|
|
public async Task ClearToStash()
|
|
{
|
|
Log.Information("Checking inventory for leftover items...");
|
|
await ScanInventory(PostAction.Stash);
|
|
|
|
if (Tracker.GetItems().Count == 0)
|
|
{
|
|
Log.Information("Inventory empty, nothing to clear");
|
|
return;
|
|
}
|
|
|
|
Log.Information("Found {Count} leftover items, depositing to stash", Tracker.GetItems().Count);
|
|
await DepositItemsToStash(Tracker.GetItems());
|
|
Tracker.Clear();
|
|
Log.Information("Inventory cleared to stash");
|
|
}
|
|
|
|
public async Task<bool> EnsureAtOwnHideout()
|
|
{
|
|
if (_atOwnHideout)
|
|
{
|
|
Log.Information("Already at own hideout");
|
|
return true;
|
|
}
|
|
|
|
await _game.FocusGame();
|
|
await Helpers.Sleep(Delays.PostFocus);
|
|
|
|
var arrived = await WaitForAreaTransition(_config.TravelTimeoutMs, () => _game.GoToHideout());
|
|
if (!arrived)
|
|
{
|
|
Log.Error("Timed out going to own hideout");
|
|
return false;
|
|
}
|
|
|
|
await Helpers.Sleep(Delays.PostTravel);
|
|
_atOwnHideout = true;
|
|
_sellerAccount = "";
|
|
return true;
|
|
}
|
|
|
|
public async Task DepositItemsToStash(List<PlacedItem> items)
|
|
{
|
|
if (items.Count == 0) return;
|
|
|
|
var stashPos = await FindAndClickNameplate("Stash");
|
|
if (stashPos == null)
|
|
{
|
|
Log.Error("Could not find Stash nameplate");
|
|
return;
|
|
}
|
|
await Helpers.Sleep(Delays.PostStashOpen);
|
|
|
|
Log.Information("Depositing {Count} items to stash", items.Count);
|
|
await CtrlClickItems(items, GridLayouts.Inventory);
|
|
|
|
await _game.PressEscape();
|
|
await Helpers.Sleep(Delays.PostEscape);
|
|
Log.Information("Items deposited to stash");
|
|
}
|
|
|
|
public async Task<bool> SalvageItems(List<PlacedItem> items)
|
|
{
|
|
if (items.Count == 0) return true;
|
|
|
|
var nameplate = await FindAndClickNameplate("SALVAGE BENCH");
|
|
if (nameplate == null)
|
|
{
|
|
Log.Error("Could not find Salvage nameplate");
|
|
return false;
|
|
}
|
|
await Helpers.Sleep(Delays.PostStashOpen);
|
|
|
|
var salvageBtn = await _screen.TemplateMatch(SalvageTemplate);
|
|
if (salvageBtn != null)
|
|
{
|
|
await _game.LeftClickAt(salvageBtn.X, salvageBtn.Y);
|
|
await Helpers.Sleep(Delays.PostEscape);
|
|
}
|
|
else
|
|
{
|
|
Log.Warning("Could not find salvage button via template match");
|
|
}
|
|
|
|
Log.Information("Salvaging {Count} inventory items", items.Count);
|
|
await CtrlClickItems(items, GridLayouts.Inventory);
|
|
|
|
await _game.PressEscape();
|
|
await Helpers.Sleep(Delays.PostEscape);
|
|
return true;
|
|
}
|
|
|
|
public async Task<bool> IdentifyItems()
|
|
{
|
|
var nameplate = await FindAndClickNameplate("Doryani");
|
|
if (nameplate == null)
|
|
{
|
|
Log.Error("Could not find Doryani nameplate");
|
|
return false;
|
|
}
|
|
await Helpers.Sleep(Delays.PostStashOpen);
|
|
|
|
// Dialog appears below and to the right of the nameplate
|
|
var dialogRegion = new Region(
|
|
nameplate.Value.X, nameplate.Value.Y,
|
|
460, 600);
|
|
var identifyPos = await _screen.FindTextInRegion(dialogRegion, "Identify");
|
|
if (identifyPos.HasValue)
|
|
{
|
|
await _game.LeftClickAt(identifyPos.Value.X, identifyPos.Value.Y);
|
|
await Helpers.Sleep(Delays.PostEscape);
|
|
}
|
|
else
|
|
{
|
|
Log.Warning("'Identify Items' not found in dialog region");
|
|
}
|
|
|
|
await _game.PressEscape();
|
|
await Helpers.Sleep(Delays.PostEscape);
|
|
return true;
|
|
}
|
|
|
|
private async Task CtrlClickItems(List<PlacedItem> items, GridLayout layout, int clickDelayMs = Delays.ClickInterval)
|
|
{
|
|
await _game.KeyDown(Game.InputSender.VK.SHIFT);
|
|
await _game.HoldCtrl();
|
|
foreach (var item in items)
|
|
{
|
|
var center = _screen.Grid.GetCellCenter(layout, item.Row, item.Col);
|
|
await _game.LeftClickAt(center.X, center.Y);
|
|
await Helpers.Sleep(clickDelayMs);
|
|
}
|
|
await _game.ReleaseCtrl();
|
|
await _game.KeyUp(Game.InputSender.VK.SHIFT);
|
|
await Helpers.Sleep(Delays.PostEscape);
|
|
}
|
|
|
|
public async Task ProcessInventory()
|
|
{
|
|
try
|
|
{
|
|
var home = await EnsureAtOwnHideout();
|
|
if (!home)
|
|
{
|
|
Log.Error("Cannot process inventory: failed to reach hideout");
|
|
return;
|
|
}
|
|
|
|
if (Tracker.HasItemsWithAction(PostAction.Salvage))
|
|
{
|
|
var salvageItems = Tracker.GetItemsByAction(PostAction.Salvage);
|
|
if (await SalvageItems(salvageItems))
|
|
Tracker.RemoveItemsByAction(PostAction.Salvage);
|
|
else
|
|
Log.Warning("Salvage failed, depositing all to stash");
|
|
}
|
|
|
|
await ScanInventory(PostAction.Stash);
|
|
|
|
var allItems = Tracker.GetItems();
|
|
if (allItems.Count > 0)
|
|
await DepositItemsToStash(allItems);
|
|
|
|
Tracker.Clear();
|
|
Log.Information("Inventory processing complete");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Error(ex, "Inventory processing failed");
|
|
try { await _game.PressEscape(); await Helpers.Sleep(Delays.PostFocus); } catch { }
|
|
Tracker.Clear();
|
|
}
|
|
}
|
|
|
|
public async Task<(int X, int Y)?> FindAndClickNameplate(string name, int maxRetries = 3, int retryDelayMs = 1000, System.Drawing.Rectangle? scanRegion = null, string? savePath = null)
|
|
{
|
|
for (var attempt = 1; attempt <= maxRetries; attempt++)
|
|
{
|
|
Log.Information("Searching for nameplate '{Name}' (attempt {Attempt}/{Max})", name, attempt, maxRetries);
|
|
|
|
// Move mouse to bottom-left so it doesn't cover nameplates
|
|
_game.MoveMouseInstant(0, 1440);
|
|
await Helpers.Sleep(100);
|
|
|
|
// Nameplates hidden by default — capture clean reference
|
|
using var reference = _screen.CaptureRawBitmap();
|
|
|
|
// Hold Alt to show nameplates, capture, then release
|
|
await _game.KeyDown(Game.InputSender.VK.MENU);
|
|
await Helpers.Sleep(50);
|
|
using var current = _screen.CaptureRawBitmap();
|
|
await _game.KeyUp(Game.InputSender.VK.MENU);
|
|
|
|
// Diff OCR — only processes the bright nameplate regions
|
|
var attemptSavePath = savePath != null
|
|
? Path.Combine(Path.GetDirectoryName(savePath)!, $"{Path.GetFileNameWithoutExtension(savePath)}_attempt{attempt}{Path.GetExtension(savePath)}")
|
|
: null;
|
|
var result = await _screen.NameplateDiffOcr(reference, current, scanRegion, attemptSavePath);
|
|
var pos = FindWordInOcrResult(result, name, fuzzy: true);
|
|
if (pos.HasValue)
|
|
{
|
|
Log.Information("Clicking nameplate '{Name}' at ({X},{Y})", name, pos.Value.X, pos.Value.Y);
|
|
await _game.LeftClickAt(pos.Value.X, pos.Value.Y);
|
|
return pos;
|
|
}
|
|
|
|
Log.Information("Nameplate '{Name}' not found in diff OCR (attempt {Attempt}), text: {Text}", name, attempt, result.Text);
|
|
if (attempt < maxRetries)
|
|
await Helpers.Sleep(retryDelayMs);
|
|
}
|
|
|
|
Log.Warning("Nameplate '{Name}' not found after {Max} retries", name, maxRetries);
|
|
return null;
|
|
}
|
|
|
|
private static (int X, int Y)? FindWordInOcrResult(OcrResponse result, string needle, bool fuzzy)
|
|
{
|
|
var lower = needle.ToLowerInvariant();
|
|
|
|
// Multi-word: match against full line text
|
|
if (lower.Contains(' '))
|
|
{
|
|
foreach (var line in result.Lines)
|
|
{
|
|
if (line.Words.Count == 0) continue;
|
|
if (line.Text.Contains(needle, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var first = line.Words[0];
|
|
var last = line.Words[^1];
|
|
return ((first.X + last.X + last.Width) / 2, (first.Y + last.Y + last.Height) / 2);
|
|
}
|
|
if (fuzzy)
|
|
{
|
|
var sim = BigramSimilarity(Normalize(needle), Normalize(line.Text));
|
|
if (sim >= 0.55)
|
|
{
|
|
var first = line.Words[0];
|
|
var last = line.Words[^1];
|
|
return ((first.X + last.X + last.Width) / 2, (first.Y + last.Y + last.Height) / 2);
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Single word — prefer exact line match ("STASH") over substring ("Guild Stash")
|
|
(int X, int Y)? containsMatch = null;
|
|
(int X, int Y)? fuzzyMatch = null;
|
|
|
|
foreach (var line in result.Lines)
|
|
{
|
|
// Exact line match — the entire line is just this word
|
|
if (line.Text.Equals(needle, StringComparison.OrdinalIgnoreCase) && line.Words.Count > 0)
|
|
{
|
|
var first = line.Words[0];
|
|
var last = line.Words[^1];
|
|
return ((first.X + last.X + last.Width) / 2, (first.Y + last.Y + last.Height) / 2);
|
|
}
|
|
|
|
foreach (var word in line.Words)
|
|
{
|
|
if (word.Text.Equals(needle, StringComparison.OrdinalIgnoreCase))
|
|
return (word.X + word.Width / 2, word.Y + word.Height / 2);
|
|
|
|
containsMatch ??= word.Text.Contains(needle, StringComparison.OrdinalIgnoreCase)
|
|
? (word.X + word.Width / 2, word.Y + word.Height / 2)
|
|
: null;
|
|
|
|
if (fuzzy)
|
|
fuzzyMatch ??= BigramSimilarity(Normalize(needle), Normalize(word.Text)) >= 0.55
|
|
? (word.X + word.Width / 2, word.Y + word.Height / 2)
|
|
: null;
|
|
}
|
|
}
|
|
|
|
return containsMatch ?? fuzzyMatch;
|
|
}
|
|
|
|
private static string Normalize(string s) =>
|
|
new(s.ToLowerInvariant().Where(char.IsLetterOrDigit).ToArray());
|
|
|
|
private static double BigramSimilarity(string a, string b)
|
|
{
|
|
if (a.Length < 2 || b.Length < 2) return a == b ? 1 : 0;
|
|
var bigramsA = new Dictionary<(char, char), int>();
|
|
for (var i = 0; i < a.Length - 1; i++)
|
|
{
|
|
var bg = (a[i], a[i + 1]);
|
|
bigramsA[bg] = bigramsA.GetValueOrDefault(bg) + 1;
|
|
}
|
|
var matches = 0;
|
|
for (var i = 0; i < b.Length - 1; i++)
|
|
{
|
|
var bg = (b[i], b[i + 1]);
|
|
if (bigramsA.TryGetValue(bg, out var count) && count > 0)
|
|
{
|
|
matches++;
|
|
bigramsA[bg] = count - 1;
|
|
}
|
|
}
|
|
return 2.0 * matches / (a.Length - 1 + b.Length - 1);
|
|
}
|
|
|
|
public async Task<bool> WaitForAreaTransition(int timeoutMs, Func<Task>? triggerAction = null)
|
|
{
|
|
var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
using var cts = new CancellationTokenSource(timeoutMs);
|
|
cts.Token.Register(() => tcs.TrySetResult(false));
|
|
|
|
void Handler(string _) => tcs.TrySetResult(true);
|
|
|
|
_logWatcher.AreaEntered += Handler;
|
|
try
|
|
{
|
|
if (triggerAction != null)
|
|
{
|
|
try { await triggerAction(); }
|
|
catch
|
|
{
|
|
tcs.TrySetResult(false);
|
|
}
|
|
}
|
|
return await tcs.Task;
|
|
}
|
|
finally
|
|
{
|
|
_logWatcher.AreaEntered -= Handler;
|
|
}
|
|
}
|
|
|
|
public (bool[,] Grid, List<PlacedItem> Items, int Free) GetInventoryState()
|
|
{
|
|
return (Tracker.GetGrid(), Tracker.GetItems(), Tracker.FreeCells);
|
|
}
|
|
|
|
public async Task ClickStashTab(StashTabInfo tab, StashTabInfo? parentFolder = null)
|
|
{
|
|
var folderName = parentFolder?.Name;
|
|
var tabName = tab.Name;
|
|
|
|
// Already on the right folder and tab — skip
|
|
if (_currentFolder == folderName && _currentTab == tabName)
|
|
{
|
|
Log.Debug("Already on tab '{Tab}' (folder '{Folder}'), skipping", tabName, folderName ?? "none");
|
|
return;
|
|
}
|
|
|
|
if (parentFolder != null && _currentFolder != folderName)
|
|
{
|
|
Log.Information("Clicking folder '{Folder}' at ({X},{Y})", parentFolder.Name, parentFolder.ClickX, parentFolder.ClickY);
|
|
await _game.LeftClickAt(parentFolder.ClickX, parentFolder.ClickY);
|
|
await Helpers.Sleep(200);
|
|
}
|
|
|
|
Log.Information("Clicking tab '{Tab}' at ({X},{Y})", tab.Name, tab.ClickX, tab.ClickY);
|
|
await _game.LeftClickAt(tab.ClickX, tab.ClickY);
|
|
await Helpers.Sleep(Delays.PostStashOpen);
|
|
|
|
_currentFolder = folderName;
|
|
_currentTab = tabName;
|
|
}
|
|
|
|
public void ResetStashTabState()
|
|
{
|
|
_currentFolder = null;
|
|
_currentTab = null;
|
|
}
|
|
|
|
public async Task GrabItemsFromStash(string layoutName, int maxItems, string? templatePath = null)
|
|
{
|
|
Log.Information("Grabbing up to {Max} items from stash layout '{Layout}' (template={Template})",
|
|
maxItems, layoutName, templatePath ?? "none");
|
|
|
|
var layout = GridLayouts.All[layoutName];
|
|
|
|
if (templatePath != null)
|
|
{
|
|
// Template matching mode: repeatedly find and click matching items
|
|
var grabbed = 0;
|
|
await _game.HoldCtrl();
|
|
while (grabbed < maxItems)
|
|
{
|
|
var match = await _screen.TemplateMatch(templatePath, layout.Region);
|
|
if (match == null) break;
|
|
|
|
Log.Information("Template match at ({X},{Y}), grabbing", match.X, match.Y);
|
|
await _game.LeftClickAt(match.X, match.Y);
|
|
await Helpers.Sleep(Delays.ClickInterval);
|
|
grabbed++;
|
|
}
|
|
await _game.ReleaseCtrl();
|
|
await Helpers.Sleep(Delays.PostEscape);
|
|
Log.Information("Grabbed {Count} matching items from stash", grabbed);
|
|
}
|
|
else
|
|
{
|
|
// Grid scan mode: grab all occupied cells
|
|
var result = await _screen.Grid.Scan(layoutName);
|
|
var grabbed = 0;
|
|
await _game.HoldCtrl();
|
|
foreach (var cell in result.Occupied)
|
|
{
|
|
if (grabbed >= maxItems) break;
|
|
var center = _screen.Grid.GetCellCenter(layout, cell.Row, cell.Col);
|
|
await _game.LeftClickAt(center.X, center.Y);
|
|
await Helpers.Sleep(Delays.ClickInterval);
|
|
grabbed++;
|
|
}
|
|
await _game.ReleaseCtrl();
|
|
await Helpers.Sleep(Delays.PostEscape);
|
|
Log.Information("Grabbed {Count} items from stash", grabbed);
|
|
}
|
|
}
|
|
}
|