fixed boss

This commit is contained in:
Boki 2026-02-23 00:32:19 -05:00
parent a197be4087
commit adc2450013
12 changed files with 1135 additions and 1093 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 MiB

After

Width:  |  Height:  |  Size: 7 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 MiB

After

Width:  |  Height:  |  Size: 5.7 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 336 KiB

After

Width:  |  Height:  |  Size: 326 KiB

Before After
Before After

File diff suppressed because it is too large Load diff

View file

@ -47,7 +47,7 @@ public class BotOrchestrator : IAsyncDisposable
public BossDetector BossDetector { get; } public BossDetector BossDetector { get; }
public FrameSaver FrameSaver { get; } public FrameSaver FrameSaver { get; }
public LootDebugDetector LootDebugDetector { get; } public LootDebugDetector LootDebugDetector { get; }
public BossRunExecutor BossRunExecutor { get; } public KulemakExecutor KulemakExecutor { get; }
public volatile bool ShowYoloOverlay = true; public volatile bool ShowYoloOverlay = true;
public volatile bool ShowFightPositionOverlay = true; public volatile bool ShowFightPositionOverlay = true;
private readonly Dictionary<string, ScrapExecutor> _scrapExecutors = new(); private readonly Dictionary<string, ScrapExecutor> _scrapExecutors = new();
@ -94,7 +94,7 @@ public class BotOrchestrator : IAsyncDisposable
Navigation = new NavigationExecutor(game, pipelineService.Pipeline, minimapCapture, Navigation = new NavigationExecutor(game, pipelineService.Pipeline, minimapCapture,
enemyDetector: EnemyDetector); enemyDetector: EnemyDetector);
BossRunExecutor = new BossRunExecutor(game, screen, inventory, logWatcher, store.Settings, BossDetector, HudReader, Navigation); KulemakExecutor = new KulemakExecutor(game, screen, inventory, logWatcher, store.Settings, BossDetector, HudReader, Navigation);
logWatcher.AreaEntered += area => logWatcher.AreaEntered += area =>
{ {
@ -212,9 +212,9 @@ public class BotOrchestrator : IAsyncDisposable
return; return;
} }
} }
if (BossRunExecutor.State != BossRunState.Idle) if (KulemakExecutor.State != MappingState.Idle)
{ {
State = BossRunExecutor.State.ToString(); State = KulemakExecutor.State.ToString();
return; return;
} }
if (Navigation.State != NavigationState.Idle) if (Navigation.State != NavigationState.Idle)
@ -301,7 +301,7 @@ public class BotOrchestrator : IAsyncDisposable
await Game.FocusGame(); await Game.FocusGame();
await Screen.Warmup(); await Screen.Warmup();
BossRunExecutor.StateChanged += _ => UpdateExecutorState(); KulemakExecutor.StateChanged += _ => UpdateExecutorState();
Navigation.StateChanged += _ => UpdateExecutorState(); Navigation.StateChanged += _ => UpdateExecutorState();
_started = true; _started = true;
@ -320,7 +320,7 @@ public class BotOrchestrator : IAsyncDisposable
Emit("info", "Starting boss run loop..."); Emit("info", "Starting boss run loop...");
State = "Preparing"; State = "Preparing";
_ = BossRunExecutor.RunBossLoop().ContinueWith(t => _ = KulemakExecutor.RunBossLoop().ContinueWith(t =>
{ {
if (t.IsFaulted) if (t.IsFaulted)
{ {

View file

@ -0,0 +1,484 @@
using Poe2Trade.Core;
using Poe2Trade.Game;
using Poe2Trade.GameLog;
using Poe2Trade.Inventory;
using Poe2Trade.Navigation;
using Poe2Trade.Screen;
using Serilog;
namespace Poe2Trade.Bot;
/// <summary>
/// Kulemak-specific boss run executor: scripted 4-phase + ring fight,
/// invitations, cathedral navigation.
/// </summary>
public class KulemakExecutor : MappingExecutor
{
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 NewInstanceTemplate = Path.Combine("assets", "new.png");
public KulemakExecutor(IGameController game, IScreenReader screen,
IInventoryManager inventory, IClientLogWatcher logWatcher, SavedSettings config,
BossDetector bossDetector, HudReader hudReader, NavigationExecutor nav)
: base(game, screen, inventory, logWatcher, config, bossDetector, hudReader, nav)
{
}
public async Task RunBossLoop()
{
ResetStop();
var runCount = _config.Kulemak.RunCount;
Log.Information("Starting boss run loop ({Count} runs)", runCount);
var completed = 0;
try
{
if (!await Prepare())
{
SetState(MappingState.Failed);
await RecoverToHideout();
SetState(MappingState.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(MappingState.Looting);
await Sleep(1000);
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(MappingState.Complete);
await Helpers.Sleep(1000);
SetState(MappingState.Idle);
}
private async Task<bool> Prepare()
{
SetState(MappingState.Preparing);
Log.Information("Preparing: depositing inventory and grabbing invitations");
await _game.FocusGame();
await Sleep(Delays.PostFocus);
var stashPos = await _inventory.FindAndClickNameplate("Stash");
if (stashPos == null)
{
Log.Error("Could not find Stash nameplate");
return false;
}
await Sleep(Delays.PostStashOpen);
var (lootTab, lootFolder) = ResolveTabPath(_config.Kulemak.LootTabPath);
if (lootTab != null)
{
await _inventory.ClickStashTab(lootTab, lootFolder);
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");
}
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);
}
else
{
Log.Warning("Invitation tab path not configured or not found, skipping grab");
}
await _game.PressEscape();
_inventory.ResetStashTabState();
await Sleep(Delays.PostEscape);
Log.Information("Preparation complete");
return true;
}
private async Task<bool> TravelToZone()
{
SetState(MappingState.TravelingToZone);
Log.Information("Traveling to Well of Souls via waypoint");
await _game.FocusGame();
await Sleep(Delays.PostFocus);
var wpPos = await _inventory.FindAndClickNameplate("Waypoint");
if (wpPos == null)
{
Log.Error("Could not find Waypoint nameplate");
return false;
}
await Sleep(1500);
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);
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(MappingState.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(MappingState.UsingInvitation);
Log.Information("Using invitation at ({X},{Y})", x, y);
await _game.MoveMouseTo(x, y);
await Sleep(200);
await _game.CtrlLeftClickAt(x, y);
await Sleep(500);
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);
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 async Task Fight()
{
SetState(MappingState.Fighting);
Log.Information("Fight phase starting");
await Sleep(3000);
if (_stopped) return;
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);
await Sleep(14000);
if (_stopped) return;
StartBossDetection("kulemak");
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;
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;
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);
}
var deathPos = await PollYoloDuringWait(2000);
if (deathPos != null)
{
fightWorldX = deathPos.Value.X;
fightWorldY = deathPos.Value.Y;
}
Log.Information("Phase {Phase} done, walking to well", phase);
await Sleep(500);
await WalkToWorldPosition(wellWorldX, wellWorldY);
await Sleep(500);
await ClickClosestTemplateToCenter(CathedralWellTemplate);
await Sleep(200);
await WalkToWorldPosition(fightWorldX + 20, fightWorldY +20, 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;
{
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);
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;
Log.Information("Walking to fight position ({X:F0},{Y:F0}) + offset", fightWorldX, fightWorldY);
await WalkToWorldPosition(fightWorldX , fightWorldY , cancelWhen: IsBossAlive);
await Sleep(300);
Log.Information("Attacking at ring fight position (no chase — boss will come to us)");
await AttackBossUntilGone(fightWorldX, fightWorldY, chase: false);
if (_stopped) return;
StopBossDetection();
Log.Information("Fight complete");
}
finally
{
_nav.Frozen = false;
FightPosition = null;
}
}
/// <summary>
/// Store loot with optional invitation grab for the next run.
/// </summary>
private async Task StoreLoot(bool grabInvitation)
{
// Use base StoreLoot for the deposit logic, but we need the stash to stay open
// for invitation grab, so we inline the full flow here.
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 _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");
}
}
await _game.PressEscape();
_inventory.ResetStashTabState();
await Sleep(Delays.PostEscape);
Log.Information("Loot stored");
}
}

View file

@ -0,0 +1,639 @@
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;
/// <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 _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);
}
await _game.PressEscape();
_inventory.ResetStashTabState();
await Sleep(Delays.PostEscape);
Log.Information("Loot stored");
}
}

