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 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 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 SalvageItems(List 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 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 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 WaitForAreaTransition(int timeoutMs, Func? triggerAction = null) { var tcs = new TaskCompletionSource(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 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); } } }