poe2-bot/src/Poe2Trade.Bot/BossRunExecutor.cs
2026-02-22 18:41:23 -05:00

1080 lines
39 KiB
C#

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<BossRunState>? 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;
/// <summary>
/// Current fight position in world coordinates, or null if not fighting.
/// </summary>
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<bool> 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<bool> 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<TemplateMatchResult?> 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<bool> 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(3000);
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(1500);
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");
await AttackBossUntilGone(fightWorldX, fightWorldY);
if (_stopped) return;
StopBossDetection();
Log.Information("Fight complete");
}
finally
{
_nav.Frozen = false;
FightPosition = null;
}
}
/// <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>
private async Task<bool> 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;
}
/// <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();
private 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;
}
/// <summary>
/// Wait for the boss healthbar to appear (boss spawns/becomes active).
/// </summary>
// -- 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;
/// <summary>
/// Background combat loop — calls Tick continuously until cancelled.
/// Target position is updated via volatile fields from the main thread.
/// </summary>
private 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>
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<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;
}
/// <summary>
/// Wait for boss to spawn, then attack until healthbar disappears.
/// Returns the last world position where YOLO spotted the boss, or null.
/// </summary>
private async Task<(double X, double Y)?> AttackBossUntilGone(
double fightAreaX = double.NaN, double fightAreaY = double.NaN, int timeoutMs = 120_000)
{
// 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 for real-time chase updates
// (main loop is too slow due to template matching to effectively track boss)
void OnBossDetected(BossSnapshot snapshot)
{
if (snapshot.Bosses.Count == 0) return;
var boss = snapshot.Bosses[0];
_combatTargetX = boss.Cx;
_combatTargetY = boss.Cy;
_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);
}
}
/// <summary>
/// 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.
/// </summary>
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);
}
}
/// <summary>
/// Find all template matches and click the one closest to screen center.
/// </summary>
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);
}
/// <summary>
/// Walk to a world position using WASD keys, checking minimap position each iteration.
/// </summary>
private async Task WalkToWorldPosition(double worldX, double worldY, int timeoutMs = 10000,
double arrivalDist = 15, 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; // 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<int>();
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<bool> 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), retrying", attempt);
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");
}
}