View file

@ -75,7 +75,7 @@ public enum BotMode
Mapping Mapping
} }
public enum BossRunState public enum MappingState
{ {
Idle, Idle,
Preparing, Preparing,

View file

@ -200,7 +200,7 @@ public sealed class D2dOverlay
ShowYolo: _bot.ShowYoloOverlay, ShowYolo: _bot.ShowYoloOverlay,
ShowFightPosition: _bot.ShowFightPositionOverlay, ShowFightPosition: _bot.ShowFightPositionOverlay,
LootLabels: _bot.LootDebugDetector.Latest, LootLabels: _bot.LootDebugDetector.Latest,
FightPosition: _bot.BossRunExecutor.FightPosition, FightPosition: _bot.KulemakExecutor.FightPosition,
Fps: fps, Fps: fps,
Timing: timing); Timing: timing);
} }

View file

@ -383,7 +383,7 @@ public partial class DebugViewModel : ObservableObject
DebugResult = "Loot test: focusing game..."; DebugResult = "Loot test: focusing game...";
await _bot.Game.FocusGame(); await _bot.Game.FocusGame();
await Task.Delay(300); await Task.Delay(300);
await _bot.BossRunExecutor.Loot(); await _bot.KulemakExecutor.Loot();
DebugResult = "Loot test: complete"; DebugResult = "Loot test: complete";
} }
catch (Exception ex) catch (Exception ex)

View file

@ -194,7 +194,7 @@ public partial class MainWindowViewModel : ObservableObject
{ {
Log.Information("END pressed — emergency stop"); Log.Information("END pressed — emergency stop");
await _bot.Navigation.Stop(); await _bot.Navigation.Stop();
_bot.BossRunExecutor.Stop(); _bot.KulemakExecutor.Stop();
_bot.Pause(); _bot.Pause();
Avalonia.Threading.Dispatcher.UIThread.Post(() => Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{ {

View file

@ -175,7 +175,7 @@
<!-- Links list --> <!-- Links list -->
<ScrollViewer> <ScrollViewer>
<ItemsControl ItemsSource="{Binding Links}"> <ItemsControl x:Name="LinksControl" ItemsSource="{Binding Links}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate> <DataTemplate>
<Border Margin="0,2" Padding="6" Background="#21262d" <Border Margin="0,2" Padding="6" Background="#21262d"
@ -184,7 +184,7 @@
<DockPanel> <DockPanel>
<Button DockPanel.Dock="Right" Content="X" FontSize="10" <Button DockPanel.Dock="Right" Content="X" FontSize="10"
VerticalAlignment="Center" VerticalAlignment="Center"
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).RemoveLinkCommand}" Command="{Binding #LinksControl.((vm:MainWindowViewModel)DataContext).RemoveLinkCommand}"
CommandParameter="{Binding Id}" /> CommandParameter="{Binding Id}" />
<CheckBox DockPanel.Dock="Left" <CheckBox DockPanel.Dock="Left"
IsChecked="{Binding Active}" IsChecked="{Binding Active}"