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; /// /// Shared infrastructure for any map/boss activity: combat loop, WASD navigation, /// boss detection, death checks, portal-stash cycle, loot storage. /// 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? 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; /// /// Current fight position in world coordinates, or null if not fighting. /// 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; /// /// Background combat loop — calls Tick continuously until cancelled. /// Target position is updated via volatile fields from the main thread. /// protected async Task RunCombatLoop(CancellationToken ct) { try { while (!ct.IsCancellationRequested) await _combat.Tick(_combatTargetX, _combatTargetY, _combatJitter); } catch (OperationCanceledException) { } } /// /// Start background combat loop, returning the task and CTS. /// Caller must cancel + await + ReleaseAll when done. /// 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 -- /// /// 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. /// protected async Task 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; } /// /// 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. /// private long _lastDeathCheckMs; private readonly Stopwatch _deathCheckSw = Stopwatch.StartNew(); protected async Task 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 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 -- /// /// Wait for boss to spawn, then attack until healthbar disappears. /// Returns the last world position where YOLO spotted the boss, or null. /// 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); } } /// /// Sleep for the given duration while polling YOLO to keep FightPosition updated. /// Returns last detected position, or null if no detections. /// 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 -- /// /// Find all template matches and click the one closest to screen center. /// 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); } /// /// Walk to a world position using WASD keys, checking minimap position each iteration. /// protected async Task WalkToWorldPosition(double worldX, double worldY, int timeoutMs = 10000, double arrivalDist = 5, Func>? 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(); 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(); 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) -- /// /// Portal → caravan → /hideout with retry. /// protected virtual async Task 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; } /// /// Identify items → deposit to configured loot tab. /// 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"); } }