using Automata.Core; using Automata.Game; using Automata.GameLog; using Automata.Inventory; using Automata.Navigation; using Automata.Screen; using Serilog; namespace Automata.Bot; /// /// Kulemak-specific boss run executor: scripted 4-phase + ring fight, /// invitations, cathedral navigation. /// 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 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 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 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 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(1000); 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 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; } /// /// Store loot with optional invitation grab for the next run. /// 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"); } }