diff --git a/debug_loot_capture.png b/debug_loot_capture.png index 0ad3f8b..39f91ea 100644 Binary files a/debug_loot_capture.png and b/debug_loot_capture.png differ diff --git a/debug_loot_detected.png b/debug_loot_detected.png index 877f929..50b59ac 100644 Binary files a/debug_loot_detected.png and b/debug_loot_detected.png differ diff --git a/debug_loot_edges.png b/debug_loot_edges.png index 3191a8e..99b4ad7 100644 Binary files a/debug_loot_edges.png and b/debug_loot_edges.png differ diff --git a/src/Poe2Trade.Bot/BossRunExecutor.cs b/src/Poe2Trade.Bot/BossRunExecutor.cs deleted file mode 100644 index 9630330..0000000 --- a/src/Poe2Trade.Bot/BossRunExecutor.cs +++ /dev/null @@ -1,1081 +0,0 @@ -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"); - } -} diff --git a/src/Poe2Trade.Bot/BotOrchestrator.cs b/src/Poe2Trade.Bot/BotOrchestrator.cs index b100ca1..7fe65be 100644 --- a/src/Poe2Trade.Bot/BotOrchestrator.cs +++ b/src/Poe2Trade.Bot/BotOrchestrator.cs @@ -47,7 +47,7 @@ public class BotOrchestrator : IAsyncDisposable public BossDetector BossDetector { get; } public FrameSaver FrameSaver { get; } public LootDebugDetector LootDebugDetector { get; } - public BossRunExecutor BossRunExecutor { get; } + public KulemakExecutor KulemakExecutor { get; } public volatile bool ShowYoloOverlay = true; public volatile bool ShowFightPositionOverlay = true; private readonly Dictionary _scrapExecutors = new(); @@ -94,7 +94,7 @@ public class BotOrchestrator : IAsyncDisposable Navigation = new NavigationExecutor(game, pipelineService.Pipeline, minimapCapture, enemyDetector: EnemyDetector); - BossRunExecutor = new BossRunExecutor(game, screen, inventory, logWatcher, store.Settings, BossDetector, HudReader, Navigation); + KulemakExecutor = new KulemakExecutor(game, screen, inventory, logWatcher, store.Settings, BossDetector, HudReader, Navigation); logWatcher.AreaEntered += area => { @@ -212,9 +212,9 @@ public class BotOrchestrator : IAsyncDisposable return; } } - if (BossRunExecutor.State != BossRunState.Idle) + if (KulemakExecutor.State != MappingState.Idle) { - State = BossRunExecutor.State.ToString(); + State = KulemakExecutor.State.ToString(); return; } if (Navigation.State != NavigationState.Idle) @@ -301,7 +301,7 @@ public class BotOrchestrator : IAsyncDisposable await Game.FocusGame(); await Screen.Warmup(); - BossRunExecutor.StateChanged += _ => UpdateExecutorState(); + KulemakExecutor.StateChanged += _ => UpdateExecutorState(); Navigation.StateChanged += _ => UpdateExecutorState(); _started = true; @@ -320,7 +320,7 @@ public class BotOrchestrator : IAsyncDisposable Emit("info", "Starting boss run loop..."); State = "Preparing"; - _ = BossRunExecutor.RunBossLoop().ContinueWith(t => + _ = KulemakExecutor.RunBossLoop().ContinueWith(t => { if (t.IsFaulted) { diff --git a/src/Poe2Trade.Bot/KulemakExecutor.cs b/src/Poe2Trade.Bot/KulemakExecutor.cs new file mode 100644 index 0000000..6739218 --- /dev/null +++ b/src/Poe2Trade.Bot/KulemakExecutor.cs @@ -0,0 +1,484 @@ +using Poe2Trade.Core; +using Poe2Trade.Game; +using Poe2Trade.GameLog; +using Poe2Trade.Inventory; +using Poe2Trade.Navigation; +using Poe2Trade.Screen; +using Serilog; + +namespace Poe2Trade.Bot; + +/// +/// Kulemak-specific boss run executor: scripted 4-phase + ring fight, +/// invitations, cathedral navigation. +/// +public class KulemakExecutor : MappingExecutor +{ + 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 NewInstanceTemplate = Path.Combine("assets", "new.png"); + + public KulemakExecutor(IGameController game, IScreenReader screen, + IInventoryManager inventory, IClientLogWatcher logWatcher, SavedSettings config, + BossDetector bossDetector, HudReader hudReader, NavigationExecutor nav) + : base(game, screen, inventory, logWatcher, config, bossDetector, hudReader, nav) + { + } + + public async Task RunBossLoop() + { + ResetStop(); + var runCount = _config.Kulemak.RunCount; + Log.Information("Starting boss run loop ({Count} runs)", runCount); + + var completed = 0; + try + { + if (!await Prepare()) + { + SetState(MappingState.Failed); + await RecoverToHideout(); + SetState(MappingState.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(MappingState.Looting); + await Sleep(1000); + 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(MappingState.Complete); + await Helpers.Sleep(1000); + SetState(MappingState.Idle); + } + + private async Task Prepare() + { + SetState(MappingState.Preparing); + Log.Information("Preparing: depositing inventory and grabbing invitations"); + + await _game.FocusGame(); + await Sleep(Delays.PostFocus); + + var stashPos = await _inventory.FindAndClickNameplate("Stash"); + if (stashPos == null) + { + Log.Error("Could not find Stash nameplate"); + return false; + } + await Sleep(Delays.PostStashOpen); + + var (lootTab, lootFolder) = ResolveTabPath(_config.Kulemak.LootTabPath); + if (lootTab != null) + { + await _inventory.ClickStashTab(lootTab, lootFolder); + + 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"); + } + + 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); + } + else + { + Log.Warning("Invitation tab path not configured or not found, skipping grab"); + } + + await _game.PressEscape(); + _inventory.ResetStashTabState(); + await Sleep(Delays.PostEscape); + + Log.Information("Preparation complete"); + return true; + } + + private async Task TravelToZone() + { + SetState(MappingState.TravelingToZone); + Log.Information("Traveling to Well of Souls via waypoint"); + + await _game.FocusGame(); + await Sleep(Delays.PostFocus); + + var wpPos = await _inventory.FindAndClickNameplate("Waypoint"); + if (wpPos == null) + { + Log.Error("Could not find Waypoint nameplate"); + return false; + } + await Sleep(1500); + + 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); + + 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(MappingState.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(MappingState.UsingInvitation); + Log.Information("Using invitation at ({X},{Y})", x, y); + + await _game.MoveMouseTo(x, y); + await Sleep(200); + await _game.CtrlLeftClickAt(x, y); + await Sleep(500); + + 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); + + 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 async Task Fight() + { + SetState(MappingState.Fighting); + Log.Information("Fight phase starting"); + + await Sleep(3000); + if (_stopped) return; + + 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); + + await Sleep(14000); + if (_stopped) return; + + StartBossDetection("kulemak"); + + 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; + + 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; + + 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); + } + + var deathPos = await PollYoloDuringWait(2000); + if (deathPos != null) + { + fightWorldX = deathPos.Value.X; + fightWorldY = deathPos.Value.Y; + } + + 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); + + await WalkToWorldPosition(fightWorldX + 20, fightWorldY +20, 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; + + { + 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); + + 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; + + Log.Information("Walking to fight position ({X:F0},{Y:F0}) + offset", 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; + } + } + + /// + /// Store loot with optional invitation grab for the next run. + /// + private async Task StoreLoot(bool grabInvitation) + { + // Use base StoreLoot for the deposit logic, but we need the stash to stay open + // for invitation grab, so we inline the full flow here. + 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 _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"); + } + } + + await _game.PressEscape(); + _inventory.ResetStashTabState(); + await Sleep(Delays.PostEscape); + + Log.Information("Loot stored"); + } +} diff --git a/src/Poe2Trade.Bot/MappingExecutor.cs b/src/Poe2Trade.Bot/MappingExecutor.cs new file mode 100644 index 0000000..0c9ee75 --- /dev/null +++ b/src/Poe2Trade.Bot/MappingExecutor.cs @@ -0,0 +1,639 @@ +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; + +/// +/// 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 _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); + } + + await _game.PressEscape(); + _inventory.ResetStashTabState(); + await Sleep(Delays.PostEscape); + + Log.Information("Loot stored"); + } +} diff --git a/src/Poe2Trade.Core/Types.cs b/src/Poe2Trade.Core/Types.cs index f91d79e..934ae74 100644 --- a/src/Poe2Trade.Core/Types.cs +++ b/src/Poe2Trade.Core/Types.cs @@ -75,7 +75,7 @@ public enum BotMode Mapping } -public enum BossRunState +public enum MappingState { Idle, Preparing, diff --git a/src/Poe2Trade.Ui/Overlay/D2dOverlay.cs b/src/Poe2Trade.Ui/Overlay/D2dOverlay.cs index be6bc87..2311a5f 100644 --- a/src/Poe2Trade.Ui/Overlay/D2dOverlay.cs +++ b/src/Poe2Trade.Ui/Overlay/D2dOverlay.cs @@ -200,7 +200,7 @@ public sealed class D2dOverlay ShowYolo: _bot.ShowYoloOverlay, ShowFightPosition: _bot.ShowFightPositionOverlay, LootLabels: _bot.LootDebugDetector.Latest, - FightPosition: _bot.BossRunExecutor.FightPosition, + FightPosition: _bot.KulemakExecutor.FightPosition, Fps: fps, Timing: timing); } diff --git a/src/Poe2Trade.Ui/ViewModels/DebugViewModel.cs b/src/Poe2Trade.Ui/ViewModels/DebugViewModel.cs index ee40af9..f269e52 100644 --- a/src/Poe2Trade.Ui/ViewModels/DebugViewModel.cs +++ b/src/Poe2Trade.Ui/ViewModels/DebugViewModel.cs @@ -383,7 +383,7 @@ public partial class DebugViewModel : ObservableObject DebugResult = "Loot test: focusing game..."; await _bot.Game.FocusGame(); await Task.Delay(300); - await _bot.BossRunExecutor.Loot(); + await _bot.KulemakExecutor.Loot(); DebugResult = "Loot test: complete"; } catch (Exception ex) diff --git a/src/Poe2Trade.Ui/ViewModels/MainWindowViewModel.cs b/src/Poe2Trade.Ui/ViewModels/MainWindowViewModel.cs index edd75f9..9134473 100644 --- a/src/Poe2Trade.Ui/ViewModels/MainWindowViewModel.cs +++ b/src/Poe2Trade.Ui/ViewModels/MainWindowViewModel.cs @@ -194,7 +194,7 @@ public partial class MainWindowViewModel : ObservableObject { Log.Information("END pressed — emergency stop"); await _bot.Navigation.Stop(); - _bot.BossRunExecutor.Stop(); + _bot.KulemakExecutor.Stop(); _bot.Pause(); Avalonia.Threading.Dispatcher.UIThread.Post(() => { diff --git a/src/Poe2Trade.Ui/Views/MainWindow.axaml b/src/Poe2Trade.Ui/Views/MainWindow.axaml index f8ec98d..760f7fd 100644 --- a/src/Poe2Trade.Ui/Views/MainWindow.axaml +++ b/src/Poe2Trade.Ui/Views/MainWindow.axaml @@ -175,7 +175,7 @@ - +