using System.Diagnostics; using Poe2Trade.Core; using Poe2Trade.Game; using Serilog; namespace Poe2Trade.Navigation; public class NavigationExecutor : IDisposable { private readonly IGameController _game; private readonly MinimapConfig _config; private readonly MinimapCapture _capture; private readonly WorldMap _worldMap; private NavigationState _state = NavigationState.Idle; private bool _stopped; private int _stuckCounter; private MapPosition? _lastPosition; private static readonly Random Rng = new(); public event Action? StateChanged; public NavigationState State => _state; public NavigationExecutor(IGameController game, MinimapConfig? config = null) { _game = game; _config = config ?? new MinimapConfig(); _capture = new MinimapCapture(_config); _worldMap = new WorldMap(_config); _capture.ModeChanged += _ => { _worldMap.Rebootstrap(); _stuckCounter = 0; _lastPosition = null; }; } private void SetState(NavigationState s) { _state = s; StateChanged?.Invoke(s); } public Task Stop() { _stopped = true; SetState(NavigationState.Idle); Log.Information("Navigation executor stopped"); return Task.CompletedTask; } public void Reset() { _worldMap.Reset(); _capture.ResetAdaptation(); _stopped = false; _stuckCounter = 0; _lastPosition = null; SetState(NavigationState.Idle); Log.Information("Navigation reset (new area)"); } public async Task RunExploreLoop() { _stopped = false; Log.Information("Starting explore loop"); var lastMoveTime = 0L; var lastClickTime = 0L; var heldKeys = new HashSet(); // currently held WASD keys try { while (!_stopped) { var frameStart = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); try { // 1. Capture + track every frame SetState(NavigationState.Capturing); using var frame = _capture.CaptureFrame(); if (frame == null) { Log.Warning("Failed to capture minimap frame"); await Helpers.Sleep(_config.CaptureIntervalMs); continue; } SetState(NavigationState.Processing); var mode = _capture.DetectedMode; var pos = _worldMap.MatchAndStitch(frame.ClassifiedMat, frame.WallMask, mode); if (_worldMap.LastMatchSucceeded) _capture.CommitWallColors(); // Stuck detection if (_lastPosition != null) { var dx = pos.X - _lastPosition.X; var dy = pos.Y - _lastPosition.Y; if (Math.Sqrt(dx * dx + dy * dy) < _config.StuckThreshold) _stuckCounter++; else _stuckCounter = 0; } _lastPosition = pos; // 2. Movement decisions (faster re-evaluation when stuck) var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); var moveInterval = _stuckCounter >= _config.StuckFrameCount ? 200 : _config.MovementWaitMs; if (now - lastMoveTime >= moveInterval) { lastMoveTime = now; if (_stuckCounter >= _config.StuckFrameCount) SetState(NavigationState.Stuck); else SetState(NavigationState.Planning); // BFS finds path through explored cells to nearest frontier var direction = _worldMap.FindNearestUnexplored(pos); if (direction == null) { Log.Information("Map fully explored"); SetState(NavigationState.Completed); break; } SetState(NavigationState.Moving); await UpdateWasdKeys(heldKeys, direction.Value.dirX, direction.Value.dirY); if (_stuckCounter >= _config.StuckFrameCount) _stuckCounter = 0; // reset after re-routing } // 3. Occasional combat clicks near screen center if (now - lastClickTime >= 1000 + Rng.Next(1000)) { lastClickTime = now; var cx = 1280 + Rng.Next(-150, 150); var cy = 720 + Rng.Next(-150, 150); await _game.LeftClickAt(cx, cy); await Helpers.Sleep(100 + Rng.Next(100)); cx = 1280 + Rng.Next(-150, 150); cy = 720 + Rng.Next(-150, 150); await _game.RightClickAt(cx, cy); } } catch (Exception ex) { Log.Error(ex, "Error in explore loop"); SetState(NavigationState.Failed); await Helpers.Sleep(1000); continue; } // Sleep remainder of frame interval var elapsed = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - frameStart; var sleepMs = _config.CaptureIntervalMs - (int)elapsed; if (sleepMs > 0) await Helpers.Sleep(sleepMs); } } finally { // Always release held keys foreach (var key in heldKeys) await _game.KeyUp(key); } if (_state != NavigationState.Completed) SetState(NavigationState.Idle); Log.Information("Explore loop ended"); } /// /// Convert a direction vector to WASD key holds. Releases keys no longer needed, /// presses new ones. Supports diagonal movement (two keys at once). /// private async Task UpdateWasdKeys(HashSet held, double dirX, double dirY) { var wanted = new HashSet(); // Threshold for diagonal: if both components are significant, hold both keys const double threshold = 0.3; if (dirY < -threshold) wanted.Add(InputSender.VK.W); // up if (dirY > threshold) wanted.Add(InputSender.VK.S); // down if (dirX < -threshold) wanted.Add(InputSender.VK.A); // left if (dirX > threshold) wanted.Add(InputSender.VK.D); // right // If direction is too weak, default to W if (wanted.Count == 0) wanted.Add(InputSender.VK.W); // Release keys no longer wanted foreach (var key in held.Except(wanted).ToList()) { await _game.KeyUp(key); held.Remove(key); } // Press newly wanted keys foreach (var key in wanted.Except(held).ToList()) { await _game.KeyDown(key); held.Add(key); } } private async Task ClickToMove(double dirX, double dirY) { // Player is at minimap center on screen; click offset from center var len = Math.Sqrt(dirX * dirX + dirY * dirY); if (len < 0.001) return; var nx = dirX / len; var ny = dirY / len; var clickX = _config.MinimapCenterX + (int)(nx * _config.ClickRadius); var clickY = _config.MinimapCenterY + (int)(ny * _config.ClickRadius); Log.Debug("Click to move: ({X}, {Y}) dir=({Dx:F2}, {Dy:F2})", clickX, clickY, nx, ny); await _game.LeftClickAt(clickX, clickY); } private async Task ClickRandomDirection() { var angle = Rng.NextDouble() * 2 * Math.PI; await ClickToMove(Math.Cos(angle), Math.Sin(angle)); } public bool IsExploring => _state != NavigationState.Idle && _state != NavigationState.Completed && _state != NavigationState.Failed; public MapPosition Position => _worldMap.Position; public byte[] GetMapSnapshot() => _worldMap.GetMapSnapshot(); public byte[] GetViewportSnapshot(int viewSize = 400) => _worldMap.GetViewportSnapshot(_worldMap.Position, viewSize); /// /// Capture one frame, track position, stitch into world map. /// Returns PNG bytes for the requested debug stage (or world map viewport by default). /// public byte[]? ProcessFrame(MinimapDebugStage stage = MinimapDebugStage.WorldMap) { var sw = Stopwatch.StartNew(); var captureStart = sw.Elapsed.TotalMilliseconds; using var frame = _capture.CaptureFrame(); var captureMs = sw.Elapsed.TotalMilliseconds - captureStart; if (frame == null) return null; var stitchStart = sw.Elapsed.TotalMilliseconds; var pos = _worldMap.MatchAndStitch(frame.ClassifiedMat, frame.WallMask, _capture.DetectedMode); if (_worldMap.LastMatchSucceeded) _capture.CommitWallColors(); var stitchMs = sw.Elapsed.TotalMilliseconds - stitchStart; var renderStart = sw.Elapsed.TotalMilliseconds; byte[]? result; if (stage == MinimapDebugStage.WorldMap) result = _worldMap.GetViewportSnapshot(pos); else result = _capture.CaptureStage(stage); var renderMs = sw.Elapsed.TotalMilliseconds - renderStart; Log.Information("ProcessFrame: capture={Capture:F1}ms stitch={Stitch:F1}ms render={Render:F1}ms total={Total:F1}ms", captureMs, stitchMs, renderMs, sw.Elapsed.TotalMilliseconds); return result; } public void SaveDebugCapture() => _capture.SaveDebugCapture(); public void Dispose() { _capture.Dispose(); _worldMap.Dispose(); } }