using Poe2Trade.Core; using Poe2Trade.Game; using Poe2Trade.Inventory; using Poe2Trade.GameLog; using Poe2Trade.Navigation; using Poe2Trade.Screen; using Poe2Trade.Trade; using Serilog; namespace Poe2Trade.Bot; public class BotStatus { public bool Paused { get; set; } public string State { get; set; } = "Idle"; public List 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 _scrapExecutors = new(); private readonly Dictionary _diamondExecutors = new(); // Events public event Action? StatusUpdated; public event Action? 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 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 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 (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 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(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 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 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); }