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()