264 lines
8.2 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|