using System.Collections.Concurrent; using Nexus.Core; using Nexus.Game; using Nexus.Inventory; using Nexus.Screen; using Nexus.Trade; using Serilog; namespace Nexus.Bot; public class DiamondExecutor { private DiamondState _state = DiamondState.Idle; private bool _stopped; private readonly string _searchId; private readonly ConcurrentQueue _queue = new(); private readonly SemaphoreSlim _signal = new(0); private readonly IGameController _game; private readonly IScreenReader _screen; private readonly ITradeMonitor _tradeMonitor; private readonly IInventoryManager _inventory; private readonly SavedSettings _config; public event Action? StateChanged; public event Action? ItemBought; public event Action? ItemFailed; public DiamondExecutor(string searchId, IGameController game, IScreenReader screen, ITradeMonitor tradeMonitor, IInventoryManager inventory, SavedSettings config) { _searchId = searchId; _game = game; _screen = screen; _tradeMonitor = tradeMonitor; _inventory = inventory; _config = config; } public DiamondState State => _state; private void SetState(DiamondState s) { _state = s; StateChanged?.Invoke(s); } public void EnqueueItems(List items) { foreach (var item in items) _queue.Enqueue(item); _signal.Release(items.Count); } public Task Stop() { _stopped = true; _signal.Release(); // unblock wait SetState(DiamondState.Idle); Log.Information("Diamond executor stopped: {SearchId}", _searchId); return Task.CompletedTask; } public async Task RunLoop() { _stopped = false; Log.Information("Diamond executor started: {SearchId}", _searchId); await _inventory.ScanInventory(PostAction.Stash); SetState(DiamondState.WaitingForListings); while (!_stopped) { await _signal.WaitAsync(); if (_stopped) break; while (_queue.TryDequeue(out var item)) { if (_stopped) break; await ProcessItem(item); } if (!_stopped) SetState(DiamondState.WaitingForListings); } SetState(DiamondState.Idle); Log.Information("Diamond executor loop ended: {SearchId}", _searchId); } private async Task ProcessItem(PricedTradeItem item) { SetState(DiamondState.Filtering); if (!ShouldBuy(item)) return; // Check inventory space if (!_inventory.Tracker.CanFit(item.W, item.H)) { Log.Information("No room for {W}x{H}, going home to stash", item.W, item.H); await GoHomeAndStash(); await _inventory.ScanInventory(PostAction.Stash); if (!_inventory.Tracker.CanFit(item.W, item.H)) { Log.Warning("Still no room for {W}x{H} after stash, skipping {Name}", item.W, item.H, DisplayName(item)); return; } } if (!await TravelToSeller(item)) return; await BuyItem(item); } private static string DisplayName(PricedTradeItem item) => DiamondSettings.KnownDiamonds.GetValueOrDefault(item.Name, item.Name); // Units per 1 divine private static readonly Dictionary CurrencyPerDivine = new(StringComparer.OrdinalIgnoreCase) { ["divine"] = 1, ["annul"] = 7, ["exalted"] = 275, ["chaos"] = 269, ["vaal"] = 65, }; private static double ToDivineEquivalent(double amount, string currency) { foreach (var (key, rate) in CurrencyPerDivine) { if (currency.Contains(key)) return amount / rate; } return -1; } private bool ShouldBuy(PricedTradeItem item) { var settings = _config.Diamond; var currency = item.PriceCurrency.ToLowerInvariant(); var name = DisplayName(item); // Look up per-item config var priceConfig = settings.Prices.FirstOrDefault(p => p.ItemName.Equals(item.Name, StringComparison.OrdinalIgnoreCase)); if (priceConfig == null) { Log.Debug("Diamond skip (not configured): {Name} ({Icon}) @ {Amount} {Currency}", name, item.Name, item.PriceAmount, item.PriceCurrency); return false; } if (!priceConfig.Enabled) { Log.Debug("Diamond skip (disabled): {Name}", name); return false; } // Convert any currency to divine equivalent var divinePrice = ToDivineEquivalent(item.PriceAmount, currency); if (divinePrice < 0) { Log.Information("Diamond skip (unknown currency): {Name} @ {Amount} {Currency}", name, item.PriceAmount, item.PriceCurrency); return false; } if (divinePrice <= priceConfig.MaxDivinePrice) { Log.Information("Diamond buy: {Name} @ {Amount} {Currency} (={DivPrice:F2} div, max={Max})", name, item.PriceAmount, item.PriceCurrency, divinePrice, priceConfig.MaxDivinePrice); return true; } Log.Information("Diamond skip (price): {Name} @ {Amount} {Currency} (={DivPrice:F2} div, max={Max})", name, item.PriceAmount, item.PriceCurrency, divinePrice, priceConfig.MaxDivinePrice); return false; } private async Task TravelToSeller(PricedTradeItem item) { var alreadyAtSeller = !_inventory.IsAtOwnHideout && !string.IsNullOrEmpty(item.Account) && item.Account == _inventory.SellerAccount; if (alreadyAtSeller) { Log.Information("Already at seller hideout, skipping travel"); return true; } SetState(DiamondState.Traveling); var arrived = await _inventory.WaitForAreaTransition( _config.TravelTimeoutMs, async () => { if (!await _tradeMonitor.ClickTravelToHideout(_searchId, item.Id)) throw new Exception("Failed to click Travel to Hideout"); }); if (!arrived) { Log.Error("Timed out waiting for hideout arrival: {ItemId}", item.Id); SetState(DiamondState.Failed); ItemFailed?.Invoke(); return false; } _inventory.SetLocation(false, item.Account); await _game.FocusGame(); await Helpers.Sleep(Delays.PostTravel); return true; } private async Task BuyItem(PricedTradeItem item) { try { SetState(DiamondState.Buying); var sellerLayout = GridLayouts.Seller; var cellCenter = _screen.Grid.GetCellCenter(sellerLayout, item.StashY, item.StashX); Log.Information("CTRL+clicking seller stash at ({X},{Y}) for {Name}", cellCenter.X, cellCenter.Y, DisplayName(item)); await _game.CtrlLeftClickAt(cellCenter.X, cellCenter.Y); await Helpers.RandomDelay(200, 400); _inventory.Tracker.TryPlace(item.W, item.H, PostAction.Stash); Log.Information("Diamond bought: {Name} @ {Amount} {Currency} (free={Free})", DisplayName(item), item.PriceAmount, item.PriceCurrency, _inventory.Tracker.FreeCells); ItemBought?.Invoke(); } catch (Exception ex) { Log.Error(ex, "Error buying diamond item {Name}", DisplayName(item)); SetState(DiamondState.Failed); ItemFailed?.Invoke(); } } private async Task GoHomeAndStash() { try { SetState(DiamondState.GoingHome); var home = await _inventory.EnsureAtOwnHideout(); if (!home) { Log.Error("Failed to reach own hideout for stashing"); SetState(DiamondState.Failed); return; } SetState(DiamondState.Storing); await _inventory.ProcessInventory(); } catch (Exception ex) { Log.Error(ex, "GoHomeAndStash failed"); SetState(DiamondState.Failed); } } }