fixed boss
This commit is contained in:
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 |
Binary file not shown.
|
Before Width: | Height: | Size: 6.1 MiB After Width: | Height: | Size: 5.7 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 336 KiB After Width: | Height: | Size: 326 KiB |
File diff suppressed because it is too large
Load diff
|
|
@ -47,7 +47,7 @@ public class BotOrchestrator : IAsyncDisposable
|
|||
public BossDetector BossDetector { get; }
|
||||
public FrameSaver FrameSaver { get; }
|
||||
public LootDebugDetector LootDebugDetector { get; }
|
||||
public BossRunExecutor BossRunExecutor { get; }
|
||||
public KulemakExecutor KulemakExecutor { get; }
|
||||
public volatile bool ShowYoloOverlay = true;
|
||||
public volatile bool ShowFightPositionOverlay = true;
|
||||
private readonly Dictionary<string, ScrapExecutor> _scrapExecutors = new();
|
||||
|
|
@ -94,7 +94,7 @@ public class BotOrchestrator : IAsyncDisposable
|
|||
Navigation = new NavigationExecutor(game, pipelineService.Pipeline, minimapCapture,
|
||||
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 =>
|
||||
{
|
||||
|
|
@ -212,9 +212,9 @@ public class BotOrchestrator : IAsyncDisposable
|
|||
return;
|
||||
}
|
||||
}
|
||||
if (BossRunExecutor.State != BossRunState.Idle)
|
||||
if (KulemakExecutor.State != MappingState.Idle)
|
||||
{
|
||||
State = BossRunExecutor.State.ToString();
|
||||
State = KulemakExecutor.State.ToString();
|
||||
return;
|
||||
}
|
||||
if (Navigation.State != NavigationState.Idle)
|
||||
|
|
@ -301,7 +301,7 @@ public class BotOrchestrator : IAsyncDisposable
|
|||
await Game.FocusGame();
|
||||
await Screen.Warmup();
|
||||
|
||||
BossRunExecutor.StateChanged += _ => UpdateExecutorState();
|
||||
KulemakExecutor.StateChanged += _ => UpdateExecutorState();
|
||||
Navigation.StateChanged += _ => UpdateExecutorState();
|
||||
_started = true;
|
||||
|
||||
|
|
@ -320,7 +320,7 @@ public class BotOrchestrator : IAsyncDisposable
|
|||
|
||||
Emit("info", "Starting boss run loop...");
|
||||
State = "Preparing";
|
||||
_ = BossRunExecutor.RunBossLoop().ContinueWith(t =>
|
||||
_ = KulemakExecutor.RunBossLoop().ContinueWith(t =>
|
||||
{
|
||||
if (t.IsFaulted)
|
||||
{
|
||||
|
|
|
|||
484
src/Poe2Trade.Bot/KulemakExecutor.cs
Normal file
484
src/Poe2Trade.Bot/KulemakExecutor.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
639
src/Poe2Trade.Bot/MappingExecutor.cs
Normal file
639
src/Poe2Trade.Bot/MappingExecutor.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -75,7 +75,7 @@ public enum BotMode
|
|||
Mapping
|
||||
}
|
||||
|
||||
public enum BossRunState
|
||||
public enum MappingState
|
||||
{
|
||||
Idle,
|
||||
Preparing,
|
||||
|
|
|
|||
|
|
@ -200,7 +200,7 @@ public sealed class D2dOverlay
|
|||
ShowYolo: _bot.ShowYoloOverlay,
|
||||
ShowFightPosition: _bot.ShowFightPositionOverlay,
|
||||
LootLabels: _bot.LootDebugDetector.Latest,
|
||||
FightPosition: _bot.BossRunExecutor.FightPosition,
|
||||
FightPosition: _bot.KulemakExecutor.FightPosition,
|
||||
Fps: fps,
|
||||
Timing: timing);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -383,7 +383,7 @@ public partial class DebugViewModel : ObservableObject
|
|||
DebugResult = "Loot test: focusing game...";
|
||||
await _bot.Game.FocusGame();
|
||||
await Task.Delay(300);
|
||||
await _bot.BossRunExecutor.Loot();
|
||||
await _bot.KulemakExecutor.Loot();
|
||||
DebugResult = "Loot test: complete";
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
|
|
|||
|
|
@ -194,7 +194,7 @@ public partial class MainWindowViewModel : ObservableObject
|
|||
{
|
||||
Log.Information("END pressed — emergency stop");
|
||||
await _bot.Navigation.Stop();
|
||||
_bot.BossRunExecutor.Stop();
|
||||
_bot.KulemakExecutor.Stop();
|
||||
_bot.Pause();
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -175,7 +175,7 @@
|
|||
|
||||
<!-- Links list -->
|
||||
<ScrollViewer>
|
||||
<ItemsControl ItemsSource="{Binding Links}">
|
||||
<ItemsControl x:Name="LinksControl" ItemsSource="{Binding Links}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Margin="0,2" Padding="6" Background="#21262d"
|
||||
|
|
@ -184,7 +184,7 @@
|
|||
<DockPanel>
|
||||
<Button DockPanel.Dock="Right" Content="X" FontSize="10"
|
||||
VerticalAlignment="Center"
|
||||
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).RemoveLinkCommand}"
|
||||
Command="{Binding #LinksControl.((vm:MainWindowViewModel)DataContext).RemoveLinkCommand}"
|
||||
CommandParameter="{Binding Id}" />
|
||||
<CheckBox DockPanel.Dock="Left"
|
||||
IsChecked="{Binding Active}"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue