switched to new way
This commit is contained in:
parent
f22d182c8f
commit
4a65c8e17b
96 changed files with 4991 additions and 10025 deletions
296
src/Poe2Trade.Bot/BotOrchestrator.cs
Normal file
296
src/Poe2Trade.Bot/BotOrchestrator.cs
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
using Microsoft.Playwright;
|
||||
using Poe2Trade.Core;
|
||||
using Poe2Trade.Game;
|
||||
using Poe2Trade.Inventory;
|
||||
using Poe2Trade.GameLog;
|
||||
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 LinkManager Links { get; }
|
||||
public ConfigStore Store { get; }
|
||||
public AppConfig Config { get; }
|
||||
|
||||
public GameController Game { get; private set; } = null!;
|
||||
public ScreenReader Screen { get; private set; } = null!;
|
||||
public ClientLogWatcher LogWatcher { get; private set; } = null!;
|
||||
public TradeMonitor TradeMonitor { get; private set; } = null!;
|
||||
public InventoryManager Inventory { get; private set; } = null!;
|
||||
public TradeExecutor TradeExecutor { get; private set; } = null!;
|
||||
public TradeQueue TradeQueue { get; private set; } = null!;
|
||||
private readonly Dictionary<string, ScrapExecutor> _scrapExecutors = new();
|
||||
|
||||
// Events
|
||||
public event Action? StatusUpdated;
|
||||
public event Action<string, string>? LogMessage; // level, message
|
||||
|
||||
public BotOrchestrator(ConfigStore store, AppConfig config)
|
||||
{
|
||||
Store = store;
|
||||
Config = config;
|
||||
_paused = store.Settings.Paused;
|
||||
Links = new LinkManager(store);
|
||||
}
|
||||
|
||||
public bool IsReady => _started;
|
||||
public bool IsPaused => _paused;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
State = "Idle";
|
||||
}
|
||||
|
||||
public async Task Start(IReadOnlyList<string> cliUrls)
|
||||
{
|
||||
Screen = new ScreenReader();
|
||||
Game = new GameController(Config);
|
||||
LogWatcher = new ClientLogWatcher(Config.Poe2LogPath);
|
||||
LogWatcher.Start();
|
||||
Emit("info", "Watching Client.txt for game events");
|
||||
|
||||
TradeMonitor = new TradeMonitor(Config);
|
||||
await TradeMonitor.Start();
|
||||
Emit("info", "Browser launched");
|
||||
|
||||
Inventory = new InventoryManager(Game, Screen, LogWatcher, Config);
|
||||
|
||||
// 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;
|
||||
|
||||
Emit("info", "Checking inventory for leftover items...");
|
||||
await Inventory.ClearToStash();
|
||||
Emit("info", "Inventory cleared");
|
||||
|
||||
// Create executors
|
||||
TradeExecutor = new TradeExecutor(Game, Screen, TradeMonitor, Inventory, Config);
|
||||
TradeExecutor.StateChanged += _ => UpdateExecutorState();
|
||||
TradeQueue = new TradeQueue(TradeExecutor, Config);
|
||||
TradeQueue.TradeCompleted += () => { _tradesCompleted++; StatusUpdated?.Invoke(); };
|
||||
TradeQueue.TradeFailed += () => { _tradesFailed++; StatusUpdated?.Invoke(); };
|
||||
|
||||
// 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;
|
||||
|
||||
_started = true;
|
||||
Emit("info", $"Loaded {allUrls.Count} trade link(s)");
|
||||
Log.Information("Bot started");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
15
src/Poe2Trade.Bot/Poe2Trade.Bot.csproj
Normal file
15
src/Poe2Trade.Bot/Poe2Trade.Bot.csproj
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Poe2Trade.Core\Poe2Trade.Core.csproj" />
|
||||
<ProjectReference Include="..\Poe2Trade.Game\Poe2Trade.Game.csproj" />
|
||||
<ProjectReference Include="..\Poe2Trade.Screen\Poe2Trade.Screen.csproj" />
|
||||
<ProjectReference Include="..\Poe2Trade.Trade\Poe2Trade.Trade.csproj" />
|
||||
<ProjectReference Include="..\Poe2Trade.Log\Poe2Trade.Log.csproj" />
|
||||
<ProjectReference Include="..\Poe2Trade.Inventory\Poe2Trade.Inventory.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
222
src/Poe2Trade.Bot/ScrapExecutor.cs
Normal file
222
src/Poe2Trade.Bot/ScrapExecutor.cs
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
using System.Text.Json;
|
||||
using Microsoft.Playwright;
|
||||
using Poe2Trade.Core;
|
||||
using Poe2Trade.Game;
|
||||
using Poe2Trade.Inventory;
|
||||
using Poe2Trade.Screen;
|
||||
using Poe2Trade.Trade;
|
||||
using Serilog;
|
||||
|
||||
namespace Poe2Trade.Bot;
|
||||
|
||||
public class ScrapExecutor
|
||||
{
|
||||
private ScrapState _state = ScrapState.Idle;
|
||||
private bool _stopped;
|
||||
private IPage? _activePage;
|
||||
private PostAction _postAction = PostAction.Salvage;
|
||||
private readonly GameController _game;
|
||||
private readonly ScreenReader _screen;
|
||||
private readonly TradeMonitor _tradeMonitor;
|
||||
private readonly InventoryManager _inventory;
|
||||
private readonly AppConfig _config;
|
||||
|
||||
public event Action<ScrapState>? StateChanged;
|
||||
public event Action? ItemBought;
|
||||
public event Action? ItemFailed;
|
||||
|
||||
public ScrapExecutor(GameController game, ScreenReader screen, TradeMonitor tradeMonitor,
|
||||
InventoryManager inventory, AppConfig config)
|
||||
{
|
||||
_game = game;
|
||||
_screen = screen;
|
||||
_tradeMonitor = tradeMonitor;
|
||||
_inventory = inventory;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
public ScrapState State => _state;
|
||||
|
||||
private void SetState(ScrapState s)
|
||||
{
|
||||
_state = s;
|
||||
StateChanged?.Invoke(s);
|
||||
}
|
||||
|
||||
public async Task Stop()
|
||||
{
|
||||
_stopped = true;
|
||||
if (_activePage != null)
|
||||
{
|
||||
try { await _activePage.CloseAsync(); } catch { }
|
||||
_activePage = null;
|
||||
}
|
||||
SetState(ScrapState.Idle);
|
||||
Log.Information("Scrap executor stopped");
|
||||
}
|
||||
|
||||
public async Task RunScrapLoop(string tradeUrl, PostAction postAction = PostAction.Salvage)
|
||||
{
|
||||
_stopped = false;
|
||||
_postAction = postAction;
|
||||
Log.Information("Starting scrap loop: {Url} postAction={Action}", tradeUrl, postAction);
|
||||
|
||||
await _inventory.ScanInventory(_postAction);
|
||||
|
||||
var (page, items) = await _tradeMonitor.OpenScrapPage(tradeUrl);
|
||||
_activePage = page;
|
||||
Log.Information("Trade page opened: {Count} items", items.Count);
|
||||
|
||||
while (!_stopped)
|
||||
{
|
||||
var salvageFailed = false;
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (_stopped) break;
|
||||
|
||||
if (!_inventory.Tracker.CanFit(item.W, item.H))
|
||||
{
|
||||
if (salvageFailed) continue;
|
||||
Log.Information("No room for {W}x{H}, processing...", item.W, item.H);
|
||||
await ProcessItems();
|
||||
if (_state == ScrapState.Failed)
|
||||
{
|
||||
salvageFailed = true;
|
||||
SetState(ScrapState.Idle);
|
||||
continue;
|
||||
}
|
||||
await _inventory.ScanInventory(_postAction);
|
||||
}
|
||||
|
||||
if (!_inventory.Tracker.CanFit(item.W, item.H))
|
||||
{
|
||||
Log.Warning("Item {W}x{H} still cannot fit after processing, skipping", item.W, item.H);
|
||||
continue;
|
||||
}
|
||||
|
||||
var success = await BuyItem(page, item);
|
||||
if (!success) Log.Warning("Failed to buy item {Id}", item.Id);
|
||||
|
||||
await Helpers.RandomDelay(500, 1000);
|
||||
}
|
||||
|
||||
if (_stopped) break;
|
||||
|
||||
Log.Information("Page exhausted, refreshing...");
|
||||
items = await RefreshPage(page);
|
||||
Log.Information("Page refreshed: {Count} items", items.Count);
|
||||
|
||||
if (items.Count == 0)
|
||||
{
|
||||
Log.Information("No items after refresh, waiting...");
|
||||
await Helpers.Sleep(5000);
|
||||
if (_stopped) break;
|
||||
items = await RefreshPage(page);
|
||||
}
|
||||
}
|
||||
|
||||
_activePage = null;
|
||||
SetState(ScrapState.Idle);
|
||||
Log.Information("Scrap loop ended");
|
||||
}
|
||||
|
||||
private async Task<bool> BuyItem(IPage page, TradeItem item)
|
||||
{
|
||||
try
|
||||
{
|
||||
var alreadyAtSeller = !_inventory.IsAtOwnHideout
|
||||
&& !string.IsNullOrEmpty(item.Account)
|
||||
&& item.Account == _inventory.SellerAccount;
|
||||
|
||||
if (alreadyAtSeller)
|
||||
{
|
||||
Log.Information("Already at seller hideout, skipping travel");
|
||||
}
|
||||
else
|
||||
{
|
||||
SetState(ScrapState.Traveling);
|
||||
var arrived = await _inventory.WaitForAreaTransition(
|
||||
_config.TravelTimeoutMs,
|
||||
async () =>
|
||||
{
|
||||
if (!await _tradeMonitor.ClickTravelToHideout(page, 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(ScrapState.Failed);
|
||||
return false;
|
||||
}
|
||||
_inventory.SetLocation(false, item.Account);
|
||||
await _game.FocusGame();
|
||||
await Helpers.Sleep(1500);
|
||||
}
|
||||
|
||||
SetState(ScrapState.Buying);
|
||||
var sellerLayout = GridLayouts.Seller;
|
||||
var cellCenter = _screen.Grid.GetCellCenter(sellerLayout, item.StashY, item.StashX);
|
||||
Log.Information("CTRL+clicking seller stash at ({X},{Y})", cellCenter.X, cellCenter.Y);
|
||||
|
||||
await _game.CtrlLeftClickAt(cellCenter.X, cellCenter.Y);
|
||||
await Helpers.RandomDelay(200, 400);
|
||||
|
||||
_inventory.Tracker.TryPlace(item.W, item.H, _postAction);
|
||||
Log.Information("Item bought: {Id} (free={Free})", item.Id, _inventory.Tracker.FreeCells);
|
||||
SetState(ScrapState.Idle);
|
||||
ItemBought?.Invoke();
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Error buying item {Id}", item.Id);
|
||||
SetState(ScrapState.Failed);
|
||||
ItemFailed?.Invoke();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessItems()
|
||||
{
|
||||
try
|
||||
{
|
||||
SetState(ScrapState.Salvaging);
|
||||
await _inventory.ProcessInventory();
|
||||
SetState(ScrapState.Idle);
|
||||
Log.Information("Process cycle complete");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Process cycle failed");
|
||||
SetState(ScrapState.Failed);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<TradeItem>> RefreshPage(IPage page)
|
||||
{
|
||||
var items = new List<TradeItem>();
|
||||
|
||||
void OnResponse(object? _, IResponse response)
|
||||
{
|
||||
if (!response.Url.Contains("/api/trade2/fetch/")) return;
|
||||
try
|
||||
{
|
||||
var body = response.TextAsync().GetAwaiter().GetResult();
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
if (doc.RootElement.TryGetProperty("result", out var results) &&
|
||||
results.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var r in results.EnumerateArray())
|
||||
items.Add(TradeMonitor.ParseTradeItem(r));
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
page.Response += OnResponse;
|
||||
await page.ReloadAsync(new PageReloadOptions { WaitUntil = WaitUntilState.NetworkIdle });
|
||||
await Helpers.Sleep(2000);
|
||||
page.Response -= OnResponse;
|
||||
return items;
|
||||
}
|
||||
}
|
||||
147
src/Poe2Trade.Bot/TradeExecutor.cs
Normal file
147
src/Poe2Trade.Bot/TradeExecutor.cs
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
using Microsoft.Playwright;
|
||||
using Poe2Trade.Core;
|
||||
using Poe2Trade.Game;
|
||||
using Poe2Trade.Inventory;
|
||||
using Poe2Trade.Screen;
|
||||
using Poe2Trade.Trade;
|
||||
using Serilog;
|
||||
|
||||
namespace Poe2Trade.Bot;
|
||||
|
||||
public class TradeExecutor
|
||||
{
|
||||
private TradeState _state = TradeState.Idle;
|
||||
private readonly GameController _game;
|
||||
private readonly ScreenReader _screen;
|
||||
private readonly TradeMonitor _tradeMonitor;
|
||||
private readonly InventoryManager _inventory;
|
||||
private readonly AppConfig _config;
|
||||
|
||||
public event Action<TradeState>? StateChanged;
|
||||
|
||||
public TradeExecutor(GameController game, ScreenReader screen, TradeMonitor tradeMonitor,
|
||||
InventoryManager inventory, AppConfig config)
|
||||
{
|
||||
_game = game;
|
||||
_screen = screen;
|
||||
_tradeMonitor = tradeMonitor;
|
||||
_inventory = inventory;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
public TradeState State => _state;
|
||||
|
||||
private void SetState(TradeState s)
|
||||
{
|
||||
_state = s;
|
||||
StateChanged?.Invoke(s);
|
||||
}
|
||||
|
||||
public async Task<bool> ExecuteTrade(TradeInfo trade)
|
||||
{
|
||||
var page = trade.Page as IPage;
|
||||
if (page == null)
|
||||
{
|
||||
Log.Error("Trade has no page reference");
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Step 1: Travel to seller hideout
|
||||
SetState(TradeState.Traveling);
|
||||
Log.Information("Clicking Travel to Hideout for {SearchId}...", trade.SearchId);
|
||||
|
||||
var arrived = await _inventory.WaitForAreaTransition(
|
||||
_config.TravelTimeoutMs,
|
||||
async () =>
|
||||
{
|
||||
if (!await _tradeMonitor.ClickTravelToHideout(page, trade.ItemIds[0]))
|
||||
throw new Exception("Failed to click Travel to Hideout");
|
||||
});
|
||||
if (!arrived)
|
||||
{
|
||||
Log.Error("Timed out waiting for hideout arrival");
|
||||
SetState(TradeState.Failed);
|
||||
return false;
|
||||
}
|
||||
|
||||
SetState(TradeState.InSellersHideout);
|
||||
_inventory.SetLocation(false);
|
||||
Log.Information("Arrived at seller hideout");
|
||||
|
||||
// Step 2: Focus game and find stash
|
||||
await _game.FocusGame();
|
||||
await Helpers.Sleep(1500);
|
||||
|
||||
var angePos = await _inventory.FindAndClickNameplate("Ange");
|
||||
if (angePos == null)
|
||||
Log.Warning("Could not find Ange nameplate, trying Stash directly");
|
||||
else
|
||||
await Helpers.Sleep(1000);
|
||||
|
||||
var stashPos = await _inventory.FindAndClickNameplate("Stash");
|
||||
if (stashPos == null)
|
||||
{
|
||||
Log.Error("Could not find Stash in seller hideout");
|
||||
SetState(TradeState.Failed);
|
||||
return false;
|
||||
}
|
||||
await Helpers.Sleep(1000);
|
||||
|
||||
// Step 3: Scan stash and buy
|
||||
SetState(TradeState.ScanningStash);
|
||||
await ScanAndBuyItems();
|
||||
|
||||
// Step 4: Wait for more
|
||||
SetState(TradeState.WaitingForMore);
|
||||
Log.Information("Waiting {Ms}ms for more items...", _config.WaitForMoreItemsMs);
|
||||
await Helpers.Sleep(_config.WaitForMoreItemsMs);
|
||||
await ScanAndBuyItems();
|
||||
|
||||
// Step 5: Go home
|
||||
SetState(TradeState.GoingHome);
|
||||
await _game.FocusGame();
|
||||
await Helpers.Sleep(300);
|
||||
|
||||
var home = await _inventory.WaitForAreaTransition(
|
||||
_config.TravelTimeoutMs, () => _game.GoToHideout());
|
||||
if (!home) Log.Warning("Timed out going home");
|
||||
|
||||
_inventory.SetLocation(true);
|
||||
|
||||
// Step 6: Store items
|
||||
SetState(TradeState.InHideout);
|
||||
await Helpers.Sleep(1000);
|
||||
await _inventory.ProcessInventory();
|
||||
|
||||
SetState(TradeState.Idle);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Trade execution failed");
|
||||
SetState(TradeState.Failed);
|
||||
|
||||
try
|
||||
{
|
||||
await _game.FocusGame();
|
||||
await _game.PressEscape();
|
||||
await Helpers.Sleep(500);
|
||||
await _game.GoToHideout();
|
||||
}
|
||||
catch { /* best-effort recovery */ }
|
||||
|
||||
SetState(TradeState.Idle);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ScanAndBuyItems()
|
||||
{
|
||||
var stashRegion = new Region(20, 140, 630, 750);
|
||||
var stashText = await _screen.ReadRegionText(stashRegion);
|
||||
Log.Information("Stash OCR: {Text}", stashText.Length > 200 ? stashText[..200] : stashText);
|
||||
SetState(TradeState.Buying);
|
||||
}
|
||||
}
|
||||
71
src/Poe2Trade.Bot/TradeQueue.cs
Normal file
71
src/Poe2Trade.Bot/TradeQueue.cs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
using Poe2Trade.Core;
|
||||
using Serilog;
|
||||
|
||||
namespace Poe2Trade.Bot;
|
||||
|
||||
public class TradeQueue
|
||||
{
|
||||
private readonly Queue<TradeInfo> _queue = new();
|
||||
private readonly TradeExecutor _executor;
|
||||
private readonly AppConfig _config;
|
||||
private bool _processing;
|
||||
|
||||
public TradeQueue(TradeExecutor executor, AppConfig config)
|
||||
{
|
||||
_executor = executor;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
public int Length => _queue.Count;
|
||||
public bool IsProcessing => _processing;
|
||||
|
||||
public event Action? TradeCompleted;
|
||||
public event Action? TradeFailed;
|
||||
|
||||
public void Enqueue(TradeInfo trade)
|
||||
{
|
||||
var existingIds = _queue.SelectMany(t => t.ItemIds).ToHashSet();
|
||||
var newIds = trade.ItemIds.Where(id => !existingIds.Contains(id)).ToList();
|
||||
if (newIds.Count == 0)
|
||||
{
|
||||
Log.Information("Skipping duplicate trade: {ItemIds}", string.Join(",", trade.ItemIds));
|
||||
return;
|
||||
}
|
||||
|
||||
var deduped = trade with { ItemIds = newIds };
|
||||
_queue.Enqueue(deduped);
|
||||
Log.Information("Trade enqueued: {Count} items, queue={QueueLen}", newIds.Count, _queue.Count);
|
||||
_ = ProcessNext();
|
||||
}
|
||||
|
||||
private async Task ProcessNext()
|
||||
{
|
||||
if (_processing || _queue.Count == 0) return;
|
||||
_processing = true;
|
||||
|
||||
var trade = _queue.Dequeue();
|
||||
try
|
||||
{
|
||||
Log.Information("Processing trade: {SearchId} ({Count} items)", trade.SearchId, trade.ItemIds.Count);
|
||||
var success = await _executor.ExecuteTrade(trade);
|
||||
if (success)
|
||||
{
|
||||
Log.Information("Trade completed");
|
||||
TradeCompleted?.Invoke();
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Information("Trade failed");
|
||||
TradeFailed?.Invoke();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Trade execution error");
|
||||
}
|
||||
|
||||
_processing = false;
|
||||
await Helpers.RandomDelay(_config.BetweenTradesDelayMs, _config.BetweenTradesDelayMs + 3000);
|
||||
_ = ProcessNext();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue