639 lines
22 KiB
C#
639 lines
22 KiB
C#
using System.Diagnostics;
|
|
using Nexus.Core;
|
|
using Nexus.Game;
|
|
using Nexus.GameLog;
|
|
using Nexus.Inventory;
|
|
using Nexus.Navigation;
|
|
using Nexus.Screen;
|
|
using Serilog;
|
|
|
|
namespace Nexus.Bot;
|
|
|
|
/// <summary>
|
|
/// Shared infrastructure for any map/boss activity: combat loop, WASD navigation,
|
|
/// boss detection, death checks, portal-stash cycle, loot storage.
|
|
/// </summary>
|
|
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<MappingState>? 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;
|
|
|
|
/// <summary>
|
|
/// Current fight position in world coordinates, or null if not fighting.
|
|
/// </summary>
|
|
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;
|
|
|
|
/// <summary>
|
|
/// Background combat loop — calls Tick continuously until cancelled.
|
|
/// Target position is updated via volatile fields from the main thread.
|
|
/// </summary>
|
|
protected async Task RunCombatLoop(CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
while (!ct.IsCancellationRequested)
|
|
await _combat.Tick(_combatTargetX, _combatTargetY, _combatJitter);
|
|
}
|
|
catch (OperationCanceledException) { }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Start background combat loop, returning the task and CTS.
|
|
/// Caller must cancel + await + ReleaseAll when done.
|
|
/// </summary>
|
|
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 --
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
protected async Task<bool> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private long _lastDeathCheckMs;
|
|
private readonly Stopwatch _deathCheckSw = Stopwatch.StartNew();
|
|
|
|
protected async Task<bool> 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<bool> 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 --
|
|
|
|
/// <summary>
|
|
/// Wait for boss to spawn, then attack until healthbar disappears.
|
|
/// Returns the last world position where YOLO spotted the boss, or null.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sleep for the given duration while polling YOLO to keep FightPosition updated.
|
|
/// Returns last detected position, or null if no detections.
|
|
/// </summary>
|
|
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 --
|
|
|
|
/// <summary>
|
|
/// Find all template matches and click the one closest to screen center.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Walk to a world position using WASD keys, checking minimap position each iteration.
|
|
/// </summary>
|
|
protected async Task WalkToWorldPosition(double worldX, double worldY, int timeoutMs = 10000,
|
|
double arrivalDist = 5, Func<Task<bool>>? 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<int>();
|
|
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<int>();
|
|
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) --
|
|
|
|
/// <summary>
|
|
/// Portal → caravan → /hideout with retry.
|
|
/// </summary>
|
|
protected virtual async Task<bool> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Identify items → deposit to configured loot tab.
|
|
/// </summary>
|
|
protected virtual async Task StoreLoot()
|
|
{
|
|
SetState(MappingState.StoringLoot);
|
|
Log.Information("Storing loot");
|
|
|
|
await _game.FocusGame();
|
|
await Sleep(Delays.PostFocus);
|
|
|
|
await _inventory.IdentifyItems();
|
|
|
|
var stashPos = await _inventory.FindAndClickNameplate("Stash");
|
|
if (stashPos == null)
|
|
{
|
|
Log.Warning("Could not find Stash, skipping loot storage");
|
|
return;
|
|
}
|
|
await Sleep(Delays.PostStashOpen);
|
|
|
|
var (lootTab, lootFolder) = ResolveTabPath(_config.Kulemak.LootTabPath);
|
|
if (lootTab != null)
|
|
await _inventory.ClickStashTab(lootTab, lootFolder);
|
|
|
|
for (var pass = 0; pass < 3; pass++)
|
|
{
|
|
var scanResult = await _inventory.SnapshotInventory();
|
|
if (scanResult.Items.Count == 0)
|
|
{
|
|
if (pass > 0) Log.Information("Inventory clear after {Pass} passes", pass);
|
|
break;
|
|
}
|
|
|
|
Log.Information("Depositing {Count} items to loot tab (pass {Pass})", scanResult.Items.Count, pass + 1);
|
|
await _game.KeyDown(InputSender.VK.SHIFT);
|
|
await _game.HoldCtrl();
|
|
|
|
foreach (var item in scanResult.Items)
|
|
{
|
|
var center = _screen.Grid.GetCellCenter(GridLayouts.Inventory, item.Row, item.Col);
|
|
await _game.LeftClickAt(center.X, center.Y);
|
|
await Sleep(Delays.ClickInterval);
|
|
}
|
|
|
|
await _game.ReleaseCtrl();
|
|
await _game.KeyUp(InputSender.VK.SHIFT);
|
|
await Sleep(Delays.PostEscape);
|
|
}
|
|
|
|
await _game.PressEscape();
|
|
_inventory.ResetStashTabState();
|
|
await Sleep(Delays.PostEscape);
|
|
|
|
Log.Information("Loot stored");
|
|
}
|
|
}
|