poe2-bot/src/Nexus.Bot/DiamondExecutor.cs
2026-03-06 14:37:05 -05:00

264 lines
8.2 KiB
C#

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<PricedTradeItem> _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<DiamondState>? 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<PricedTradeItem> 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<string, double> 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<bool> 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);
}
}
}