poe2-bot/src/Poe2Trade.Bot/BotOrchestrator.cs
2026-02-13 16:56:44 -05:00

346 lines
10 KiB
C#

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<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 int _tradesCompleted;
private 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; }
private readonly Dictionary<string, ScrapExecutor> _scrapExecutors = new();
// 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)
{
Store = store;
Game = game;
Screen = screen;
LogWatcher = logWatcher;
TradeMonitor = tradeMonitor;
Inventory = inventory;
TradeExecutor = tradeExecutor;
TradeQueue = tradeQueue;
Links = links;
Navigation = new NavigationExecutor(game);
logWatcher.AreaEntered += _ => Navigation.Reset();
logWatcher.Start(); // start early so area events fire even before Bot.Start()
_paused = store.Settings.Paused;
}
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 (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 += () => { _tradesCompleted++; StatusUpdated?.Invoke(); };
TradeQueue.TradeFailed += () => { _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<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}");
}
// 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();
Navigation.StateChanged += _ => UpdateExecutorState();
_started = true;
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();
Screen.Dispose();
await TradeMonitor.DisposeAsync();
LogWatcher.Dispose();
}
private void OnNewListings(string searchId, List<string> 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 += () => { _tradesCompleted++; StatusUpdated?.Invoke(); };
scrapExec.ItemFailed += () => { _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);
}