poe2-bot/src/Poe2Trade.Inventory/InventoryManager.cs
2026-02-22 14:21:32 -05:00

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);
}
}
}