poe2-bot/src/Nexus.Bot/BotOrchestrator.cs
2026-03-06 14:37:05 -05:00

565 lines
19 KiB
C#

using Nexus.Core;
using Nexus.Game;
using Nexus.Inventory;
using Nexus.GameLog;
using Nexus.Navigation;
using Nexus.Screen;
using Nexus.Trade;
using Serilog;
namespace Nexus.Bot;
public class BotStatus
{
public bool Paused { get; set; }
public string State { get; set; } = "Idle";
public List<TradeLink> Links { get; set; } = [];
public int TradesCompleted { get; set; }
public int TradesFailed { get; set; }
public long Uptime { get; set; }
}
public class BotOrchestrator : IAsyncDisposable
{
private bool _paused;
private string _state = "Idle";
private volatile int _tradesCompleted;
private volatile int _tradesFailed;
private readonly long _startTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
private bool _started;
public ConfigStore Store { get; }
public SavedSettings Config => Store.Settings;
public LinkManager Links { get; }
public IGameController Game { get; }
public IScreenReader Screen { get; }
public IClientLogWatcher LogWatcher { get; }
public ITradeMonitor TradeMonitor { get; }
public IInventoryManager Inventory { get; }
public TradeExecutor TradeExecutor { get; }
public TradeQueue TradeQueue { get; }
public NavigationExecutor Navigation { get; }
public FramePipelineService PipelineService { get; }
public GameStateDetector GameState { get; }
public HudReader HudReader { get; }
public EnemyDetector EnemyDetector { get; }
public BossDetector BossDetector { get; }
public FrameSaver FrameSaver { get; }
public LootDebugDetector LootDebugDetector { get; }
public KulemakExecutor KulemakExecutor { get; }
public AtlasExecutor AtlasExecutor { get; }
public volatile bool ShowYoloOverlay = true;
public volatile bool ShowFightPositionOverlay = true;
private readonly Dictionary<string, ScrapExecutor> _scrapExecutors = new();
private readonly Dictionary<string, DiamondExecutor> _diamondExecutors = new();
private CraftingExecutor? _craftingExecutor;
// Events
public event Action? StatusUpdated;
public event Action<string, string>? LogMessage; // level, message
public BotOrchestrator(ConfigStore store, IGameController game, IScreenReader screen,
IClientLogWatcher logWatcher, ITradeMonitor tradeMonitor,
IInventoryManager inventory, TradeExecutor tradeExecutor,
TradeQueue tradeQueue, LinkManager links, FramePipelineService pipelineService)
{
Store = store;
Game = game;
Screen = screen;
LogWatcher = logWatcher;
TradeMonitor = tradeMonitor;
Inventory = inventory;
TradeExecutor = tradeExecutor;
TradeQueue = tradeQueue;
Links = links;
PipelineService = pipelineService;
// Create consumers
var minimapCapture = new MinimapCapture(new MinimapConfig(), pipelineService.Backend, "assets");
GameState = new GameStateDetector();
HudReader = new HudReader();
EnemyDetector = new EnemyDetector();
EnemyDetector.Enabled = true;
BossDetector = new BossDetector();
FrameSaver = new FrameSaver();
LootDebugDetector = new LootDebugDetector(screen);
// Register on shared pipeline
pipelineService.Pipeline.AddConsumer(minimapCapture);
pipelineService.Pipeline.AddConsumer(GameState);
pipelineService.Pipeline.AddConsumer(HudReader);
pipelineService.Pipeline.AddConsumer(EnemyDetector);
pipelineService.Pipeline.AddConsumer(BossDetector);
pipelineService.Pipeline.AddConsumer(FrameSaver);
// Pass shared pipeline to NavigationExecutor
Navigation = new NavigationExecutor(game, pipelineService.Pipeline, minimapCapture,
enemyDetector: EnemyDetector);
KulemakExecutor = new KulemakExecutor(game, screen, inventory, logWatcher, store.Settings, BossDetector, HudReader, Navigation);
AtlasExecutor = new AtlasExecutor(game, screen, inventory, store.Settings, pipelineService.Pipeline);
logWatcher.AreaEntered += area =>
{
Navigation.Reset();
OnAreaEntered(area);
};
logWatcher.Start(); // start early so area events fire even before Bot.Start()
_paused = store.Settings.Paused;
}
// Boss zones → boss name mapping
private static readonly Dictionary<string, string> BossZones = new(StringComparer.OrdinalIgnoreCase)
{
["The Black Cathedral"] = "kulemak",
};
private void OnAreaEntered(string area)
{
if (BossZones.TryGetValue(area, out var boss))
{
BossDetector.SetBoss(boss);
Log.Information("Boss zone detected: {Area} → switching to {Boss} model", area, boss);
}
}
public bool IsReady => _started;
public bool IsPaused => _paused;
public BotMode Mode
{
get => Config.Mode;
set
{
if (Config.Mode == value) return;
Store.UpdateSettings(s => s.Mode = value);
StatusUpdated?.Invoke();
}
}
public string State
{
get => _state;
set
{
if (_state == value) return;
_state = value;
StatusUpdated?.Invoke();
}
}
public async Task EmergencyStop()
{
Log.Warning("EMERGENCY STOP triggered");
_paused = true;
Store.SetPaused(true);
// Stop all trade executors
foreach (var exec in _scrapExecutors.Values)
await exec.Stop();
_scrapExecutors.Clear();
foreach (var exec in _diamondExecutors.Values)
await exec.Stop();
_diamondExecutors.Clear();
TradeQueue.Clear();
// Stop crafting
_craftingExecutor?.Stop();
_craftingExecutor = null;
// Stop navigation and mapping
await Navigation.Stop();
KulemakExecutor.Stop();
State = "Stopped (END)";
StatusUpdated?.Invoke();
}
public void Pause()
{
_paused = true;
Store.SetPaused(true);
Log.Information("Bot paused");
StatusUpdated?.Invoke();
}
public void Resume()
{
_paused = false;
Store.SetPaused(false);
Log.Information("Bot resumed");
StatusUpdated?.Invoke();
}
public TradeLink AddLink(string url, string? name = null, LinkMode? mode = null, PostAction? postAction = null)
{
var link = Links.AddLink(url, name ?? "", mode, postAction);
StatusUpdated?.Invoke();
return link;
}
public void RemoveLink(string id)
{
Links.RemoveLink(id);
StatusUpdated?.Invoke();
}
public void ToggleLink(string id, bool active)
{
var link = Links.ToggleLink(id, active);
if (link == null) return;
StatusUpdated?.Invoke();
if (active)
_ = ActivateLink(link);
else
_ = DeactivateLink(id);
}
public void ChangeLinkMode(string id, LinkMode newMode)
{
var link = Links.UpdateMode(id, newMode);
if (link == null) return;
StatusUpdated?.Invoke();
if (!_started || !link.Active) return;
_ = Task.Run(async () =>
{
try
{
await DeactivateLink(id);
await ActivateLink(link);
}
catch (Exception ex)
{
Log.Error(ex, "Failed to change link mode: {Id} → {Mode}", id, newMode);
Emit("error", $"Failed to switch mode: {link.Name}");
}
});
}
public BotStatus GetStatus() => new()
{
Paused = _paused,
State = _state,
Links = Links.GetLinks(),
TradesCompleted = _tradesCompleted,
TradesFailed = _tradesFailed,
Uptime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - _startTime,
};
public void UpdateExecutorState()
{
var execState = TradeExecutor.State;
if (execState != TradeState.Idle)
{
State = execState.ToString();
return;
}
foreach (var scrapExec in _scrapExecutors.Values)
{
if (scrapExec.State != ScrapState.Idle)
{
State = scrapExec.State.ToString();
return;
}
}
foreach (var diamondExec in _diamondExecutors.Values)
{
if (diamondExec.State != DiamondState.Idle && diamondExec.State != DiamondState.WaitingForListings)
{
State = diamondExec.State.ToString();
return;
}
}
if (_craftingExecutor != null && _craftingExecutor.State != CraftingState.Idle
&& _craftingExecutor.State != CraftingState.Done)
{
State = _craftingExecutor.State.ToString();
return;
}
if (KulemakExecutor.State != MappingState.Idle)
{
State = KulemakExecutor.State.ToString();
return;
}
if (Navigation.State != NavigationState.Idle)
{
State = Navigation.State.ToString();
return;
}
State = "Idle";
}
public async Task Start(IReadOnlyList<string> cliUrls)
{
LogWatcher.Start();
Emit("info", "Watching Client.txt for game events");
await TradeMonitor.Start();
Emit("info", "Browser launched");
// Warmup OCR daemon
var ocrWarmup = Screen.Warmup().ContinueWith(t =>
{
if (t.IsFaulted) Log.Warning(t.Exception!, "OCR warmup failed");
});
// Check if already in hideout
var inHideout = LogWatcher.CurrentArea.Contains("hideout", StringComparison.OrdinalIgnoreCase);
if (inHideout)
{
Log.Information("Already in hideout: {Area}", LogWatcher.CurrentArea);
Inventory.SetLocation(true);
}
else
{
Emit("info", "Sending /hideout command...");
await Game.FocusGame();
var arrivedHome = await Inventory.WaitForAreaTransition(Config.TravelTimeoutMs, () => Game.GoToHideout());
Inventory.SetLocation(true);
if (!arrivedHome)
Log.Warning("Timed out waiting for hideout transition on startup");
}
State = "InHideout";
Emit("info", "In hideout, ready to trade");
await ocrWarmup;
// Wire executor events
TradeExecutor.StateChanged += _ => UpdateExecutorState();
Navigation.StateChanged += _ => UpdateExecutorState();
TradeQueue.TradeCompleted += () => { Interlocked.Increment(ref _tradesCompleted); StatusUpdated?.Invoke(); };
TradeQueue.TradeFailed += () => { Interlocked.Increment(ref _tradesFailed); StatusUpdated?.Invoke(); };
Inventory.Updated += () => StatusUpdated?.Invoke();
_started = true;
Emit("info", "Checking inventory for leftover items...");
await Inventory.ClearToStash();
Emit("info", "Inventory cleared");
// Wire trade monitor events before activating links to avoid race
TradeMonitor.NewListings += OnNewListings;
TradeMonitor.DiamondListings += OnDiamondListings;
// Load links
var allUrls = new HashSet<string>(cliUrls);
foreach (var l in Store.Settings.Links)
allUrls.Add(l.Url);
foreach (var url in allUrls)
{
var link = Links.AddLink(url);
if (link.Active)
await ActivateLink(link);
else
Emit("info", $"Loaded (inactive): {link.Name}");
}
Emit("info", $"Loaded {allUrls.Count} trade link(s)");
Log.Information("Bot started");
}
public async Task StartMapping()
{
LogWatcher.Start();
await Game.FocusGame();
await Screen.Warmup();
KulemakExecutor.StateChanged += _ => UpdateExecutorState();
Navigation.StateChanged += _ => UpdateExecutorState();
_started = true;
if (Config.MapType == MapType.Kulemak)
{
// Boss run needs hideout first
var inHideout = LogWatcher.CurrentArea.Contains("hideout", StringComparison.OrdinalIgnoreCase);
if (!inHideout)
{
Emit("info", "Sending /hideout command...");
var arrivedHome = await Inventory.WaitForAreaTransition(Config.TravelTimeoutMs, () => Game.GoToHideout());
if (!arrivedHome)
Log.Warning("Timed out waiting for hideout transition on startup");
}
Inventory.SetLocation(true);
Emit("info", "Starting boss run loop...");
State = "Preparing";
_ = KulemakExecutor.RunBossLoop().ContinueWith(t =>
{
if (t.IsFaulted)
{
Log.Error(t.Exception!, "Boss run loop failed");
Emit("error", $"Boss run failed: {t.Exception?.InnerException?.Message}");
}
else
{
Emit("info", "Boss run loop finished");
}
State = "Idle";
StatusUpdated?.Invoke();
});
}
else
{
Emit("info", "Starting map exploration...");
State = "Exploring";
_ = Navigation.RunExploreLoop().ContinueWith(t =>
{
if (t.IsFaulted)
{
Log.Error(t.Exception!, "Explore loop failed");
Emit("error", $"Explore loop failed: {t.Exception?.InnerException?.Message}");
}
else
{
Emit("info", "Exploration finished");
}
State = "Idle";
StatusUpdated?.Invoke();
});
}
}
public async ValueTask DisposeAsync()
{
Log.Information("Shutting down bot...");
foreach (var exec in _scrapExecutors.Values)
await exec.Stop();
foreach (var exec in _diamondExecutors.Values)
await exec.Stop();
EnemyDetector.Dispose();
Screen.Dispose();
await TradeMonitor.DisposeAsync();
LogWatcher.Dispose();
PipelineService.Dispose();
}
private void OnNewListings(string searchId, List<TradeItem> items)
{
if (_paused)
{
Emit("warn", $"New listings ({items.Count}) skipped - bot paused");
return;
}
if (!Links.IsActive(searchId))
{
Emit("warn", $"New listings ({items.Count}) skipped - link {searchId} inactive");
return;
}
Log.Information("New listings: {SearchId} ({Count} items)", searchId, items.Count);
Emit("info", $"New listings: {items.Count} items from {searchId}");
TradeQueue.Enqueue(new TradeInfo(
SearchId: searchId,
Items: items,
Timestamp: DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
));
}
private void OnDiamondListings(string searchId, List<PricedTradeItem> items)
{
if (_paused)
{
Emit("warn", $"Diamond listings ({items.Count}) skipped - bot paused");
return;
}
if (!Links.IsActive(searchId)) return;
foreach (var item in items)
{
var display = DiamondSettings.KnownDiamonds.GetValueOrDefault(item.Name, item.Name);
Emit("info", $"Diamond: {display} @ {item.PriceAmount} {item.PriceCurrency}");
}
if (_diamondExecutors.TryGetValue(searchId, out var exec))
exec.EnqueueItems(items);
}
private async Task ActivateLink(TradeLink link)
{
try
{
if (link.Mode == LinkMode.Scrap)
{
var scrapExec = new ScrapExecutor(Game, Screen, TradeMonitor, Inventory, Config);
scrapExec.StateChanged += _ => UpdateExecutorState();
scrapExec.ItemBought += () => { Interlocked.Increment(ref _tradesCompleted); StatusUpdated?.Invoke(); };
scrapExec.ItemFailed += () => { Interlocked.Increment(ref _tradesFailed); StatusUpdated?.Invoke(); };
_scrapExecutors[link.Id] = scrapExec;
Emit("info", $"Scrap loop started: {link.Name}");
StatusUpdated?.Invoke();
_ = scrapExec.RunScrapLoop(link.Url, link.PostAction).ContinueWith(t =>
{
if (t.IsFaulted)
{
Log.Error(t.Exception!, "Scrap loop error: {LinkId}", link.Id);
Emit("error", $"Scrap loop failed: {link.Name}");
_scrapExecutors.Remove(link.Id);
}
});
}
else if (link.Mode == LinkMode.Diamond)
{
var searchId = TradeMonitor.ExtractSearchId(link.Url);
var diamondExec = new DiamondExecutor(searchId, Game, Screen, TradeMonitor, Inventory, Config);
diamondExec.StateChanged += _ => UpdateExecutorState();
diamondExec.ItemBought += () => { Interlocked.Increment(ref _tradesCompleted); StatusUpdated?.Invoke(); };
diamondExec.ItemFailed += () => { Interlocked.Increment(ref _tradesFailed); StatusUpdated?.Invoke(); };
_diamondExecutors[searchId] = diamondExec;
await TradeMonitor.AddDiamondSearch(link.Url);
Emit("info", $"Diamond search started: {link.Name}");
StatusUpdated?.Invoke();
_ = diamondExec.RunLoop().ContinueWith(t =>
{
if (t.IsFaulted)
{
Log.Error(t.Exception!, "Diamond loop error: {LinkId}", link.Id);
Emit("error", $"Diamond loop failed: {link.Name}");
_diamondExecutors.Remove(searchId);
}
});
}
else
{
await TradeMonitor.AddSearch(link.Url);
Emit("info", $"Monitoring: {link.Name}");
StatusUpdated?.Invoke();
}
}
catch (Exception ex)
{
Log.Error(ex, "Failed to activate link: {Url}", link.Url);
Emit("error", $"Failed to activate: {link.Name}");
}
}
private async Task DeactivateLink(string id)
{
if (_scrapExecutors.TryGetValue(id, out var scrapExec))
{
await scrapExec.Stop();
_scrapExecutors.Remove(id);
}
// Diamond executors are keyed by searchId, not link id — but they're the same
if (_diamondExecutors.TryGetValue(id, out var diamondExec))
{
await diamondExec.Stop();
_diamondExecutors.Remove(id);
}
await TradeMonitor.PauseSearch(id);
}
private void Emit(string level, string message) => LogMessage?.Invoke(level, message);
}