1080 lines
39 KiB
C#
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");
|
|
}
|
|
}
|