poe2-bot/src/Nexus.Bot/MappingExecutor.cs
2026-03-06 14:37:05 -05:00

639 lines
22 KiB
C#

using System.Diagnostics;
using Nexus.Core;
using Nexus.Game;
using Nexus.GameLog;
using Nexus.Inventory;
using Nexus.Navigation;
using Nexus.Screen;
using Serilog;
namespace Nexus.Bot;
/// <summary>
/// Shared infrastructure for any map/boss activity: combat loop, WASD navigation,
/// boss detection, death checks, portal-stash cycle, loot storage.
/// </summary>
public abstract class MappingExecutor : GameExecutor
{
private static readonly string BossHealthbarTemplate = Path.Combine("assets", "unique-boss-present.png");
private static readonly string BossHealthbarTemplate2 = Path.Combine("assets", "unique-boss-present2.png");
private static readonly string BossHealthbarTemplate3 = Path.Combine("assets", "unique-boss-present3.png");
private static readonly string ResurrectTemplate = Path.Combine("assets", "resurrect.png");
private MappingState _state = MappingState.Idle;
protected readonly IClientLogWatcher _logWatcher;
protected readonly BossDetector _bossDetector;
protected readonly NavigationExecutor _nav;
protected readonly CombatManager _combat;
public event Action<MappingState>? StateChanged;
protected MappingExecutor(IGameController game, IScreenReader screen,
IInventoryManager inventory, IClientLogWatcher logWatcher, SavedSettings config,
BossDetector bossDetector, HudReader hudReader, NavigationExecutor nav)
: base(game, screen, inventory, config)
{
_logWatcher = logWatcher;
_bossDetector = bossDetector;
_combat = new CombatManager(game, hudReader, new FlaskManager(game, hudReader));
_nav = nav;
}
public MappingState State => _state;
/// <summary>
/// Current fight position in world coordinates, or null if not fighting.
/// </summary>
public (double X, double Y)? FightPosition { get; protected set; }
protected void SetState(MappingState s)
{
_state = s;
StateChanged?.Invoke(s);
}
public override void Stop()
{
base.Stop();
_nav.Frozen = false;
FightPosition = null;
Log.Information("Mapping executor stop requested");
}
// -- Combat loop --
protected volatile int _combatTargetX = 1280;
protected volatile int _combatTargetY = 660;
protected volatile int _combatJitter = 30;
/// <summary>
/// Background combat loop — calls Tick continuously until cancelled.
/// Target position is updated via volatile fields from the main thread.
/// </summary>
protected async Task RunCombatLoop(CancellationToken ct)
{
try
{
while (!ct.IsCancellationRequested)
await _combat.Tick(_combatTargetX, _combatTargetY, _combatJitter);
}
catch (OperationCanceledException) { }
}
/// <summary>
/// Start background combat loop, returning the task and CTS.
/// Caller must cancel + await + ReleaseAll when done.
/// </summary>
protected (Task combatTask, CancellationTokenSource cts) StartCombatLoop(int x = 1280, int y = 660, int jitter = 0)
{
_combatTargetX = x;
_combatTargetY = y;
_combatJitter = jitter;
_combat.Reset().GetAwaiter().GetResult();
var cts = new CancellationTokenSource();
var task = Task.Run(() => RunCombatLoop(cts.Token));
return (task, cts);
}
protected async Task StopCombatLoop(Task combatTask, CancellationTokenSource cts)
{
cts.Cancel();
try { await combatTask; } catch { /* expected */ }
await _combat.ReleaseAll();
cts.Dispose();
await WaitForStablePosition();
}
protected async Task WaitForStablePosition(int minConsecutive = 5, int timeoutMs = 2000)
{
var sw = Stopwatch.StartNew();
var consecutive = 0;
while (sw.ElapsedMilliseconds < timeoutMs && consecutive < minConsecutive)
{
await Sleep(50);
if (_nav.LastMatchSucceeded)
consecutive++;
else
consecutive = 0;
}
if (consecutive >= minConsecutive)
Log.Information("Position stabilized ({Count} consecutive matches in {Ms}ms)",
consecutive, sw.ElapsedMilliseconds);
else
Log.Warning("Position stabilization timed out ({Count}/{Min} after {Ms}ms)",
consecutive, minConsecutive, sw.ElapsedMilliseconds);
}
// -- Boss detection --
/// <summary>
/// Check top-of-screen region for the unique boss healthbar frame.
/// Healthbar spans (750,16) to (1818,112). Uses lower threshold (0.5) to tolerate YOLO overlay.
/// </summary>
protected async Task<bool> IsBossAlive()
{
var topRegion = new Region(750, 16, 1068, 96);
var m1 = await _screen.TemplateMatchAll(BossHealthbarTemplate, topRegion, threshold: 0.5, silent: true);
if (m1.Count > 0) return true;
var m2 = await _screen.TemplateMatchAll(BossHealthbarTemplate2, topRegion, threshold: 0.5, silent: true);
if (m2.Count > 0) return true;
var m3 = await _screen.TemplateMatchAll(BossHealthbarTemplate3, topRegion, threshold: 0.5, silent: true);
if (m3.Count > 0) return true;
return false;
}
/// <summary>
/// Check for the "Resurrect at Checkpoint" button — means we died.
/// Self-throttled: only actually checks every 2s to avoid slowing combat.
/// If found, click it, wait for respawn, and return true.
/// </summary>
private long _lastDeathCheckMs;
private readonly Stopwatch _deathCheckSw = Stopwatch.StartNew();
protected async Task<bool> CheckDeath()
{
var now = _deathCheckSw.ElapsedMilliseconds;
if (now - _lastDeathCheckMs < 2000) return false;
_lastDeathCheckMs = now;
var deathRegion = new Region(1090, 1030, 370, 60);
var match = await _screen.TemplateMatch(ResurrectTemplate, deathRegion);
if (match == null) return false;
Log.Warning("Death detected! Clicking resurrect at ({X},{Y})", match.X, match.Y);
await _combat.ReleaseAll();
await _game.LeftClickAt(match.X, match.Y);
await Sleep(3000); // wait for respawn + loading
return true;
}
protected async Task<bool> WaitForBossSpawn(int timeoutMs = 30_000)
{
Log.Information("Waiting for boss healthbar to appear...");
var sw = Stopwatch.StartNew();
while (sw.ElapsedMilliseconds < timeoutMs)
{
if (_stopped) return false;
if (await CheckDeath()) continue;
if (await IsBossAlive())
{
Log.Information("Boss healthbar detected after {Ms}ms", sw.ElapsedMilliseconds);
return true;
}
await Sleep(50);
}
// Final death check before giving up — force check ignoring throttle
_lastDeathCheckMs = 0;
if (await CheckDeath())
{
Log.Warning("Died while waiting for boss spawn");
return false;
}
Log.Warning("WaitForBossSpawn timed out after {Ms}ms", timeoutMs);
return false;
}
protected void StartBossDetection(string bossName)
{
_bossDetector.SetBoss(bossName);
_bossDetector.Enabled = true;
Log.Information("Boss detection started for {Boss}", bossName);
}
protected void StopBossDetection()
{
_bossDetector.Enabled = false;
Log.Information("Boss detection stopped");
}
// -- Attack methods --
/// <summary>
/// Wait for boss to spawn, then attack until healthbar disappears.
/// Returns the last world position where YOLO spotted the boss, or null.
/// </summary>
protected async Task<(double X, double Y)?> AttackBossUntilGone(
double fightAreaX = double.NaN, double fightAreaY = double.NaN,
int timeoutMs = 120_000, bool chase = true)
{
if (!await WaitForBossSpawn())
return null;
const int screenCx = 1280;
const int screenCy = 660;
const double screenToWorld = 97.0 / 835.0;
(double X, double Y)? lastBossWorldPos = null;
var yoloLogCount = 0;
void OnBossDetected(BossSnapshot snapshot)
{
if (snapshot.Bosses.Count == 0) return;
var boss = snapshot.Bosses[0];
_combatTargetX = boss.Cx;
_combatTargetY = boss.Cy;
if (chase) _combat.SetChaseTarget(boss.Cx, boss.Cy);
}
_bossDetector.BossDetected += OnBossDetected;
Log.Information("Boss is alive, engaging");
var (combatTask, cts) = StartCombatLoop(screenCx, screenCy);
try
{
var sw = Stopwatch.StartNew();
var healthbarMissCount = 0;
const int healthbarMissThreshold = 3;
while (sw.ElapsedMilliseconds < timeoutMs)
{
if (_stopped) return lastBossWorldPos;
var snapshot = _bossDetector.Latest;
if (snapshot.Bosses.Count > 0)
{
var boss = snapshot.Bosses[0];
// Use bottom-right of bounding box (boss feet), not box center.
// Isometric camera looks from upper-right, so ground is at box bottom-right.
var groundX = boss.X + boss.Width;
var groundY = boss.Y + boss.Height;
var wp = _nav.WorldPosition;
lastBossWorldPos = (
wp.X + (groundX - screenCx) * screenToWorld,
wp.Y + (groundY - screenCy) * screenToWorld);
FightPosition = lastBossWorldPos;
yoloLogCount++;
if (yoloLogCount % 5 == 1)
Log.Information("YOLO boss: ground=({Gx},{Gy}) box=({Bx},{By},{Bw}x{Bh}) charWorld=({Cx:F1},{Cy:F1}) bossWorld=({Wx:F1},{Wy:F1}) conf={Conf:F2}",
groundX, groundY, boss.X, boss.Y, boss.Width, boss.Height,
wp.X, wp.Y,
lastBossWorldPos.Value.X, lastBossWorldPos.Value.Y, boss.Confidence);
}
if (await CheckDeath()) { healthbarMissCount = 0; continue; }
if (await IsBossAlive())
{
healthbarMissCount = 0;
}
else
{
healthbarMissCount++;
if (healthbarMissCount < healthbarMissThreshold)
{
Log.Debug("Healthbar miss {N}/{Threshold}", healthbarMissCount, healthbarMissThreshold);
continue;
}
_lastDeathCheckMs = 0;
if (await CheckDeath()) { healthbarMissCount = 0; continue; }
Log.Information("Healthbar gone for {N} checks, boss phase over after {Ms}ms — last pos={Pos}",
healthbarMissCount, sw.ElapsedMilliseconds,
lastBossWorldPos != null ? $"({lastBossWorldPos.Value.X:F1},{lastBossWorldPos.Value.Y:F1})" : "null");
return lastBossWorldPos;
}
}
Log.Warning("AttackBossUntilGone timed out after {Ms}ms", timeoutMs);
return lastBossWorldPos;
}
finally
{
_bossDetector.BossDetected -= OnBossDetected;
_combat.ClearChaseTarget();
await StopCombatLoop(combatTask, cts);
}
}
/// <summary>
/// Sleep for the given duration while polling YOLO to keep FightPosition updated.
/// Returns last detected position, or null if no detections.
/// </summary>
protected async Task<(double X, double Y)?> PollYoloDuringWait(int durationMs)
{
const int screenCx = 1280;
const int screenCy = 660;
const double screenToWorld = 97.0 / 835.0;
(double X, double Y)? lastPos = null;
var sw = Stopwatch.StartNew();
while (sw.ElapsedMilliseconds < durationMs)
{
if (_stopped) break;
var snapshot = _bossDetector.Latest;
if (snapshot.Bosses.Count > 0)
{
var boss = snapshot.Bosses[0];
var groundX = boss.X + boss.Width;
var groundY = boss.Y + boss.Height;
var wp = _nav.WorldPosition;
lastPos = (
wp.X + (groundX - screenCx) * screenToWorld,
wp.Y + (groundY - screenCy) * screenToWorld);
FightPosition = lastPos;
}
await Sleep(100);
}
return lastPos;
}
protected async Task AttackAtPosition(int x, int y, int durationMs)
{
var (combatTask, cts) = StartCombatLoop(x, y, jitter: 0);
try
{
var sw = Stopwatch.StartNew();
while (sw.ElapsedMilliseconds < durationMs)
{
if (_stopped) return;
if (await CheckDeath()) continue;
await Sleep(500);
}
}
finally
{
await StopCombatLoop(combatTask, cts);
}
}
protected async Task AttackUntilBossDead(int x, int y, int timeoutMs)
{
var (combatTask, cts) = StartCombatLoop(x, y, jitter: 0);
try
{
var sw = Stopwatch.StartNew();
await Sleep(2000);
while (sw.ElapsedMilliseconds < timeoutMs)
{
if (_stopped) return;
if (await CheckDeath()) continue;
if (!await IsBossAlive())
{
Log.Information("Boss dead, stopping attack after {Ms}ms", sw.ElapsedMilliseconds);
return;
}
await Sleep(500);
}
Log.Warning("AttackUntilBossDead timed out after {Ms}ms", timeoutMs);
}
finally
{
await StopCombatLoop(combatTask, cts);
}
}
// -- Navigation --
/// <summary>
/// Find all template matches and click the one closest to screen center.
/// </summary>
protected async Task ClickClosestTemplateToCenter(string templatePath)
{
const int screenCx = 1280;
const int screenCy = 660;
var centerRegion = new Region(850, 50, 860, 550);
for (var attempt = 0; attempt < 3; attempt++)
{
var matches = await _screen.TemplateMatchAll(templatePath, centerRegion);
if (matches.Count > 0)
{
var closest = matches.OrderBy(m =>
{
var dx = m.X - screenCx;
var dy = m.Y - screenCy;
return dx * dx + dy * dy;
}).First();
Log.Information("Clicking closest match at ({X},{Y}) conf={Conf:F3} (of {Count} matches, attempt {A})",
closest.X, closest.Y, closest.Confidence, matches.Count, attempt + 1);
await _game.LeftClickAt(closest.X, closest.Y);
return;
}
Log.Warning("No center match for {Template} (attempt {A}/3), nudging", Path.GetFileName(templatePath), attempt + 1);
var nudgeKey = attempt % 2 == 0 ? InputSender.VK.W : InputSender.VK.S;
await _game.KeyDown(nudgeKey);
await Sleep(300);
await _game.KeyUp(nudgeKey);
await Sleep(500);
}
Log.Warning("No matches found for {Template} after nudges, clicking screen center", Path.GetFileName(templatePath));
await _game.LeftClickAt(screenCx, screenCy);
}
/// <summary>
/// Walk to a world position using WASD keys, checking minimap position each iteration.
/// </summary>
protected async Task WalkToWorldPosition(double worldX, double worldY, int timeoutMs = 10000,
double arrivalDist = 5, Func<Task<bool>>? cancelWhen = null)
{
Log.Information("Walking to world ({X:F0},{Y:F0})", worldX, worldY);
const int screenCx = 1280;
const int screenCy = 660;
var sw = Stopwatch.StartNew();
var heldKeys = new HashSet<int>();
var lastBlinkMs = -2300L;
try
{
while (sw.ElapsedMilliseconds < timeoutMs)
{
if (_stopped) break;
if (cancelWhen != null && await cancelWhen())
{
Log.Information("Walk cancelled early (cancel condition met)");
break;
}
var pos = _nav.WorldPosition;
var dx = worldX - pos.X;
var dy = worldY - pos.Y;
var dist = Math.Sqrt(dx * dx + dy * dy);
if (dist <= arrivalDist)
{
Log.Information("Arrived at ({X:F0},{Y:F0}), dist={Dist:F0}", pos.X, pos.Y, dist);
break;
}
var len = Math.Sqrt(dx * dx + dy * dy);
var dirX = dx / len;
var dirY = dy / len;
var blinkCooldown = 2300 + Rng.Next(-300, 301);
if (dist > 200 && sw.ElapsedMilliseconds - lastBlinkMs >= blinkCooldown)
{
var blinkX = screenCx + (int)(dirX * 400);
var blinkY = screenCy + (int)(dirY * 400);
await _game.MoveMouseFast(blinkX, blinkY);
await Sleep(30);
await _game.PressKey(InputSender.VK.SPACE);
lastBlinkMs = sw.ElapsedMilliseconds;
}
var wanted = new HashSet<int>();
if (dirY < -0.3) wanted.Add(InputSender.VK.W);
if (dirY > 0.3) wanted.Add(InputSender.VK.S);
if (dirX < -0.3) wanted.Add(InputSender.VK.A);
if (dirX > 0.3) wanted.Add(InputSender.VK.D);
foreach (var key in heldKeys.Except(wanted).ToList())
{
await _game.KeyUp(key);
heldKeys.Remove(key);
}
foreach (var key in wanted.Except(heldKeys).ToList())
{
await _game.KeyDown(key);
heldKeys.Add(key);
}
await Sleep(100);
}
if (sw.ElapsedMilliseconds >= timeoutMs)
Log.Warning("WalkToWorldPosition timed out after {Ms}ms", timeoutMs);
}
finally
{
foreach (var key in heldKeys)
await _game.KeyUp(key);
}
}
// -- Return/Store (virtual) --
/// <summary>
/// Portal → caravan → /hideout with retry.
/// </summary>
protected virtual async Task<bool> ReturnHome()
{
SetState(MappingState.Returning);
Log.Information("Returning home");
await _game.FocusGame();
await Sleep(Delays.PostFocus);
// Walk away from loot (hold S briefly)
await _game.KeyDown(InputSender.VK.S);
await Sleep(500);
await _game.KeyUp(InputSender.VK.S);
await Sleep(200);
// Press + to open portal, then click it at known position
await _game.PressPlus();
await Sleep(2500);
await _game.LeftClickAt(1280, 450);
// Wait for area transition to caravan
var arrivedCaravan = await _inventory.WaitForAreaTransition(_config.TravelTimeoutMs);
if (!arrivedCaravan)
{
Log.Error("Timed out waiting for caravan transition");
return false;
}
await Sleep(Delays.PostTravel);
// /hideout to go home — retry up to 3 times
var arrivedHome = false;
for (var attempt = 1; attempt <= 3; attempt++)
{
arrivedHome = await _inventory.WaitForAreaTransition(
_config.TravelTimeoutMs, () => _game.GoToHideout());
if (arrivedHome) break;
if (attempt < 3)
{
Log.Warning("Hideout command didn't register (attempt {Attempt}/3), clearing UI and retrying", attempt);
await _game.PressKey(InputSender.VK.SPACE);
await Sleep(1000);
}
}
if (!arrivedHome)
{
Log.Error("Timed out going to hideout after 3 attempts");
return false;
}
await Sleep(Delays.PostTravel);
_inventory.SetLocation(true);
Log.Information("Arrived at hideout");
return true;
}
/// <summary>
/// Identify items → deposit to configured loot tab.
/// </summary>
protected virtual async Task StoreLoot()
{
SetState(MappingState.StoringLoot);
Log.Information("Storing loot");
await _game.FocusGame();
await Sleep(Delays.PostFocus);
await _inventory.IdentifyItems();
var stashPos = await _inventory.FindAndClickNameplate("Stash");
if (stashPos == null)
{
Log.Warning("Could not find Stash, skipping loot storage");
return;
}
await Sleep(Delays.PostStashOpen);
var (lootTab, lootFolder) = ResolveTabPath(_config.Kulemak.LootTabPath);
if (lootTab != null)
await _inventory.ClickStashTab(lootTab, lootFolder);
for (var pass = 0; pass < 3; pass++)
{
var scanResult = await _inventory.SnapshotInventory();
if (scanResult.Items.Count == 0)
{
if (pass > 0) Log.Information("Inventory clear after {Pass} passes", pass);
break;
}
Log.Information("Depositing {Count} items to loot tab (pass {Pass})", scanResult.Items.Count, pass + 1);
await _game.KeyDown(InputSender.VK.SHIFT);
await _game.HoldCtrl();
foreach (var item in scanResult.Items)
{
var center = _screen.Grid.GetCellCenter(GridLayouts.Inventory, item.Row, item.Col);
await _game.LeftClickAt(center.X, center.Y);
await Sleep(Delays.ClickInterval);
}
await _game.ReleaseCtrl();
await _game.KeyUp(InputSender.VK.SHIFT);
await Sleep(Delays.PostEscape);
}
await _game.PressEscape();
_inventory.ResetStashTabState();
await Sleep(Delays.PostEscape);
Log.Information("Loot stored");
}
}