poe2-bot/src/Automata.Bot/GameExecutor.cs
2026-02-28 15:13:31 -05:00

249 lines
8.2 KiB
C#

using System.Diagnostics;
using Automata.Core;
using Automata.Game;
using Automata.Inventory;
using Automata.Screen;
using Serilog;
namespace Automata.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);
}
}