using Microsoft.Playwright; 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 BossRunExecutor BossRunExecutor { get; } private readonly Dictionary _scrapExecutors = 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(); BossDetector = new BossDetector(); FrameSaver = new FrameSaver(); // 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); BossRunExecutor = new BossRunExecutor(game, screen, inventory, logWatcher, store.Settings, BossDetector); 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); BossDetector.Enabled = true; Log.Information("Boss zone detected: {Area} → enabling {Boss} detector", area, boss); } else if (BossDetector.Enabled) { BossDetector.Enabled = false; Log.Information("Left boss zone → disabling boss detector"); } } 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 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 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; } } if (BossRunExecutor.State != BossRunState.Idle) { State = BossRunExecutor.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"); // 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}"); } // Wire trade monitor events TradeMonitor.NewListings += OnNewListings; Emit("info", $"Loaded {allUrls.Count} trade link(s)"); Log.Information("Bot started"); } public async Task StartMapping() { LogWatcher.Start(); await Game.FocusGame(); await Screen.Warmup(); BossRunExecutor.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"; _ = BossRunExecutor.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(); EnemyDetector.Dispose(); Screen.Dispose(); await TradeMonitor.DisposeAsync(); LogWatcher.Dispose(); PipelineService.Dispose(); } private void OnNewListings(string searchId, List itemIds, IPage page) { if (_paused) { Emit("warn", $"New listings ({itemIds.Count}) skipped - bot paused"); return; } if (!Links.IsActive(searchId)) return; Log.Information("New listings: {SearchId} ({Count} items)", searchId, itemIds.Count); Emit("info", $"New listings: {itemIds.Count} items from {searchId}"); TradeQueue.Enqueue(new TradeInfo( SearchId: searchId, ItemIds: itemIds, WhisperText: "", Timestamp: DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), TradeUrl: "", Page: page )); } 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 { 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); } await TradeMonitor.PauseSearch(id); } private void Emit(string level, string message) => LogMessage?.Invoke(level, message); }