565 lines
19 KiB
C#
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);
|
|
}
|