boss ready

This commit is contained in:
Boki 2026-02-21 15:44:22 -05:00
parent 89c3a0a077
commit 64a6ab694b
21 changed files with 857 additions and 249 deletions

View file

@ -18,13 +18,16 @@ public class BossRunExecutor : GameExecutor
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 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 NewInstanceTemplate = Path.Combine("assets", "new.png");
private static readonly string ResurrectTemplate = Path.Combine("assets", "resurrect.png");
private BossRunState _state = BossRunState.Idle;
private readonly IClientLogWatcher _logWatcher;
private readonly BossDetector _bossDetector;
private readonly HudReader _hudReader;
private readonly NavigationExecutor _nav;
private readonly CombatManager _combat;
public event Action<BossRunState>? StateChanged;
@ -35,7 +38,7 @@ public class BossRunExecutor : GameExecutor
{
_logWatcher = logWatcher;
_bossDetector = bossDetector;
_hudReader = hudReader;
_combat = new CombatManager(game, hudReader, new FlaskManager(game, hudReader));
_nav = nav;
}
@ -56,8 +59,10 @@ public class BossRunExecutor : GameExecutor
public async Task RunBossLoop()
{
_stopped = false;
Log.Information("Starting boss run loop ({Count} invitations)", _config.Kulemak.InvitationCount);
var runCount = _config.Kulemak.RunCount;
Log.Information("Starting boss run loop ({Count} runs)", runCount);
// First run: deposit inventory and grab 1 invitation
if (!await Prepare())
{
SetState(BossRunState.Failed);
@ -67,11 +72,11 @@ public class BossRunExecutor : GameExecutor
}
var completed = 0;
for (var i = 0; i < _config.Kulemak.InvitationCount; i++)
for (var i = 0; i < runCount; i++)
{
if (_stopped) break;
Log.Information("=== Boss run {N}/{Total} ===", i + 1, _config.Kulemak.InvitationCount);
Log.Information("=== Boss run {N}/{Total} ===", i + 1, runCount);
if (!await TravelToZone())
{
@ -113,13 +118,14 @@ public class BossRunExecutor : GameExecutor
}
if (_stopped) break;
await StoreLoot();
bool isLastRun = i == runCount - 1 || _stopped;
await StoreLoot(grabInvitation: !isLastRun);
completed++;
if (_stopped) break;
}
Log.Information("Boss run loop finished: {Completed}/{Total} runs completed", completed, _config.Kulemak.InvitationCount);
Log.Information("Boss run loop finished: {Completed}/{Total} runs completed", completed, runCount);
SetState(BossRunState.Complete);
await Helpers.Sleep(1000);
SetState(BossRunState.Idle);
@ -186,7 +192,7 @@ public class BossRunExecutor : GameExecutor
(false, false) => "stash12",
};
await _inventory.GrabItemsFromStash(layoutName, _config.Kulemak.InvitationCount, InvitationTemplate);
await _inventory.GrabItemsFromStash(layoutName, 1, InvitationTemplate);
}
else
{
@ -310,7 +316,7 @@ public class BossRunExecutor : GameExecutor
Log.Information("Fight phase starting");
// Wait for arena to settle
await Helpers.Sleep(5500);
await Helpers.Sleep(4500);
if (_stopped) return;
// Find and click the cathedral door
@ -336,7 +342,7 @@ public class BossRunExecutor : GameExecutor
const double wellWorldX = -496;
const double wellWorldY = -378;
await WalkToWorldPosition(fightWorldX, fightWorldY);
await WalkToWorldPosition(fightWorldX, fightWorldY, cancelWhen: IsBossAlive);
if (_stopped) return;
// 3x fight-then-well loop
@ -356,14 +362,17 @@ public class BossRunExecutor : GameExecutor
Log.Information("Fight area updated to ({X:F0},{Y:F0})", fightWorldX, fightWorldY);
}
// Wait for death animation before looking for well
await Helpers.Sleep(3000);
// Walk to well and click the closest match to screen center
Log.Information("Phase {Phase} done, walking to well", phase);
await WalkToWorldPosition(wellWorldX, wellWorldY);
await ClickClosestTemplateToCenter(CathedralWellTemplate);
await Helpers.Sleep(2000);
await Helpers.Sleep(200);
// Walk back to fight position for next phase
await WalkToWorldPosition(fightWorldX, fightWorldY);
await WalkToWorldPosition(fightWorldX, fightWorldY, cancelWhen: IsBossAlive);
}
// 4th fight - no well after
@ -372,7 +381,10 @@ public class BossRunExecutor : GameExecutor
await AttackBossUntilGone();
if (_stopped) return;
// Return the ring
// Walk to ring area and return it
await WalkToWorldPosition(-440, -330);
if (_stopped) return;
Log.Information("Looking for Return the Ring...");
var ring = await _screen.TemplateMatch(ReturnTheRingTemplate);
if (ring == null)
@ -419,13 +431,39 @@ public class BossRunExecutor : GameExecutor
/// <summary>
/// Check top-of-screen region for the unique boss healthbar frame.
/// Uses lower threshold (0.5) to tolerate partial overlay from YOLO bounding boxes.
/// Healthbar spans (750,16) to (1818,112). Uses lower threshold (0.5) to tolerate YOLO overlay.
/// </summary>
private async Task<bool> IsBossAlive()
{
var topRegion = new Region(800, 0, 960, 120);
var matches = await _screen.TemplateMatchAll(BossHealthbarTemplate, topRegion, threshold: 0.5);
return matches.Count > 0;
var topRegion = new Region(750, 16, 1068, 96);
// Check all three healthbar templates — boss is alive if ANY matches
var m1 = await _screen.TemplateMatchAll(BossHealthbarTemplate, topRegion, threshold: 0.5);
if (m1.Count > 0) return true;
var m2 = await _screen.TemplateMatchAll(BossHealthbarTemplate2, topRegion, threshold: 0.5);
if (m2.Count > 0) return true;
var m3 = await _screen.TemplateMatchAll(BossHealthbarTemplate3, topRegion, threshold: 0.5);
if (m3.Count > 0) return true;
return false;
}
/// <summary>
/// Check for the "Resurrect at Checkpoint" button — means we died.
/// If found, click it, wait for respawn, and return true.
/// </summary>
private async Task<bool> CheckDeath()
{
var match = await _screen.TemplateMatch(ResurrectTemplate);
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 Helpers.Sleep(3000); // wait for respawn + loading
return true;
}
/// <summary>
@ -435,13 +473,13 @@ public class BossRunExecutor : GameExecutor
{
Log.Information("Waiting for boss healthbar to appear...");
var sw = Stopwatch.StartNew();
const int screenCx = 1280;
const int screenCy = 720;
var atkX = 1280;
var atkY = 720;
while (sw.ElapsedMilliseconds < timeoutMs)
{
if (_stopped) return false;
if (await CheckDeath()) continue;
if (await IsBossAlive())
{
@ -449,19 +487,14 @@ public class BossRunExecutor : GameExecutor
return true;
}
// Attack at center while waiting — boss often spawns on top of us
var targetX = screenCx + Rng.Next(-30, 31);
var targetY = screenCy + Rng.Next(-30, 31);
await _game.MoveMouseFast(targetX, targetY);
_game.LeftMouseDown();
await Helpers.Sleep(Rng.Next(20, 35));
_game.LeftMouseUp();
_game.RightMouseDown();
await Helpers.Sleep(Rng.Next(20, 35));
_game.RightMouseUp();
await Helpers.Sleep(Rng.Next(50, 80));
// Attack toward YOLO-detected boss if available, otherwise last known position
var snapshot = _bossDetector.Latest;
if (snapshot.Bosses.Count > 0)
{
atkX = snapshot.Bosses[0].Cx;
atkY = snapshot.Bosses[0].Cy;
}
await _combat.Tick(atkX, atkY);
}
Log.Warning("WaitForBossSpawn timed out after {Ms}ms", timeoutMs);
@ -490,11 +523,9 @@ public class BossRunExecutor : GameExecutor
await Helpers.Sleep(200);
var sw = Stopwatch.StartNew();
var consecutiveMisses = 0;
var lastCheckMs = 0L;
var holding = false;
var manaStableCount = 0;
await _combat.Reset();
Log.Information("Boss is alive, engaging");
try
@ -520,82 +551,22 @@ public class BossRunExecutor : GameExecutor
lastBossWorldPos = (bossWorldX, bossWorldY);
}
// Re-check healthbar every ~1.5s, need 5 consecutive misses (~7.5s) to confirm phase end
if (sw.ElapsedMilliseconds - lastCheckMs > 1500)
// Re-check healthbar every ~0.5s, first miss = phase over
if (sw.ElapsedMilliseconds - lastCheckMs > 500)
{
if (await CheckDeath()) continue;
var bossAlive = await IsBossAlive();
lastCheckMs = sw.ElapsedMilliseconds;
if (!bossAlive)
{
consecutiveMisses++;
Log.Information("Healthbar not found ({Misses}/5 consecutive misses, {Ms}ms elapsed)",
consecutiveMisses, sw.ElapsedMilliseconds);
if (consecutiveMisses >= 5)
{
Log.Information("Boss phase over after {Ms}ms", sw.ElapsedMilliseconds);
return lastBossWorldPos;
}
}
else
{
if (consecutiveMisses > 0)
Log.Information("Healthbar found again, resetting miss count (was {Misses})", consecutiveMisses);
consecutiveMisses = 0;
Log.Information("Healthbar not found, boss phase over after {Ms}ms", sw.ElapsedMilliseconds);
return lastBossWorldPos;
}
}
// Check mana to decide hold vs click
var mana = _hudReader.Current.ManaPct;
if (!holding)
{
// Ramp up: click cycle until mana is stable above 80%
if (mana >= 0.80f)
manaStableCount++;
else
manaStableCount = 0;
var targetX = atkX + Rng.Next(-30, 31);
var targetY = atkY + Rng.Next(-30, 31);
await _game.MoveMouseFast(targetX, targetY);
_game.LeftMouseDown();
await Helpers.Sleep(Rng.Next(20, 35));
_game.LeftMouseUp();
_game.RightMouseDown();
await Helpers.Sleep(Rng.Next(20, 35));
_game.RightMouseUp();
await Helpers.Sleep(Rng.Next(50, 80));
// After 5 consecutive checks above 80%, switch to hold
if (manaStableCount >= 5)
{
Log.Information("Mana stable at {Mana:P0}, switching to hold attack", mana);
_game.LeftMouseDown();
_game.RightMouseDown();
holding = true;
}
}
else
{
// Holding: nudge mouse toward boss and monitor mana
var targetX = atkX + Rng.Next(-30, 31);
var targetY = atkY + Rng.Next(-30, 31);
await _game.MoveMouseFast(targetX, targetY);
if (mana < 0.30f)
{
Log.Information("Mana dropped to {Mana:P0}, releasing to recover", mana);
_game.LeftMouseUp();
_game.RightMouseUp();
holding = false;
manaStableCount = 0;
}
await Helpers.Sleep(Rng.Next(80, 120));
}
await _combat.Tick(atkX, atkY);
}
Log.Warning("AttackBossUntilGone timed out after {Ms}ms", timeoutMs);
@ -603,34 +574,26 @@ public class BossRunExecutor : GameExecutor
}
finally
{
if (holding)
{
_game.LeftMouseUp();
_game.RightMouseUp();
}
await _combat.ReleaseAll();
}
}
private async Task AttackAtPosition(int x, int y, int durationMs)
{
await _combat.Reset();
var sw = Stopwatch.StartNew();
while (sw.ElapsedMilliseconds < durationMs)
try
{
if (_stopped) return;
var targetX = x + Rng.Next(-20, 21);
var targetY = y + Rng.Next(-20, 21);
await _game.MoveMouseFast(targetX, targetY);
_game.LeftMouseDown();
await Helpers.Sleep(Rng.Next(20, 35));
_game.LeftMouseUp();
_game.RightMouseDown();
await Helpers.Sleep(Rng.Next(20, 35));
_game.RightMouseUp();
await Helpers.Sleep(Rng.Next(50, 80));
while (sw.ElapsedMilliseconds < durationMs)
{
if (_stopped) return;
if (await CheckDeath()) continue;
await _combat.Tick(x, y, jitter: 20);
}
}
finally
{
await _combat.ReleaseAll();
}
}
@ -665,7 +628,8 @@ public class BossRunExecutor : GameExecutor
/// <summary>
/// Walk to a world position using WASD keys, checking minimap position each iteration.
/// </summary>
private async Task WalkToWorldPosition(double worldX, double worldY, int timeoutMs = 10000, double arrivalDist = 15)
private async Task WalkToWorldPosition(double worldX, double worldY, int timeoutMs = 10000,
double arrivalDist = 15, Func<Task<bool>>? cancelWhen = null)
{
Log.Information("Walking to world ({X:F0},{Y:F0})", worldX, worldY);
@ -682,6 +646,12 @@ public class BossRunExecutor : GameExecutor
{
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;
@ -795,7 +765,7 @@ public class BossRunExecutor : GameExecutor
return true;
}
private async Task StoreLoot()
private async Task StoreLoot(bool grabInvitation = false)
{
SetState(BossRunState.StoringLoot);
Log.Information("Storing loot");
@ -812,12 +782,11 @@ public class BossRunExecutor : GameExecutor
}
await Helpers.Sleep(Delays.PostStashOpen);
// Click loot tab
// Click loot tab and deposit all inventory items
var (lootTab, lootFolder) = ResolveTabPath(_config.Kulemak.LootTabPath);
if (lootTab != null)
await _inventory.ClickStashTab(lootTab, lootFolder);
// Deposit all inventory items
var scanResult = await _screen.Grid.Scan("inventory");
if (scanResult.Occupied.Count > 0)
{
@ -835,6 +804,25 @@ public class BossRunExecutor : GameExecutor
await Helpers.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");
}
}
// Close stash
await _game.PressEscape();
_inventory.ResetStashTabState();