poe2-bot/src/Poe2Trade.Bot/KulemakExecutor.cs
2026-02-26 22:22:33 -05:00

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");
}
}