rename
This commit is contained in:
parent
bef61f841d
commit
c3de5fdb63
107 changed files with 0 additions and 0 deletions
29
src/Automata.Inventory/IInventoryManager.cs
Normal file
29
src/Automata.Inventory/IInventoryManager.cs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
using Poe2Trade.Core;
|
||||
using Poe2Trade.Screen;
|
||||
|
||||
namespace Poe2Trade.Inventory;
|
||||
|
||||
public interface IInventoryManager
|
||||
{
|
||||
event Action? Updated;
|
||||
InventoryTracker Tracker { get; }
|
||||
byte[]? LastScreenshot { get; }
|
||||
bool IsAtOwnHideout { get; }
|
||||
string SellerAccount { get; }
|
||||
void SetLocation(bool atHome, string? seller = null);
|
||||
Task ScanInventory(PostAction defaultAction = PostAction.Stash);
|
||||
Task<ScanResult> SnapshotInventory();
|
||||
Task ClearToStash();
|
||||
Task<bool> EnsureAtOwnHideout();
|
||||
Task ProcessInventory();
|
||||
Task<bool> WaitForAreaTransition(int timeoutMs, Func<Task>? triggerAction = null);
|
||||
Task<(int X, int Y)?> FindAndClickNameplate(string name, int maxRetries = 3, int retryDelayMs = 1000, System.Drawing.Rectangle? scanRegion = null, string? savePath = null);
|
||||
Task DepositItemsToStash(List<PlacedItem> items);
|
||||
Task DepositAllToOpenStash();
|
||||
Task<bool> SalvageItems(List<PlacedItem> items);
|
||||
Task<bool> IdentifyItems();
|
||||
(bool[,] Grid, List<PlacedItem> Items, int Free) GetInventoryState();
|
||||
Task ClickStashTab(StashTabInfo tab, StashTabInfo? parentFolder = null);
|
||||
void ResetStashTabState();
|
||||
Task GrabItemsFromStash(string layoutName, int maxItems, string? templatePath = null);
|
||||
}
|
||||
551
src/Automata.Inventory/InventoryManager.cs
Normal file
551
src/Automata.Inventory/InventoryManager.cs
Normal file
|
|
@ -0,0 +1,551 @@
|
|||
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();
|
||||
|
||||
_game.MoveMouseInstant(1700, 700);
|
||||
await Helpers.Sleep(100);
|
||||
|
||||
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<ScanResult> SnapshotInventory()
|
||||
{
|
||||
_game.MoveMouseInstant(1700, 700);
|
||||
await Helpers.Sleep(100);
|
||||
var result = await _screen.Grid.Scan("inventory");
|
||||
LastScreenshot = await _screen.CaptureRegion(GridLayouts.Inventory.Region);
|
||||
Updated?.Invoke();
|
||||
return result;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
await DepositAllToOpenStash();
|
||||
|
||||
await _game.PressEscape();
|
||||
await Helpers.Sleep(Delays.PostEscape);
|
||||
Log.Information("Deposit complete");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deposit one item at a time while stash is already open.
|
||||
/// Scans → clicks first occupied cell → rescans → repeats.
|
||||
/// Cells that remain occupied after clicking are marked as false positives and skipped.
|
||||
/// </summary>
|
||||
public async Task DepositAllToOpenStash()
|
||||
{
|
||||
await _game.KeyDown(Game.InputSender.VK.SHIFT);
|
||||
await _game.HoldCtrl();
|
||||
|
||||
var falsePositives = new HashSet<(int Row, int Col)>();
|
||||
(int Row, int Col)? lastClicked = null;
|
||||
|
||||
for (var i = 0; i < 60; i++)
|
||||
{
|
||||
if (lastClicked == null)
|
||||
{
|
||||
_game.MoveMouseInstant(1700, 700);
|
||||
await Helpers.Sleep(50);
|
||||
}
|
||||
var scan = await _screen.Grid.Scan("inventory");
|
||||
LastScreenshot = await _screen.CaptureRegion(GridLayouts.Inventory.Region);
|
||||
Updated?.Invoke();
|
||||
|
||||
// If we clicked a cell last iteration, check if it's still there (= false positive)
|
||||
if (lastClicked.HasValue &&
|
||||
scan.Occupied.Any(c => c.Row == lastClicked.Value.Row && c.Col == lastClicked.Value.Col))
|
||||
{
|
||||
Log.Debug("Cell ({Row},{Col}) still occupied after click — false positive",
|
||||
lastClicked.Value.Row, lastClicked.Value.Col);
|
||||
falsePositives.Add(lastClicked.Value);
|
||||
}
|
||||
lastClicked = null;
|
||||
|
||||
var candidates = scan.Occupied
|
||||
.Where(c => !falsePositives.Contains((c.Row, c.Col)))
|
||||
.ToList();
|
||||
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
Log.Information(scan.Occupied.Count > 0
|
||||
? $"All {scan.Occupied.Count} remaining occupied cells are false positives, done"
|
||||
: "Inventory empty");
|
||||
break;
|
||||
}
|
||||
|
||||
var cell = candidates[0];
|
||||
var center = _screen.Grid.GetCellCenter(GridLayouts.Inventory, cell.Row, cell.Col);
|
||||
Log.Information("Depositing cell ({Row},{Col}) — {Real} candidates / {Total} detected",
|
||||
cell.Row, cell.Col, candidates.Count, scan.Occupied.Count);
|
||||
_game.MoveMouseInstant(center.X, center.Y);
|
||||
await Helpers.RandomDelay(50, 100);
|
||||
_game.LeftMouseDown();
|
||||
_game.LeftMouseUp();
|
||||
lastClicked = (cell.Row, cell.Col);
|
||||
await Helpers.RandomDelay(50, 100);
|
||||
}
|
||||
|
||||
await _game.ReleaseCtrl();
|
||||
await _game.KeyUp(Game.InputSender.VK.SHIFT);
|
||||
}
|
||||
|
||||
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);
|
||||
await DepositItemsToStash(Tracker.GetItems());
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
126
src/Automata.Inventory/InventoryTracker.cs
Normal file
126
src/Automata.Inventory/InventoryTracker.cs
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
using Poe2Trade.Core;
|
||||
using Poe2Trade.Screen;
|
||||
using Serilog;
|
||||
|
||||
namespace Poe2Trade.Inventory;
|
||||
|
||||
public class PlacedItem
|
||||
{
|
||||
public int Row { get; init; }
|
||||
public int Col { get; init; }
|
||||
public int W { get; init; }
|
||||
public int H { get; init; }
|
||||
public PostAction PostAction { get; init; }
|
||||
}
|
||||
|
||||
public class InventoryTracker
|
||||
{
|
||||
private const int Rows = 5;
|
||||
private const int Cols = 12;
|
||||
|
||||
private readonly bool[,] _grid = new bool[Rows, Cols];
|
||||
private readonly List<PlacedItem> _items = [];
|
||||
|
||||
public void InitFromScan(bool[,] cells, List<GridItem> items, PostAction defaultAction = PostAction.Stash)
|
||||
{
|
||||
Array.Clear(_grid);
|
||||
_items.Clear();
|
||||
|
||||
var rowCount = Math.Min(cells.GetLength(0), Rows);
|
||||
var colCount = Math.Min(cells.GetLength(1), Cols);
|
||||
for (var r = 0; r < rowCount; r++)
|
||||
for (var c = 0; c < colCount; c++)
|
||||
_grid[r, c] = cells[r, c];
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (item.W > 2 || item.H > 4)
|
||||
{
|
||||
Log.Warning("Ignoring oversized item at ({Row},{Col}) {W}x{H}", item.Row, item.Col, item.W, item.H);
|
||||
continue;
|
||||
}
|
||||
_items.Add(new PlacedItem { Row = item.Row, Col = item.Col, W = item.W, H = item.H, PostAction = defaultAction });
|
||||
}
|
||||
|
||||
Log.Information("Inventory initialized: {Occupied} occupied, {Items} items, {Free} free",
|
||||
Rows * Cols - FreeCells, _items.Count, FreeCells);
|
||||
}
|
||||
|
||||
public (int Row, int Col)? TryPlace(int w, int h, PostAction postAction = PostAction.Stash)
|
||||
{
|
||||
for (var col = 0; col <= Cols - w; col++)
|
||||
for (var row = 0; row <= Rows - h; row++)
|
||||
{
|
||||
if (!Fits(row, col, w, h)) continue;
|
||||
Place(row, col, w, h, postAction);
|
||||
Log.Information("Item placed at ({Row},{Col}) {W}x{H} free={Free}", row, col, w, h, FreeCells);
|
||||
return (row, col);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public bool CanFit(int w, int h)
|
||||
{
|
||||
for (var col = 0; col <= Cols - w; col++)
|
||||
for (var row = 0; row <= Rows - h; row++)
|
||||
if (Fits(row, col, w, h)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
public List<PlacedItem> GetItems() => [.. _items];
|
||||
public List<PlacedItem> GetItemsByAction(PostAction action) => _items.Where(i => i.PostAction == action).ToList();
|
||||
public bool HasItemsWithAction(PostAction action) => _items.Any(i => i.PostAction == action);
|
||||
|
||||
public void RemoveItem(PlacedItem item)
|
||||
{
|
||||
if (!_items.Remove(item)) return;
|
||||
for (var r = item.Row; r < item.Row + item.H; r++)
|
||||
for (var c = item.Col; c < item.Col + item.W; c++)
|
||||
_grid[r, c] = false;
|
||||
}
|
||||
|
||||
public void RemoveItemsByAction(PostAction action)
|
||||
{
|
||||
var toRemove = _items.Where(i => i.PostAction == action).ToList();
|
||||
foreach (var item in toRemove)
|
||||
RemoveItem(item);
|
||||
Log.Information("Removed {Count} items with action {Action}", toRemove.Count, action);
|
||||
}
|
||||
|
||||
public bool[,] GetGrid() => (bool[,])_grid.Clone();
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
Array.Clear(_grid);
|
||||
_items.Clear();
|
||||
Log.Information("Inventory cleared");
|
||||
}
|
||||
|
||||
public int FreeCells
|
||||
{
|
||||
get
|
||||
{
|
||||
var count = 0;
|
||||
for (var r = 0; r < Rows; r++)
|
||||
for (var c = 0; c < Cols; c++)
|
||||
if (!_grid[r, c]) count++;
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
private bool Fits(int row, int col, int w, int h)
|
||||
{
|
||||
for (var r = row; r < row + h; r++)
|
||||
for (var c = col; c < col + w; c++)
|
||||
if (_grid[r, c]) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private void Place(int row, int col, int w, int h, PostAction postAction)
|
||||
{
|
||||
for (var r = row; r < row + h; r++)
|
||||
for (var c = col; c < col + w; c++)
|
||||
_grid[r, c] = true;
|
||||
_items.Add(new PlacedItem { Row = row, Col = col, W = w, H = h, PostAction = postAction });
|
||||
}
|
||||
}
|
||||
13
src/Automata.Inventory/Poe2Trade.Inventory.csproj
Normal file
13
src/Automata.Inventory/Poe2Trade.Inventory.csproj
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Poe2Trade.Core\Poe2Trade.Core.csproj" />
|
||||
<ProjectReference Include="..\Poe2Trade.Game\Poe2Trade.Game.csproj" />
|
||||
<ProjectReference Include="..\Poe2Trade.Screen\Poe2Trade.Screen.csproj" />
|
||||
<ProjectReference Include="..\Poe2Trade.Log\Poe2Trade.Log.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
192
src/Automata.Inventory/StashCalibrator.cs
Normal file
192
src/Automata.Inventory/StashCalibrator.cs
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
using Poe2Trade.Core;
|
||||
using Poe2Trade.Game;
|
||||
using Poe2Trade.Screen;
|
||||
using Serilog;
|
||||
|
||||
namespace Poe2Trade.Inventory;
|
||||
|
||||
public class StashCalibrator
|
||||
{
|
||||
private readonly IScreenReader _screen;
|
||||
private readonly IGameController _game;
|
||||
|
||||
// Tab bar sits above the stash grid
|
||||
private static readonly Region TabBarRegion = new(23, 95, 840, 75);
|
||||
// Sub-tab row between tab bar and folder grid (folders push grid down)
|
||||
private static readonly Region SubTabRegion = new(23, 165, 840, 50);
|
||||
// Horizontal gap (px) between OCR words to split into separate tab names
|
||||
private const int TabGapThreshold = 25;
|
||||
private const int PostTabClickMin = 120;
|
||||
private const int PostTabClickMax = 250;
|
||||
|
||||
public StashCalibrator(IScreenReader screen, IGameController game)
|
||||
{
|
||||
_screen = screen;
|
||||
_game = game;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calibrates an already-open stash/shop panel.
|
||||
/// OCRs tab bar, clicks each tab, detects folders and grid size.
|
||||
/// When firstFolderOnly is true (shop), only the first folder is inspected.
|
||||
/// </summary>
|
||||
public async Task<StashCalibration> CalibrateOpenPanel(bool firstFolderOnly = false)
|
||||
{
|
||||
var tabs = await OcrTabBar(TabBarRegion);
|
||||
Log.Information("StashCalibrator: found {Count} tabs: {Names}",
|
||||
tabs.Count, string.Join(", ", tabs.Select(t => t.Name)));
|
||||
|
||||
var limit = firstFolderOnly ? Math.Min(1, tabs.Count) : tabs.Count;
|
||||
|
||||
for (var i = 0; i < limit; i++)
|
||||
{
|
||||
var tab = tabs[i];
|
||||
tab.Index = i;
|
||||
|
||||
// Click this tab
|
||||
await _game.LeftClickAt(tab.ClickX, tab.ClickY);
|
||||
await Helpers.RandomDelay(PostTabClickMin, PostTabClickMax);
|
||||
|
||||
// Check for sub-tabs (folder detection)
|
||||
var subTabs = await OcrTabBar(SubTabRegion);
|
||||
if (subTabs.Count > 0)
|
||||
{
|
||||
tab.IsFolder = true;
|
||||
Log.Information("StashCalibrator: tab '{Name}' is a folder with {Count} sub-tabs",
|
||||
tab.Name, subTabs.Count);
|
||||
|
||||
for (var j = 0; j < subTabs.Count; j++)
|
||||
{
|
||||
var sub = subTabs[j];
|
||||
sub.Index = j;
|
||||
|
||||
// Click sub-tab to detect its grid size
|
||||
await _game.LeftClickAt(sub.ClickX, sub.ClickY);
|
||||
await Helpers.RandomDelay(PostTabClickMin, PostTabClickMax);
|
||||
sub.GridCols = await DetectGridSize(isFolder: true);
|
||||
}
|
||||
|
||||
tab.SubTabs = subTabs;
|
||||
// Folder's own grid cols = first sub-tab's (they're usually the same)
|
||||
tab.GridCols = subTabs[0].GridCols;
|
||||
}
|
||||
else
|
||||
{
|
||||
tab.IsFolder = false;
|
||||
tab.GridCols = await DetectGridSize(isFolder: false);
|
||||
}
|
||||
}
|
||||
|
||||
// For firstFolderOnly, trim to only the inspected tabs
|
||||
if (firstFolderOnly && tabs.Count > limit)
|
||||
tabs = tabs.GetRange(0, limit);
|
||||
|
||||
return new StashCalibration
|
||||
{
|
||||
Tabs = tabs,
|
||||
CalibratedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OCR a region and group words into tab names by horizontal gap.
|
||||
/// Returns list with screen-absolute click positions.
|
||||
/// </summary>
|
||||
private async Task<List<StashTabInfo>> OcrTabBar(Region region)
|
||||
{
|
||||
// Save debug capture of the region
|
||||
Directory.CreateDirectory("debug");
|
||||
var tag = region == TabBarRegion ? "tabbar" : "subtab";
|
||||
await _screen.SaveRegion(region, $"debug/calibrate-{tag}-{DateTime.Now:HHmmss}.png");
|
||||
|
||||
var ocr = await _screen.Ocr(region);
|
||||
Log.Information("StashCalibrator: OCR region ({Tag}) raw text: '{Text}'", tag, ocr.Text);
|
||||
|
||||
var allWords = ocr.Lines.SelectMany(l => l.Words).ToList();
|
||||
if (allWords.Count == 0) return [];
|
||||
|
||||
return GroupWordsIntoTabs(allWords, region, TabGapThreshold);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Groups OCR words into tab names based on horizontal gaps.
|
||||
/// Words within gapThreshold px → same tab. Larger gaps → separate tabs.
|
||||
/// Converts region-relative coords to screen-absolute.
|
||||
/// </summary>
|
||||
private static List<StashTabInfo> GroupWordsIntoTabs(List<OcrWord> words, Region region, int gapThreshold)
|
||||
{
|
||||
// Sort left-to-right by X position
|
||||
var sorted = words.OrderBy(w => w.X).ToList();
|
||||
var tabs = new List<StashTabInfo>();
|
||||
|
||||
var currentWords = new List<OcrWord> { sorted[0] };
|
||||
|
||||
for (var i = 1; i < sorted.Count; i++)
|
||||
{
|
||||
var prev = currentWords[^1];
|
||||
var curr = sorted[i];
|
||||
var gap = curr.X - (prev.X + prev.Width);
|
||||
|
||||
if (gap > gapThreshold)
|
||||
{
|
||||
// Flush current group as a tab
|
||||
tabs.Add(BuildTab(currentWords, region));
|
||||
currentWords = [curr];
|
||||
}
|
||||
else
|
||||
{
|
||||
currentWords.Add(curr);
|
||||
}
|
||||
}
|
||||
|
||||
// Flush last group
|
||||
tabs.Add(BuildTab(currentWords, region));
|
||||
|
||||
return tabs;
|
||||
}
|
||||
|
||||
private static StashTabInfo BuildTab(List<OcrWord> words, Region region)
|
||||
{
|
||||
var name = string.Join(" ", words.Select(w => w.Text));
|
||||
var minX = words.Min(w => w.X);
|
||||
var maxX = words.Max(w => w.X + w.Width);
|
||||
var minY = words.Min(w => w.Y);
|
||||
var maxY = words.Max(w => w.Y + w.Height);
|
||||
|
||||
// Click center of the bounding box, converted to screen-absolute
|
||||
var clickX = region.X + (minX + maxX) / 2;
|
||||
var clickY = region.Y + (minY + maxY) / 2;
|
||||
|
||||
return new StashTabInfo
|
||||
{
|
||||
Name = name,
|
||||
ClickX = clickX,
|
||||
ClickY = clickY
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detect grid size (12 or 24 columns) by scanning with both layouts.
|
||||
/// The correct layout aligns with actual cells, so empty cells match the
|
||||
/// empty template → lower occupancy. The wrong layout misaligns → ~100% occupied.
|
||||
/// </summary>
|
||||
private async Task<int> DetectGridSize(bool isFolder)
|
||||
{
|
||||
var layout12 = isFolder ? "stash12_folder" : "stash12";
|
||||
var layout24 = isFolder ? "stash24_folder" : "stash24";
|
||||
|
||||
var scan12 = await _screen.Grid.Scan(layout12);
|
||||
var scan24 = await _screen.Grid.Scan(layout24);
|
||||
|
||||
var total12 = scan12.Layout.Cols * scan12.Layout.Rows;
|
||||
var total24 = scan24.Layout.Cols * scan24.Layout.Rows;
|
||||
|
||||
var rate12 = (double)scan12.Occupied.Count / total12;
|
||||
var rate24 = (double)scan24.Occupied.Count / total24;
|
||||
|
||||
Log.Information("StashCalibrator: grid detection - 12col={Rate12:P1} ({Occ12}/{Tot12}), 24col={Rate24:P1} ({Occ24}/{Tot24})",
|
||||
rate12, scan12.Occupied.Count, total12, rate24, scan24.Occupied.Count, total24);
|
||||
|
||||
return rate12 <= rate24 ? 12 : 24;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue