switched to new way

This commit is contained in:
Boki 2026-02-13 01:12:51 -05:00
parent f22d182c8f
commit 4a65c8e17b
96 changed files with 4991 additions and 10025 deletions

View 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);
}

View 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>

View 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;
}
}

View 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);
}
}

View 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();
}
}