using System.Diagnostics; using Poe2Trade.Core; using Poe2Trade.Game; using Poe2Trade.GameLog; using Poe2Trade.Inventory; using Poe2Trade.Navigation; using Poe2Trade.Screen; using Serilog; namespace Poe2Trade.Bot; public class BossRunExecutor : GameExecutor { private static readonly string WellOfSoulsTemplate = Path.Combine("assets", "well-of-souls.png"); private static readonly string BlackCathedralTemplate = Path.Combine("assets", "black-cathedral.png"); private static readonly string InvitationTemplate = Path.Combine("assets", "invitation.png"); private static readonly string CathedralDoorTemplate = Path.Combine("assets", "black-cathedral-door.png"); private static readonly string CathedralWellTemplate = Path.Combine("assets", "black-cathedral-well.png"); private static readonly string ReturnTheRingTemplate = Path.Combine("assets", "return-the-ring.png"); 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 NewInstanceTemplate = Path.Combine("assets", "new.png"); private static readonly string ResurrectTemplate = Path.Combine("assets", "resurrect.png"); private BossRunState _state = BossRunState.Idle; private readonly IClientLogWatcher _logWatcher; private readonly BossDetector _bossDetector; private readonly NavigationExecutor _nav; private readonly CombatManager _combat; public event Action? StateChanged; public BossRunExecutor(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 BossRunState State => _state; /// /// Current fight position in world coordinates, or null if not fighting. /// public (double X, double Y)? FightPosition { get; private set; } private void SetState(BossRunState s) { _state = s; StateChanged?.Invoke(s); } public override void Stop() { base.Stop(); _nav.Frozen = false; FightPosition = null; Log.Information("Boss run executor stop requested"); } public async Task RunBossLoop() { ResetStop(); var runCount = _config.Kulemak.RunCount; Log.Information("Starting boss run loop ({Count} runs)", runCount); var completed = 0; try { // First run: deposit inventory and grab 1 invitation if (!await Prepare()) { SetState(BossRunState.Failed); await RecoverToHideout(); SetState(BossRunState.Idle); return; } for (var i = 0; i < runCount; i++) { if (_stopped) break; Log.Information("=== Boss run {N}/{Total} ===", i + 1, runCount); if (!await TravelToZone()) { Log.Error("Failed to travel to zone"); await RecoverToHideout(); break; } if (_stopped) break; var entrance = await WalkToEntrance(); if (entrance == null) { Log.Error("Failed to find Black Cathedral entrance"); await RecoverToHideout(); break; } if (_stopped) break; if (!await UseInvitation(entrance.X, entrance.Y)) { Log.Error("Failed to use invitation"); await RecoverToHideout(); break; } if (_stopped) break; await Fight(); if (_stopped) break; SetState(BossRunState.Looting); await Sleep(1000); // wait for loot labels to render await Loot(); if (_stopped) break; if (!await ReturnHome()) { Log.Error("Failed to return home"); await RecoverToHideout(); break; } if (_stopped) break; bool isLastRun = i == runCount - 1 || _stopped; await StoreLoot(grabInvitation: !isLastRun); completed++; if (_stopped) break; } } catch (OperationCanceledException) when (_stopped) { Log.Information("Boss run loop cancelled by user"); } Log.Information("Boss run loop finished: {Completed}/{Total} runs completed", completed, runCount); SetState(BossRunState.Complete); await Helpers.Sleep(1000); // non-cancellable final delay SetState(BossRunState.Idle); } private async Task Prepare() { SetState(BossRunState.Preparing); Log.Information("Preparing: depositing inventory and grabbing invitations"); await _game.FocusGame(); await Sleep(Delays.PostFocus); // Open stash var stashPos = await _inventory.FindAndClickNameplate("Stash"); if (stashPos == null) { Log.Error("Could not find Stash nameplate"); return false; } await Sleep(Delays.PostStashOpen); // Click loot tab and deposit all inventory items var (lootTab, lootFolder) = ResolveTabPath(_config.Kulemak.LootTabPath); if (lootTab != null) { await _inventory.ClickStashTab(lootTab, lootFolder); // Deposit all inventory items via ctrl+click var scanResult = await _screen.Grid.Scan("inventory"); if (scanResult.Occupied.Count > 0) { Log.Information("Depositing {Count} inventory items to loot tab", scanResult.Occupied.Count); await _game.KeyDown(InputSender.VK.SHIFT); await _game.HoldCtrl(); foreach (var cell in scanResult.Occupied) { var center = _screen.Grid.GetCellCenter(GridLayouts.Inventory, cell.Row, cell.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); } } else { Log.Warning("Loot tab path not configured or not found, skipping deposit"); } // Click invitation tab and grab invitations var (invTab, invFolder) = ResolveTabPath(_config.Kulemak.InvitationTabPath); if (invTab != null) { await _inventory.ClickStashTab(invTab, invFolder); // Determine layout name based on tab config var layoutName = (invTab.GridCols == 24, invFolder != null) switch { (true, true) => "stash24_folder", (true, false) => "stash24", (false, true) => "stash12_folder", (false, false) => "stash12", }; await _inventory.GrabItemsFromStash(layoutName, 1, InvitationTemplate); } else { Log.Warning("Invitation tab path not configured or not found, skipping grab"); } // Close stash await _game.PressEscape(); _inventory.ResetStashTabState(); await Sleep(Delays.PostEscape); Log.Information("Preparation complete"); return true; } private async Task TravelToZone() { SetState(BossRunState.TravelingToZone); Log.Information("Traveling to Well of Souls via waypoint"); await _game.FocusGame(); await Sleep(Delays.PostFocus); // Find and click Waypoint var wpPos = await _inventory.FindAndClickNameplate("Waypoint"); if (wpPos == null) { Log.Error("Could not find Waypoint nameplate"); return false; } await Sleep(1500); // Template match well-of-souls.png and click var match = await _screen.TemplateMatch(WellOfSoulsTemplate); if (match == null) { Log.Error("Could not find Well of Souls on waypoint map"); await _game.PressEscape(); return false; } Log.Information("Found Well of Souls at ({X},{Y}), clicking", match.X, match.Y); await _game.LeftClickAt(match.X, match.Y); // Wait for area transition var arrived = await _inventory.WaitForAreaTransition(_config.TravelTimeoutMs); if (!arrived) { Log.Error("Timed out waiting for Well of Souls transition"); return false; } await Sleep(Delays.PostTravel); Log.Information("Arrived at Well of Souls"); return true; } private async Task WalkToEntrance() { SetState(BossRunState.WalkingToEntrance); Log.Information("Walking to Black Cathedral entrance (W+D)"); return await WalkAndMatch(BlackCathedralTemplate, InputSender.VK.W, InputSender.VK.D, 15000); } private async Task UseInvitation(int x, int y) { SetState(BossRunState.UsingInvitation); Log.Information("Using invitation at ({X},{Y})", x, y); // Hover first so the game registers the target, then use invitation await _game.MoveMouseTo(x, y); await Sleep(200); await _game.CtrlLeftClickAt(x, y); await Sleep(500); // Find "NEW" button via template match — pick the leftmost var matches = await _screen.TemplateMatchAll(NewInstanceTemplate); if (matches.Count == 0) { Log.Error("Could not find 'NEW' template for instance selection"); return false; } var target = matches.OrderBy(m => m.X).First(); Log.Information("Found {Count} 'NEW' matches, clicking leftmost at ({X},{Y}) conf={Conf:F3}", matches.Count, target.X, target.Y, target.Confidence); await _game.MoveMouseTo(target.X, target.Y); await Sleep(150); await _game.LeftClickAt(target.X, target.Y); // Wait for area transition into boss arena var arrived = await _inventory.WaitForAreaTransition(_config.TravelTimeoutMs); if (!arrived) { Log.Error("Timed out waiting for boss arena transition"); return false; } await Sleep(Delays.PostTravel); Log.Information("Entered boss arena"); return true; } private void StartBossDetection() { _bossDetector.SetBoss("kulemak"); _bossDetector.Enabled = true; Log.Information("Boss detection started"); } private void StopBossDetection() { _bossDetector.Enabled = false; Log.Information("Boss detection stopped"); } private async Task Fight() { SetState(BossRunState.Fighting); Log.Information("Fight phase starting"); // Wait for arena to settle await Sleep(3000); if (_stopped) return; // Find and click the cathedral door Log.Information("Looking for cathedral door..."); var door = await _screen.TemplateMatch(CathedralDoorTemplate); if (door == null) { Log.Error("Could not find cathedral door template"); return; } Log.Information("Found cathedral door at ({X},{Y}), clicking", door.X, door.Y); await _game.LeftClickAt(door.X, door.Y); // Wait for cathedral interior to load await Sleep(14000); if (_stopped) return; StartBossDetection(); // Walk to fight area (world coords) var fightWorldX = -454.0; var fightWorldY = -332.0; const double wellWorldX = -496; const double wellWorldY = -378; FightPosition = (fightWorldX, fightWorldY); await WalkToWorldPosition(fightWorldX, fightWorldY, cancelWhen: IsBossAlive); _nav.Frozen = true; // Lock canvas — position tracking only try { if (_stopped) return; // 3x fight-then-well loop for (var phase = 1; phase <= 3; phase++) { if (_stopped) return; var preWp = _nav.WorldPosition; Log.Information("=== Boss phase {Phase}/4 === fightArea=({FX:F0},{FY:F0}) charPos=({CX:F1},{CY:F1})", phase, fightWorldX, fightWorldY, preWp.X, preWp.Y); var lastBossPos = await AttackBossUntilGone(fightWorldX, fightWorldY); if (_stopped) return; // Update fight area to where the boss was last seen var postWp = _nav.WorldPosition; Log.Information("Phase {Phase} ended: charPos=({CX:F1},{CY:F1}) lastBossPos={Boss}", phase, postWp.X, postWp.Y, lastBossPos != null ? $"({lastBossPos.Value.X:F1},{lastBossPos.Value.Y:F1})" : "null"); if (lastBossPos != null) { fightWorldX = lastBossPos.Value.X; fightWorldY = lastBossPos.Value.Y; FightPosition = (fightWorldX, fightWorldY); Log.Information("Fight area updated to ({X:F0},{Y:F0})", fightWorldX, fightWorldY); } // Wait for death animation + loot settle, keep updating fight position from YOLO var deathPos = await PollYoloDuringWait(2000); if (deathPos != null) { fightWorldX = deathPos.Value.X; fightWorldY = deathPos.Value.Y; } // Walk to well and click the closest match to screen center Log.Information("Phase {Phase} done, walking to well", phase); await Sleep(500); await WalkToWorldPosition(wellWorldX, wellWorldY); await Sleep(500); await ClickClosestTemplateToCenter(CathedralWellTemplate); await Sleep(200); // Walk back to fight position for next phase await WalkToWorldPosition(fightWorldX, fightWorldY, cancelWhen: IsBossAlive); } // 4th fight - no well after if (_stopped) return; { var p4wp = _nav.WorldPosition; Log.Information("=== Boss phase 4/4 === fightArea=({FX:F0},{FY:F0}) charPos=({CX:F1},{CY:F1})", fightWorldX, fightWorldY, p4wp.X, p4wp.Y); } var finalBossPos = await AttackBossUntilGone(fightWorldX, fightWorldY); if (_stopped) return; // Update fight area from phase 4 if we got detections { var p4postWp = _nav.WorldPosition; Log.Information("Phase 4 ended: charPos=({CX:F1},{CY:F1}) finalBossPos={Boss}", p4postWp.X, p4postWp.Y, finalBossPos != null ? $"({finalBossPos.Value.X:F1},{finalBossPos.Value.Y:F1})" : "null"); } if (finalBossPos != null) { fightWorldX = finalBossPos.Value.X; fightWorldY = finalBossPos.Value.Y; FightPosition = (fightWorldX, fightWorldY); } Log.Information("Ring phase: using fightArea=({FX:F0},{FY:F0})", fightWorldX, fightWorldY); // Walk to known ring position and look for the template await WalkToWorldPosition(-440, -330); await Sleep(1000); if (_stopped) return; Log.Information("Looking for Return the Ring..."); var ring = await _screen.TemplateMatch(ReturnTheRingTemplate); if (ring == null) { ring = await _screen.TemplateMatch(ReturnTheRingTemplate); } if (ring != null) { Log.Information("Found Return the Ring at ({X},{Y}), clicking", ring.X, ring.Y); await _game.LeftClickAt(ring.X, ring.Y); await Sleep(500); } else { Log.Error("Could not find Return the Ring template"); } if (_stopped) return; // Walk back to fight area — stop early if boss already spawned nearby Log.Information("Walking to fight position ({X:F0},{Y:F0})", fightWorldX, fightWorldY); await WalkToWorldPosition(fightWorldX, fightWorldY, cancelWhen: IsBossAlive); await Sleep(300); Log.Information("Attacking at ring fight position (no chase — boss will come to us)"); await AttackBossUntilGone(fightWorldX, fightWorldY, chase: false); if (_stopped) return; StopBossDetection(); Log.Information("Fight complete"); } finally { _nav.Frozen = false; FightPosition = null; } } /// /// 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. /// private async Task IsBossAlive() { var topRegion = new Region(750, 16, 1068, 96); // Check all three healthbar templates — boss is alive if ANY matches 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(); private 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; } /// /// Wait for the boss healthbar to appear (boss spawns/becomes active). /// // -- Async combat: combat runs in background, checks run on main thread -- private volatile int _combatTargetX = 1280; private volatile int _combatTargetY = 660; private volatile int _combatJitter = 30; /// /// Background combat loop — calls Tick continuously until cancelled. /// Target position is updated via volatile fields from the main thread. /// private 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. /// private (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); } private async Task StopCombatLoop(Task combatTask, CancellationTokenSource cts) { cts.Cancel(); try { await combatTask; } catch { /* expected */ } await _combat.ReleaseAll(); cts.Dispose(); await WaitForStablePosition(); } private 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); } private 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; } /// /// Wait for boss to spawn, then attack until healthbar disappears. /// Returns the last world position where YOLO spotted the boss, or null. /// private async Task<(double X, double Y)?> AttackBossUntilGone( double fightAreaX = double.NaN, double fightAreaY = double.NaN, int timeoutMs = 120_000, bool chase = true) { // Wait for boss to actually appear before attacking 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; // Subscribe to YOLO events — aim mouse at boss, optionally chase with WASD 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; // require 3 consecutive misses while (sw.ElapsedMilliseconds < timeoutMs) { if (_stopped) return lastBossWorldPos; // Update lastBossWorldPos from latest YOLO (for phase tracking) var snapshot = _bossDetector.Latest; if (snapshot.Bosses.Count > 0) { var boss = snapshot.Bosses[0]; var wp = _nav.WorldPosition; lastBossWorldPos = ( wp.X + (boss.Cx - screenCx) * screenToWorld, wp.Y + (boss.Cy - screenCy) * screenToWorld); FightPosition = lastBossWorldPos; yoloLogCount++; if (yoloLogCount % 5 == 1) // log every 5th detection Log.Information("YOLO boss: screen=({Sx},{Sy}) charWorld=({Cx:F1},{Cy:F1}) bossWorld=({Bx:F1},{By:F1}) conf={Conf:F2}", boss.Cx, boss.Cy, wp.X, wp.Y, lastBossWorldPos.Value.X, lastBossWorldPos.Value.Y, boss.Confidence); } // Check death + healthbar (combat keeps running in background) 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; } // Confirm: did we die or did boss phase actually end? _lastDeathCheckMs = 0; if (await CheckDeath()) { healthbarMissCount = 0; continue; } Log.Information("Healthbar gone for {N} checks, boss phase over after {Ms}ms", healthbarMissCount, sw.ElapsedMilliseconds); // Keep polling YOLO briefly — boss death animation can move it further. // Use the LAST detection (not furthest) so we end up where the boss actually is. var postDeathSw = Stopwatch.StartNew(); while (postDeathSw.ElapsedMilliseconds < 2000) { if (_stopped) break; var postSnapshot = _bossDetector.Latest; if (postSnapshot.Bosses.Count > 0) { var boss = postSnapshot.Bosses[0]; var wp = _nav.WorldPosition; var bx = wp.X + (boss.Cx - screenCx) * screenToWorld; var by = wp.Y + (boss.Cy - screenCy) * screenToWorld; lastBossWorldPos = (bx, by); FightPosition = lastBossWorldPos; Log.Information("Post-death YOLO: bossWorld=({Bx:F1},{By:F1})", bx, by); } await Sleep(100); } 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 /// (e.g., during boss death animation when YOLO still detects the corpse/model). /// Returns last detected position, or null if no detections. /// private 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 wp = _nav.WorldPosition; lastPos = ( wp.X + (boss.Cx - screenCx) * screenToWorld, wp.Y + (boss.Cy - screenCy) * screenToWorld); FightPosition = lastPos; } await Sleep(100); } return lastPos; } private 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); } } private async Task AttackUntilBossDead(int x, int y, int timeoutMs) { var (combatTask, cts) = StartCombatLoop(x, y, jitter: 0); try { var sw = Stopwatch.StartNew(); // Attack for at least 2s before checking healthbar 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); } } /// /// Find all template matches and click the one closest to screen center. /// private async Task ClickClosestTemplateToCenter(string templatePath) { const int screenCx = 1280; const int screenCy = 660; // Search center region only to avoid clicking distant matches 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; } // Nudge character a bit and retry 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. /// private async Task WalkToWorldPosition(double worldX, double worldY, int timeoutMs = 10000, double arrivalDist = 15, 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; // allow immediate first blink 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; } // Normalize direction var len = Math.Sqrt(dx * dx + dy * dy); var dirX = dx / len; var dirY = dy / len; // Blink toward destination every ~2.3s ± 0.3s (only when far enough to avoid overshooting) var blinkCooldown = 2300 + Rng.Next(-300, 301); if (dist > 200 && sw.ElapsedMilliseconds - lastBlinkMs >= blinkCooldown) { // Move mouse in the travel direction so blink goes the right way 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; } // Map direction to WASD keys var wanted = new HashSet(); if (dirY < -0.3) wanted.Add(InputSender.VK.W); // up if (dirY > 0.3) wanted.Add(InputSender.VK.S); // down if (dirX < -0.3) wanted.Add(InputSender.VK.A); // left if (dirX > 0.3) wanted.Add(InputSender.VK.D); // right // Release keys no longer needed foreach (var key in heldKeys.Except(wanted).ToList()) { await _game.KeyUp(key); heldKeys.Remove(key); } // Press new keys 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 { // Release all held keys foreach (var key in heldKeys) await _game.KeyUp(key); } } private async Task ReturnHome() { SetState(BossRunState.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 (command can fail to register while loading) 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; } private async Task StoreLoot(bool grabInvitation = false) { SetState(BossRunState.StoringLoot); Log.Information("Storing loot"); await _game.FocusGame(); await Sleep(Delays.PostFocus); // Identify items at Doryani before stashing await _inventory.IdentifyItems(); // Open stash var stashPos = await _inventory.FindAndClickNameplate("Stash"); if (stashPos == null) { Log.Warning("Could not find Stash, skipping loot storage"); return; } await Sleep(Delays.PostStashOpen); // Click loot tab and deposit all inventory items 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 _screen.Grid.Scan("inventory"); 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); } // Grab 1 invitation for the next run while stash is still open if (grabInvitation) { var (invTab, invFolder) = ResolveTabPath(_config.Kulemak.InvitationTabPath); if (invTab != null) { await _inventory.ClickStashTab(invTab, invFolder); var layoutName = (invTab.GridCols == 24, invFolder != null) switch { (true, true) => "stash24_folder", (true, false) => "stash24", (false, true) => "stash12_folder", (false, false) => "stash12", }; await _inventory.GrabItemsFromStash(layoutName, 1, InvitationTemplate); Log.Information("Grabbed 1 invitation for next run"); } } // Close stash await _game.PressEscape(); _inventory.ResetStashTabState(); await Sleep(Delays.PostEscape); Log.Information("Loot stored"); } }