diff --git a/debug_loot_capture.png b/debug_loot_capture.png index 1e7069b..80cf44a 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 56c21c2..af1c6d3 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 dcc5e79..03da3c7 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 index e859ad9..ce28a32 100644 --- a/src/Poe2Trade.Bot/BossRunExecutor.cs +++ b/src/Poe2Trade.Bot/BossRunExecutor.cs @@ -44,6 +44,11 @@ public class BossRunExecutor : GameExecutor 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; @@ -58,77 +63,84 @@ public class BossRunExecutor : GameExecutor public async Task RunBossLoop() { - _stopped = false; + ResetStop(); var runCount = _config.Kulemak.RunCount; Log.Information("Starting boss run loop ({Count} runs)", runCount); - // First run: deposit inventory and grab 1 invitation - if (!await Prepare()) - { - SetState(BossRunState.Failed); - await RecoverToHideout(); - SetState(BossRunState.Idle); - return; - } - var completed = 0; - for (var i = 0; i < runCount; i++) + try { - if (_stopped) break; - - Log.Information("=== Boss run {N}/{Total} ===", i + 1, runCount); - - if (!await TravelToZone()) + // First run: deposit inventory and grab 1 invitation + if (!await Prepare()) { - Log.Error("Failed to travel to zone"); + SetState(BossRunState.Failed); await RecoverToHideout(); - break; + SetState(BossRunState.Idle); + return; } - if (_stopped) break; - var entrance = await WalkToEntrance(); - if (entrance == null) + for (var i = 0; i < runCount; i++) { - Log.Error("Failed to find Black Cathedral entrance"); - await RecoverToHideout(); - break; + 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; } - 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 Helpers.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); + await Helpers.Sleep(1000); // non-cancellable final delay SetState(BossRunState.Idle); } @@ -138,7 +150,7 @@ public class BossRunExecutor : GameExecutor Log.Information("Preparing: depositing inventory and grabbing invitations"); await _game.FocusGame(); - await Helpers.Sleep(Delays.PostFocus); + await Sleep(Delays.PostFocus); // Open stash var stashPos = await _inventory.FindAndClickNameplate("Stash"); @@ -147,7 +159,7 @@ public class BossRunExecutor : GameExecutor Log.Error("Could not find Stash nameplate"); return false; } - await Helpers.Sleep(Delays.PostStashOpen); + await Sleep(Delays.PostStashOpen); // Click loot tab and deposit all inventory items var (lootTab, lootFolder) = ResolveTabPath(_config.Kulemak.LootTabPath); @@ -166,11 +178,11 @@ public class BossRunExecutor : GameExecutor { var center = _screen.Grid.GetCellCenter(GridLayouts.Inventory, cell.Row, cell.Col); await _game.LeftClickAt(center.X, center.Y); - await Helpers.Sleep(Delays.ClickInterval); + await Sleep(Delays.ClickInterval); } await _game.ReleaseCtrl(); await _game.KeyUp(InputSender.VK.SHIFT); - await Helpers.Sleep(Delays.PostEscape); + await Sleep(Delays.PostEscape); } } else @@ -203,7 +215,7 @@ public class BossRunExecutor : GameExecutor // Close stash await _game.PressEscape(); _inventory.ResetStashTabState(); - await Helpers.Sleep(Delays.PostEscape); + await Sleep(Delays.PostEscape); Log.Information("Preparation complete"); return true; @@ -215,7 +227,7 @@ public class BossRunExecutor : GameExecutor Log.Information("Traveling to Well of Souls via waypoint"); await _game.FocusGame(); - await Helpers.Sleep(Delays.PostFocus); + await Sleep(Delays.PostFocus); // Find and click Waypoint var wpPos = await _inventory.FindAndClickNameplate("Waypoint"); @@ -224,7 +236,7 @@ public class BossRunExecutor : GameExecutor Log.Error("Could not find Waypoint nameplate"); return false; } - await Helpers.Sleep(500); + await Sleep(500); // Template match well-of-souls.png and click var match = await _screen.TemplateMatch(WellOfSoulsTemplate); @@ -246,7 +258,7 @@ public class BossRunExecutor : GameExecutor return false; } - await Helpers.Sleep(Delays.PostTravel); + await Sleep(Delays.PostTravel); Log.Information("Arrived at Well of Souls"); return true; } @@ -266,9 +278,9 @@ public class BossRunExecutor : GameExecutor // Hover first so the game registers the target, then use invitation await _game.MoveMouseTo(x, y); - await Helpers.Sleep(200); + await Sleep(200); await _game.CtrlLeftClickAt(x, y); - await Helpers.Sleep(500); + await Sleep(500); // Find "NEW" button via template match — pick the leftmost var matches = await _screen.TemplateMatchAll(NewInstanceTemplate); @@ -282,7 +294,7 @@ public class BossRunExecutor : GameExecutor 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 Helpers.Sleep(150); + await Sleep(150); await _game.LeftClickAt(target.X, target.Y); // Wait for area transition into boss arena @@ -293,7 +305,7 @@ public class BossRunExecutor : GameExecutor return false; } - await Helpers.Sleep(Delays.PostTravel); + await Sleep(Delays.PostTravel); Log.Information("Entered boss arena"); return true; } @@ -317,7 +329,7 @@ public class BossRunExecutor : GameExecutor Log.Information("Fight phase starting"); // Wait for arena to settle - await Helpers.Sleep(3000); + await Sleep(3000); if (_stopped) return; // Find and click the cathedral door @@ -332,7 +344,7 @@ public class BossRunExecutor : GameExecutor await _game.LeftClickAt(door.X, door.Y); // Wait for cathedral interior to load - await Helpers.Sleep(14000); + await Sleep(14000); if (_stopped) return; StartBossDetection(); @@ -343,6 +355,7 @@ public class BossRunExecutor : GameExecutor 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 if (_stopped) { _nav.Frozen = false; return; } @@ -367,18 +380,19 @@ public class BossRunExecutor : GameExecutor { 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 before looking for well - await Helpers.Sleep(3000); + await Sleep(3000); // Walk to well and click the closest match to screen center Log.Information("Phase {Phase} done, walking to well", phase); await WalkToWorldPosition(wellWorldX, wellWorldY); - await Helpers.Sleep(500); + await Sleep(500); await ClickClosestTemplateToCenter(CathedralWellTemplate); - await Helpers.Sleep(200); + await Sleep(200); // Walk back to fight position for next phase await WalkToWorldPosition(fightWorldX, fightWorldY, cancelWhen: IsBossAlive); @@ -405,12 +419,13 @@ public class BossRunExecutor : GameExecutor { 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 Helpers.Sleep(1000); + await Sleep(1000); if (_stopped) return; Log.Information("Looking for Return the Ring..."); @@ -423,7 +438,7 @@ public class BossRunExecutor : GameExecutor { Log.Information("Found Return the Ring at ({X},{Y}), clicking", ring.X, ring.Y); await _game.LeftClickAt(ring.X, ring.Y); - await Helpers.Sleep(500); + await Sleep(500); } else { @@ -434,17 +449,14 @@ public class BossRunExecutor : GameExecutor // Walk back to fight area — fightWorldX/Y carries position from all phases Log.Information("Walking to fight position ({X:F0},{Y:F0})", fightWorldX, fightWorldY); await WalkToWorldPosition(fightWorldX, fightWorldY); - await Helpers.Sleep(300); - await _game.PressKey(InputSender.VK.Q); - await Helpers.Sleep(300); - await _game.PressKey(InputSender.VK.E); - await Helpers.Sleep(300); + await Sleep(300); Log.Information("Attacking at ring fight position"); await AttackBossUntilGone(fightWorldX, fightWorldY); if (_stopped) return; StopBossDetection(); _nav.Frozen = false; + FightPosition = null; Log.Information("Fight complete"); } @@ -490,7 +502,7 @@ public class BossRunExecutor : GameExecutor Log.Warning("Death detected! Clicking resurrect at ({X},{Y})", match.X, match.Y); await _combat.ReleaseAll(); await _game.LeftClickAt(match.X, match.Y); - await Helpers.Sleep(3000); // wait for respawn + loading + await Sleep(3000); // wait for respawn + loading return true; } @@ -547,7 +559,7 @@ public class BossRunExecutor : GameExecutor var consecutive = 0; while (sw.ElapsedMilliseconds < timeoutMs && consecutive < minConsecutive) { - await Helpers.Sleep(50); + await Sleep(50); if (_nav.LastMatchSucceeded) consecutive++; else @@ -566,46 +578,31 @@ public class BossRunExecutor : GameExecutor Log.Information("Waiting for boss healthbar to appear..."); var sw = Stopwatch.StartNew(); - var (combatTask, cts) = StartCombatLoop(); - try + while (sw.ElapsedMilliseconds < timeoutMs) { - while (sw.ElapsedMilliseconds < timeoutMs) + if (_stopped) return false; + + if (await CheckDeath()) continue; + + if (await IsBossAlive()) { - if (_stopped) return false; - - // Update target from YOLO (fast, no capture) - var snapshot = _bossDetector.Latest; - if (snapshot.Bosses.Count > 0) - { - _combatTargetX = snapshot.Bosses[0].Cx; - _combatTargetY = snapshot.Bosses[0].Cy; - } - - // Check death + healthbar (blocks main thread, combat continues in background) - if (await CheckDeath()) continue; - - if (await IsBossAlive()) - { - Log.Information("Boss healthbar detected after {Ms}ms", sw.ElapsedMilliseconds); - return true; - } + Log.Information("Boss healthbar detected after {Ms}ms", sw.ElapsedMilliseconds); + return true; } - // 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; - } + await Sleep(50); + } - Log.Warning("WaitForBossSpawn timed out after {Ms}ms", timeoutMs); + // 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; } - finally - { - await StopCombatLoop(combatTask, cts); - } + + Log.Warning("WaitForBossSpawn timed out after {Ms}ms", timeoutMs); + return false; } /// @@ -688,8 +685,9 @@ public class BossRunExecutor : GameExecutor _lastDeathCheckMs = 0; if (await CheckDeath()) { healthbarMissCount = 0; continue; } - Log.Information("Healthbar gone for {N} checks, boss phase over after {Ms}ms", - healthbarMissCount, sw.ElapsedMilliseconds); + Log.Information("Healthbar gone for {N} checks, boss phase over after {Ms}ms, lastBossPos={Boss}", + healthbarMissCount, sw.ElapsedMilliseconds, + lastBossWorldPos != null ? $"({lastBossWorldPos.Value.X:F1},{lastBossWorldPos.Value.Y:F1})" : "null"); return lastBossWorldPos; } } @@ -715,7 +713,7 @@ public class BossRunExecutor : GameExecutor { if (_stopped) return; if (await CheckDeath()) continue; - await Helpers.Sleep(500); + await Sleep(500); } } finally @@ -731,7 +729,7 @@ public class BossRunExecutor : GameExecutor { var sw = Stopwatch.StartNew(); // Attack for at least 2s before checking healthbar - await Helpers.Sleep(2000); + await Sleep(2000); while (sw.ElapsedMilliseconds < timeoutMs) { if (_stopped) return; @@ -741,7 +739,7 @@ public class BossRunExecutor : GameExecutor Log.Information("Boss dead, stopping attack after {Ms}ms", sw.ElapsedMilliseconds); return; } - await Helpers.Sleep(500); + await Sleep(500); } Log.Warning("AttackUntilBossDead timed out after {Ms}ms", timeoutMs); } @@ -784,9 +782,9 @@ public class BossRunExecutor : GameExecutor 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 Helpers.Sleep(300); + await Sleep(300); await _game.KeyUp(nudgeKey); - await Helpers.Sleep(500); + await Sleep(500); } Log.Warning("No matches found for {Template} after nudges, clicking screen center", Path.GetFileName(templatePath)); @@ -836,15 +834,15 @@ public class BossRunExecutor : GameExecutor var dirX = dx / len; var dirY = dy / len; - // Blink toward destination every ~2.3s ± 0.3s + // Blink toward destination every ~2.3s ± 0.3s (only when far enough to avoid overshooting) var blinkCooldown = 2300 + Rng.Next(-300, 301); - if (sw.ElapsedMilliseconds - lastBlinkMs >= blinkCooldown) + 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 Helpers.Sleep(30); + await Sleep(30); await _game.PressKey(InputSender.VK.SPACE); lastBlinkMs = sw.ElapsedMilliseconds; } @@ -869,7 +867,7 @@ public class BossRunExecutor : GameExecutor heldKeys.Add(key); } - await Helpers.Sleep(100); + await Sleep(100); } if (sw.ElapsedMilliseconds >= timeoutMs) @@ -889,17 +887,17 @@ public class BossRunExecutor : GameExecutor Log.Information("Returning home"); await _game.FocusGame(); - await Helpers.Sleep(Delays.PostFocus); + await Sleep(Delays.PostFocus); // Walk away from loot (hold S briefly) await _game.KeyDown(InputSender.VK.S); - await Helpers.Sleep(500); + await Sleep(500); await _game.KeyUp(InputSender.VK.S); - await Helpers.Sleep(200); + await Sleep(200); // Press + to open portal, then click it at known position await _game.PressPlus(); - await Helpers.Sleep(2500); + await Sleep(2500); await _game.LeftClickAt(1280, 450); // Wait for area transition to caravan @@ -909,7 +907,7 @@ public class BossRunExecutor : GameExecutor Log.Error("Timed out waiting for caravan transition"); return false; } - await Helpers.Sleep(Delays.PostTravel); + await Sleep(Delays.PostTravel); // /hideout to go home var arrivedHome = await _inventory.WaitForAreaTransition( @@ -920,7 +918,7 @@ public class BossRunExecutor : GameExecutor return false; } - await Helpers.Sleep(Delays.PostTravel); + await Sleep(Delays.PostTravel); _inventory.SetLocation(true); Log.Information("Arrived at hideout"); return true; @@ -932,7 +930,7 @@ public class BossRunExecutor : GameExecutor Log.Information("Storing loot"); await _game.FocusGame(); - await Helpers.Sleep(Delays.PostFocus); + await Sleep(Delays.PostFocus); // Identify items at Doryani before stashing await _inventory.IdentifyItems(); @@ -944,7 +942,7 @@ public class BossRunExecutor : GameExecutor Log.Warning("Could not find Stash, skipping loot storage"); return; } - await Helpers.Sleep(Delays.PostStashOpen); + await Sleep(Delays.PostStashOpen); // Click loot tab and deposit all inventory items var (lootTab, lootFolder) = ResolveTabPath(_config.Kulemak.LootTabPath); @@ -968,12 +966,12 @@ public class BossRunExecutor : GameExecutor { var center = _screen.Grid.GetCellCenter(GridLayouts.Inventory, item.Row, item.Col); await _game.LeftClickAt(center.X, center.Y); - await Helpers.Sleep(Delays.ClickInterval); + await Sleep(Delays.ClickInterval); } await _game.ReleaseCtrl(); await _game.KeyUp(InputSender.VK.SHIFT); - await Helpers.Sleep(Delays.PostEscape); + await Sleep(Delays.PostEscape); } // Grab 1 invitation for the next run while stash is still open @@ -998,7 +996,7 @@ public class BossRunExecutor : GameExecutor // Close stash await _game.PressEscape(); _inventory.ResetStashTabState(); - await Helpers.Sleep(Delays.PostEscape); + await Sleep(Delays.PostEscape); Log.Information("Loot stored"); } diff --git a/src/Poe2Trade.Bot/CombatManager.cs b/src/Poe2Trade.Bot/CombatManager.cs index 8f00535..91a2db4 100644 --- a/src/Poe2Trade.Bot/CombatManager.cs +++ b/src/Poe2Trade.Bot/CombatManager.cs @@ -32,6 +32,9 @@ public class CombatManager private long _lastOrbitMs; private int _nextOrbitMs = OrbitStepMinMs; + // Ability rotation — press Q and E every ~6 seconds + private long _lastAbilityMs; + // Chase — walks toward a screen position instead of orbiting private volatile int _chaseX = -1; private volatile int _chaseY = -1; @@ -69,6 +72,7 @@ public class CombatManager public async Task Tick(int x, int y, int jitter = 30) { await _flasks.Tick(); + await UseAbilities(); if (_chaseX >= 0) { @@ -210,6 +214,35 @@ public class CombatManager } } + /// + /// Press Q and E abilities every ~6 seconds. + /// + private async Task UseAbilities() + { + var now = _orbitSw.ElapsedMilliseconds; + if (now - _lastAbilityMs < 6000) return; + _lastAbilityMs = now; + + // Release held attacks before abilities + if (_holding) + { + _game.LeftMouseUp(); + _game.RightMouseUp(); + } + + await _game.PressKey(InputSender.VK.Q); + await Helpers.Sleep(Rng.Next(80, 120)); + await _game.PressKey(InputSender.VK.E); + await Helpers.Sleep(Rng.Next(80, 120)); + + // Re-engage attacks + if (_holding) + { + _game.LeftMouseDown(); + _game.RightMouseDown(); + } + } + /// /// Reset state for a new combat phase (releases held buttons if any). /// diff --git a/src/Poe2Trade.Bot/GameExecutor.cs b/src/Poe2Trade.Bot/GameExecutor.cs index e8e4fca..44d4ee4 100644 --- a/src/Poe2Trade.Bot/GameExecutor.cs +++ b/src/Poe2Trade.Bot/GameExecutor.cs @@ -20,6 +20,10 @@ public abstract class GameExecutor protected readonly IInventoryManager _inventory; protected readonly SavedSettings _config; protected volatile bool _stopped; + protected CancellationTokenSource _stopCts = new(); + + /// Cancellation token that fires when Stop() is called. + protected CancellationToken StopToken => _stopCts.Token; protected GameExecutor(IGameController game, IScreenReader screen, IInventoryManager inventory, SavedSettings config) @@ -33,8 +37,20 @@ public abstract class GameExecutor public virtual void Stop() { _stopped = true; + _stopCts.Cancel(); } + /// Reset stopped state for a new run. + protected void ResetStop() + { + _stopped = false; + _stopCts.Dispose(); + _stopCts = new CancellationTokenSource(); + } + + /// Cancellable sleep that throws OperationCanceledException when stopped. + protected Task Sleep(int ms) => Helpers.Sleep(ms, _stopCts.Token); + // ------ Loot pickup ------ // Tiers to skip (noise, low-value, or hidden by filter) @@ -53,11 +69,11 @@ public abstract class GameExecutor // Move mouse out of the way so it doesn't cover labels _game.MoveMouseInstant(0, 1440); - await Helpers.Sleep(100); + await Sleep(100); // Hold Alt, capture, detect await _game.KeyDown(InputSender.VK.MENU); - await Helpers.Sleep(250); + await Sleep(250); using var capture = _screen.CaptureRawBitmap(); var labels = _screen.DetectLootLabels(capture, capture); @@ -81,14 +97,14 @@ public abstract class GameExecutor label.Tier, label.AvgR, label.AvgG, label.AvgB, label.CenterX, label.CenterY); await _game.LeftClickAt(label.CenterX, label.CenterY); totalPicked++; - await Helpers.Sleep(200); + await Sleep(200); } // Quick check: capture small region around each clicked label to see if // new labels appeared underneath. If none changed, we're done. - await Helpers.Sleep(300); + await Sleep(300); _game.MoveMouseInstant(0, 1440); - await Helpers.Sleep(100); + await Sleep(100); using var recheck = _screen.CaptureRawBitmap(); var newLabels = _screen.DetectLootLabels(recheck, recheck); @@ -103,7 +119,7 @@ public abstract class GameExecutor } Log.Information("Quick recheck: {Count} new labels appeared, continuing", newPickups.Count); - await Helpers.Sleep(300); + await Sleep(300); } Log.Information("Loot pickup complete ({Count} items)", totalPicked); @@ -118,9 +134,9 @@ public abstract class GameExecutor Log.Information("Recovering: escaping and going to hideout"); await _game.FocusGame(); await _game.PressEscape(); - await Helpers.Sleep(Delays.PostEscape); + await Sleep(Delays.PostEscape); await _game.PressEscape(); - await Helpers.Sleep(Delays.PostEscape); + await Sleep(Delays.PostEscape); var arrived = await _inventory.WaitForAreaTransition( _config.TravelTimeoutMs, () => _game.GoToHideout()); @@ -160,7 +176,7 @@ public abstract class GameExecutor var match = await _screen.TemplateMatch(templatePath); if (match == null) { - await Helpers.Sleep(500); + await Sleep(500); continue; } @@ -182,7 +198,7 @@ public abstract class GameExecutor // Stop, settle, re-match for accurate position await _game.KeyUp(vk2); await _game.KeyUp(vk1); - await Helpers.Sleep(300); + await Sleep(300); var fresh = await _screen.TemplateMatch(templatePath); if (fresh != null) @@ -194,7 +210,7 @@ public abstract class GameExecutor return match; } - await Helpers.Sleep(200); + await Sleep(200); } Log.Error("WalkAndMatch timed out after {Ms}ms (spotted={Spotted})", timeoutMs, spotted); return null; diff --git a/src/Poe2Trade.Core/Helpers.cs b/src/Poe2Trade.Core/Helpers.cs index b6afcc8..81519f6 100644 --- a/src/Poe2Trade.Core/Helpers.cs +++ b/src/Poe2Trade.Core/Helpers.cs @@ -4,11 +4,11 @@ public static class Helpers { private static readonly Random Rng = new(); - public static Task Sleep(int ms) + public static Task Sleep(int ms, CancellationToken ct = default) { var variance = Math.Max(1, ms / 10); // ±10% var actual = ms + Rng.Next(-variance, variance + 1); - return Task.Delay(Math.Max(1, actual)); + return Task.Delay(Math.Max(1, actual), ct); } public static Task RandomDelay(int minMs, int maxMs) diff --git a/src/Poe2Trade.Ui/Overlay/D2dOverlay.cs b/src/Poe2Trade.Ui/Overlay/D2dOverlay.cs index 2c15277..f73716a 100644 --- a/src/Poe2Trade.Ui/Overlay/D2dOverlay.cs +++ b/src/Poe2Trade.Ui/Overlay/D2dOverlay.cs @@ -198,6 +198,7 @@ public sealed class D2dOverlay ShowHudDebug: _bot.Store.Settings.ShowHudDebug, ShowLootDebug: showLoot, LootLabels: _bot.LootDebugDetector.Latest, + FightPosition: _bot.BossRunExecutor.FightPosition, Fps: fps, Timing: timing); } diff --git a/src/Poe2Trade.Ui/Overlay/IOverlayLayer.cs b/src/Poe2Trade.Ui/Overlay/IOverlayLayer.cs index 07c2fd2..415f5db 100644 --- a/src/Poe2Trade.Ui/Overlay/IOverlayLayer.cs +++ b/src/Poe2Trade.Ui/Overlay/IOverlayLayer.cs @@ -15,6 +15,7 @@ public record OverlayState( bool ShowHudDebug, bool ShowLootDebug, IReadOnlyList LootLabels, + (double X, double Y)? FightPosition, double Fps, RenderTiming? Timing); diff --git a/src/Poe2Trade.Ui/Overlay/Layers/D2dEnemyBoxLayer.cs b/src/Poe2Trade.Ui/Overlay/Layers/D2dEnemyBoxLayer.cs index 7a5513d..1084c4d 100644 --- a/src/Poe2Trade.Ui/Overlay/Layers/D2dEnemyBoxLayer.cs +++ b/src/Poe2Trade.Ui/Overlay/Layers/D2dEnemyBoxLayer.cs @@ -82,6 +82,18 @@ internal sealed class D2dEnemyBoxLayer : ID2dOverlayLayer, IDisposable rt.DrawTextLayout(new System.Numerics.Vector2(labelX, labelY), layout, ctx.Cyan); } + + // Fight position — red circle on screen where the fight area is + if (state.FightPosition is var (fx, fy)) + { + const double worldToScreen = 835.0 / 97.0; // inverse of screenToWorld + const int screenCx = 1280, screenCy = 720; + var wp = state.NavPosition; + var sx = (float)(screenCx + (fx - wp.X) * worldToScreen); + var sy = (float)(screenCy + (fy - wp.Y) * worldToScreen); + var ellipse = new Ellipse(new System.Numerics.Vector2(sx, sy), 40f, 40f); + rt.DrawEllipse(ellipse, ctx.Red, 3f); + } } public void Dispose()