473 lines
17 KiB
C#
473 lines
17 KiB
C#
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 (invTab, invFolder) = ResolveTabPath(_config.Kulemak.InvitationTabPath);
|
|
|
|
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)
|
|
{
|
|
Log.Warning("Loot tab path not configured or not found, skipping deposit");
|
|
}
|
|
else
|
|
{
|
|
await _inventory.ClickStashTab(lootTab, lootFolder);
|
|
await _inventory.DepositAllToOpenStash();
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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, 10000);
|
|
}
|
|
|
|
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(100);
|
|
await WalkToWorldPosition(wellWorldX, wellWorldY);
|
|
await Sleep(100);
|
|
for (var attempt = 0; attempt < 5; attempt++)
|
|
{
|
|
if (await TryClickWell()) break;
|
|
Log.Warning("Well not found (attempt {Attempt}), walking A+W to get closer", attempt + 1);
|
|
await _game.KeyDown(InputSender.VK.A);
|
|
if(attempt == 0) await _game.KeyDown(InputSender.VK.W);
|
|
await Sleep(1000);
|
|
await _game.KeyUp(InputSender.VK.A);
|
|
if(attempt == 0) await _game.KeyUp(InputSender.VK.W);
|
|
await Sleep(100);
|
|
}
|
|
await Sleep(1500);
|
|
|
|
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(-450, -340);
|
|
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;
|
|
}
|
|
}
|
|
|
|
private async Task<bool> TryClickWell()
|
|
{
|
|
const int screenCx = 1280;
|
|
const int screenCy = 660;
|
|
var centerRegion = new Region(850, 50, 860, 550);
|
|
|
|
var matches = await _screen.TemplateMatchAll(CathedralWellTemplate, centerRegion);
|
|
if (matches.Count == 0) return false;
|
|
|
|
var closest = matches.OrderBy(m =>
|
|
{
|
|
var dx = m.X - screenCx;
|
|
var dy = m.Y - screenCy;
|
|
return dx * dx + dy * dy;
|
|
}).First();
|
|
|
|
Log.Information("Clicking well at ({X},{Y}) conf={Conf:F3}", closest.X, closest.Y, closest.Confidence);
|
|
await _game.LeftClickAt(closest.X, closest.Y);
|
|
return true;
|
|
}
|
|
|
|
/// <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);
|
|
|
|
await _inventory.DepositAllToOpenStash();
|
|
|
|
// 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");
|
|
}
|
|
}
|