249 lines
8.2 KiB
C#
249 lines
8.2 KiB
C#
using System.Diagnostics;
|
|
using Poe2Trade.Core;
|
|
using Poe2Trade.Game;
|
|
using Poe2Trade.Inventory;
|
|
using Poe2Trade.Screen;
|
|
using Serilog;
|
|
|
|
namespace Poe2Trade.Bot;
|
|
|
|
/// <summary>
|
|
/// Base class for game executors that interact with the game world.
|
|
/// Provides shared utilities: loot pickup, recovery, walking, stash tab resolution.
|
|
/// </summary>
|
|
public abstract class GameExecutor
|
|
{
|
|
protected static readonly Random Rng = new();
|
|
|
|
protected readonly IGameController _game;
|
|
protected readonly IScreenReader _screen;
|
|
protected readonly IInventoryManager _inventory;
|
|
protected readonly SavedSettings _config;
|
|
protected volatile bool _stopped;
|
|
protected CancellationTokenSource _stopCts = new();
|
|
|
|
/// <summary>Cancellation token that fires when Stop() is called.</summary>
|
|
protected CancellationToken StopToken => _stopCts.Token;
|
|
|
|
protected GameExecutor(IGameController game, IScreenReader screen,
|
|
IInventoryManager inventory, SavedSettings config)
|
|
{
|
|
_game = game;
|
|
_screen = screen;
|
|
_inventory = inventory;
|
|
_config = config;
|
|
}
|
|
|
|
public virtual void Stop()
|
|
{
|
|
_stopped = true;
|
|
_stopCts.Cancel();
|
|
}
|
|
|
|
/// <summary>Reset stopped state for a new run.</summary>
|
|
protected void ResetStop()
|
|
{
|
|
_stopped = false;
|
|
_stopCts.Dispose();
|
|
_stopCts = new CancellationTokenSource();
|
|
}
|
|
|
|
/// <summary>Cancellable sleep that throws OperationCanceledException when stopped.</summary>
|
|
protected Task Sleep(int ms) => Helpers.Sleep(ms, _stopCts.Token);
|
|
|
|
// ------ Loot pickup ------
|
|
|
|
// Tiers to skip (noise, low-value, or hidden by filter)
|
|
private static readonly HashSet<string> SkipTiers = ["gold"];
|
|
|
|
public async Task Loot()
|
|
{
|
|
Log.Information("Starting loot pickup");
|
|
|
|
const int maxRounds = 5;
|
|
var totalPicked = 0;
|
|
|
|
for (var round = 0; round < maxRounds; round++)
|
|
{
|
|
if (_stopped) return;
|
|
|
|
// Move mouse out of the way so it doesn't cover labels
|
|
_game.MoveMouseInstant(0, 1440);
|
|
await Sleep(100);
|
|
|
|
// Hold Alt, capture, detect
|
|
await _game.KeyDown(InputSender.VK.MENU);
|
|
await Sleep(250);
|
|
|
|
using var capture = _screen.CaptureRawBitmap();
|
|
var labels = _screen.DetectLootLabels(capture, capture);
|
|
var pickups = labels.Where(l => !SkipTiers.Contains(l.Tier)).ToList();
|
|
|
|
if (pickups.Count == 0)
|
|
{
|
|
await _game.KeyUp(InputSender.VK.MENU);
|
|
Log.Information("No loot labels in round {Round} (picked {Total} total)", round + 1, totalPicked);
|
|
break;
|
|
}
|
|
|
|
Log.Information("Round {Round}: {Count} loot labels ({Skipped} skipped)",
|
|
round + 1, pickups.Count, labels.Count - pickups.Count);
|
|
|
|
// Click all detected labels (positions are stable)
|
|
foreach (var label in pickups)
|
|
{
|
|
if (_stopped) break;
|
|
Log.Information("Picking up: tier={Tier} color=({R},{G},{B}) at ({X},{Y})",
|
|
label.Tier, label.AvgR, label.AvgG, label.AvgB, label.CenterX, label.CenterY);
|
|
await _game.LeftClickAt(label.CenterX, label.CenterY);
|
|
totalPicked++;
|
|
await Sleep(200);
|
|
}
|
|
|
|
// Quick check: capture small region around each clicked label to see if
|
|
// new labels appeared underneath. If none changed, we're done.
|
|
await Sleep(300);
|
|
_game.MoveMouseInstant(0, 1440);
|
|
await Sleep(100);
|
|
|
|
using var recheck = _screen.CaptureRawBitmap();
|
|
var newLabels = _screen.DetectLootLabels(recheck, recheck);
|
|
var newPickups = newLabels.Where(l => !SkipTiers.Contains(l.Tier)).ToList();
|
|
|
|
await _game.KeyUp(InputSender.VK.MENU);
|
|
|
|
if (newPickups.Count == 0)
|
|
{
|
|
Log.Information("Quick recheck: no new labels, done (picked {Total} total)", totalPicked);
|
|
break;
|
|
}
|
|
|
|
Log.Information("Quick recheck: {Count} new labels appeared, continuing", newPickups.Count);
|
|
await Sleep(300);
|
|
}
|
|
|
|
Log.Information("Loot pickup complete ({Count} items)", totalPicked);
|
|
}
|
|
|
|
// ------ Recovery ------
|
|
|
|
protected async Task RecoverToHideout()
|
|
{
|
|
try
|
|
{
|
|
Log.Information("Recovering: escaping and going to hideout");
|
|
await _game.FocusGame();
|
|
// await _game.PressEscape();
|
|
// await Sleep(Delays.PostEscape);
|
|
// await _game.PressEscape();
|
|
// await Sleep(Delays.PostEscape);
|
|
|
|
var arrived = await _inventory.WaitForAreaTransition(
|
|
_config.TravelTimeoutMs, () => _game.GoToHideout());
|
|
if (arrived)
|
|
{
|
|
_inventory.SetLocation(true);
|
|
Log.Information("Recovery: arrived at hideout");
|
|
}
|
|
else
|
|
{
|
|
Log.Warning("Recovery: timed out going to hideout");
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Error(ex, "Recovery failed");
|
|
}
|
|
}
|
|
|
|
// ------ Walk + template match ------
|
|
|
|
protected async Task<TemplateMatchResult?> WalkAndMatch(string templatePath, int vk1, int vk2,
|
|
int timeoutMs = 15000, int closeRadius = 350)
|
|
{
|
|
const int screenCx = 2560 / 2;
|
|
const int screenCy = 1440 / 2;
|
|
|
|
await _game.KeyDown(vk1);
|
|
await _game.KeyDown(vk2);
|
|
try
|
|
{
|
|
var sw = Stopwatch.StartNew();
|
|
bool spotted = false;
|
|
while (sw.ElapsedMilliseconds < timeoutMs)
|
|
{
|
|
if (_stopped) return null;
|
|
var match = await _screen.TemplateMatch(templatePath);
|
|
if (match == null)
|
|
{
|
|
await Sleep(500);
|
|
continue;
|
|
}
|
|
|
|
var dx = match.X - screenCx;
|
|
var dy = match.Y - screenCy;
|
|
var dist = Math.Sqrt(dx * dx + dy * dy);
|
|
|
|
if (!spotted)
|
|
{
|
|
Log.Information("Template spotted at ({X},{Y}), dist={Dist:F0}px from center, approaching...",
|
|
match.X, match.Y, dist);
|
|
spotted = true;
|
|
}
|
|
|
|
if (dist <= closeRadius)
|
|
{
|
|
Log.Information("Close enough at ({X},{Y}), dist={Dist:F0}px, stopping", match.X, match.Y, dist);
|
|
|
|
// Stop, settle, re-match for accurate position
|
|
await _game.KeyUp(vk2);
|
|
await _game.KeyUp(vk1);
|
|
await Sleep(300);
|
|
|
|
var fresh = await _screen.TemplateMatch(templatePath);
|
|
if (fresh != null)
|
|
{
|
|
Log.Information("Final position at ({X},{Y})", fresh.X, fresh.Y);
|
|
return fresh;
|
|
}
|
|
Log.Warning("Re-match failed, using last known position");
|
|
return match;
|
|
}
|
|
|
|
await Sleep(200);
|
|
}
|
|
Log.Error("WalkAndMatch timed out after {Ms}ms (spotted={Spotted})", timeoutMs, spotted);
|
|
return null;
|
|
}
|
|
finally
|
|
{
|
|
await _game.KeyUp(vk2);
|
|
await _game.KeyUp(vk1);
|
|
}
|
|
}
|
|
|
|
// ------ Stash tab resolution ------
|
|
|
|
protected (StashTabInfo? Tab, StashTabInfo? Folder) ResolveTabPath(string tabPath)
|
|
{
|
|
if (string.IsNullOrEmpty(tabPath) || _config.StashCalibration == null)
|
|
return (null, null);
|
|
|
|
var parts = tabPath.Split('/');
|
|
if (parts.Length == 1)
|
|
{
|
|
var tab = _config.StashCalibration.Tabs.FirstOrDefault(t => t.Name == parts[0]);
|
|
return (tab, null);
|
|
}
|
|
|
|
if (parts.Length == 2)
|
|
{
|
|
var folder = _config.StashCalibration.Tabs.FirstOrDefault(t => t.Name == parts[0] && t.IsFolder);
|
|
if (folder == null) return (null, null);
|
|
var subTab = folder.SubTabs.FirstOrDefault(t => t.Name == parts[1]);
|
|
return (subTab, folder);
|
|
}
|
|
|
|
return (null, null);
|
|
}
|
|
}
|