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();
|
||||
}
|
||||
}
|
||||
29
src/Poe2Trade.Core/AppConfig.cs
Normal file
29
src/Poe2Trade.Core/AppConfig.cs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace Poe2Trade.Core;
|
||||
|
||||
public class AppConfig
|
||||
{
|
||||
public List<string> TradeUrls { get; set; } = [];
|
||||
public string Poe2LogPath { get; set; } = @"C:\Program Files (x86)\Steam\steamapps\common\Path of Exile 2\logs\Client.txt";
|
||||
public string Poe2WindowTitle { get; set; } = "Path of Exile 2";
|
||||
public string BrowserUserDataDir { get; set; } = "./browser-data";
|
||||
public int TravelTimeoutMs { get; set; } = 15000;
|
||||
public int StashScanTimeoutMs { get; set; } = 10000;
|
||||
public int WaitForMoreItemsMs { get; set; } = 20000;
|
||||
public int BetweenTradesDelayMs { get; set; } = 5000;
|
||||
|
||||
public static AppConfig Load(string? configPath = null)
|
||||
{
|
||||
var builder = new ConfigurationBuilder();
|
||||
var path = configPath ?? "appsettings.json";
|
||||
if (File.Exists(path))
|
||||
{
|
||||
builder.AddJsonFile(path, optional: true);
|
||||
}
|
||||
var configuration = builder.Build();
|
||||
var config = new AppConfig();
|
||||
configuration.Bind(config);
|
||||
return config;
|
||||
}
|
||||
}
|
||||
147
src/Poe2Trade.Core/ConfigStore.cs
Normal file
147
src/Poe2Trade.Core/ConfigStore.cs
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Serilog;
|
||||
|
||||
namespace Poe2Trade.Core;
|
||||
|
||||
public class SavedLink
|
||||
{
|
||||
public string Url { get; set; } = "";
|
||||
public string Name { get; set; } = "";
|
||||
public bool Active { get; set; } = true;
|
||||
public LinkMode Mode { get; set; } = LinkMode.Live;
|
||||
public PostAction PostAction { get; set; } = PostAction.Stash;
|
||||
public string AddedAt { get; set; } = DateTime.UtcNow.ToString("o");
|
||||
}
|
||||
|
||||
public class SavedSettings
|
||||
{
|
||||
public bool Paused { get; set; }
|
||||
public List<SavedLink> Links { get; set; } = [];
|
||||
public string Poe2LogPath { get; set; } = @"C:\Program Files (x86)\Steam\steamapps\common\Path of Exile 2\logs\Client.txt";
|
||||
public string Poe2WindowTitle { get; set; } = "Path of Exile 2";
|
||||
public string BrowserUserDataDir { get; set; } = "./browser-data";
|
||||
public int TravelTimeoutMs { get; set; } = 15000;
|
||||
public int StashScanTimeoutMs { get; set; } = 10000;
|
||||
public int WaitForMoreItemsMs { get; set; } = 20000;
|
||||
public int BetweenTradesDelayMs { get; set; } = 5000;
|
||||
public double? WindowX { get; set; }
|
||||
public double? WindowY { get; set; }
|
||||
public double? WindowWidth { get; set; }
|
||||
public double? WindowHeight { get; set; }
|
||||
}
|
||||
|
||||
public class ConfigStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
|
||||
};
|
||||
|
||||
private readonly string _filePath;
|
||||
private SavedSettings _data;
|
||||
|
||||
public ConfigStore(string? configPath = null)
|
||||
{
|
||||
_filePath = configPath ?? Path.GetFullPath("config.json");
|
||||
_data = Load();
|
||||
}
|
||||
|
||||
public SavedSettings Settings => _data;
|
||||
public IReadOnlyList<SavedLink> Links => _data.Links;
|
||||
|
||||
public void AddLink(string url, string name = "", LinkMode mode = LinkMode.Live, PostAction? postAction = null)
|
||||
{
|
||||
url = StripLive(url);
|
||||
if (_data.Links.Any(l => l.Url == url)) return;
|
||||
_data.Links.Add(new SavedLink
|
||||
{
|
||||
Url = url,
|
||||
Name = name,
|
||||
Active = true,
|
||||
Mode = mode,
|
||||
PostAction = postAction ?? (mode == LinkMode.Scrap ? PostAction.Salvage : PostAction.Stash),
|
||||
AddedAt = DateTime.UtcNow.ToString("o")
|
||||
});
|
||||
Save();
|
||||
}
|
||||
|
||||
public void RemoveLink(string url)
|
||||
{
|
||||
_data.Links.RemoveAll(l => l.Url == url);
|
||||
Save();
|
||||
}
|
||||
|
||||
public void RemoveLinkById(string id)
|
||||
{
|
||||
_data.Links.RemoveAll(l => l.Url.Split('/').Last() == id);
|
||||
Save();
|
||||
}
|
||||
|
||||
public SavedLink? UpdateLinkById(string id, Action<SavedLink> update)
|
||||
{
|
||||
var link = _data.Links.FirstOrDefault(l => l.Url.Split('/').Last() == id);
|
||||
if (link == null) return null;
|
||||
update(link);
|
||||
Save();
|
||||
return link;
|
||||
}
|
||||
|
||||
public void SetPaused(bool paused)
|
||||
{
|
||||
_data.Paused = paused;
|
||||
Save();
|
||||
}
|
||||
|
||||
public void UpdateSettings(Action<SavedSettings> update)
|
||||
{
|
||||
update(_data);
|
||||
Save();
|
||||
}
|
||||
|
||||
public void Save()
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.Serialize(_data, JsonOptions);
|
||||
File.WriteAllText(_filePath, json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Failed to save config.json to {Path}", _filePath);
|
||||
}
|
||||
}
|
||||
|
||||
private SavedSettings Load()
|
||||
{
|
||||
if (!File.Exists(_filePath))
|
||||
{
|
||||
Log.Information("No config.json found at {Path}, using defaults", _filePath);
|
||||
return new SavedSettings();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var raw = File.ReadAllText(_filePath);
|
||||
var parsed = JsonSerializer.Deserialize<SavedSettings>(raw, JsonOptions);
|
||||
if (parsed == null) return new SavedSettings();
|
||||
|
||||
// Migrate links: strip /live from URLs
|
||||
foreach (var link in parsed.Links)
|
||||
{
|
||||
link.Url = StripLive(link.Url);
|
||||
}
|
||||
|
||||
Log.Information("Loaded config.json from {Path} ({LinkCount} links)", _filePath, parsed.Links.Count);
|
||||
return parsed;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Failed to read config.json at {Path}, using defaults", _filePath);
|
||||
return new SavedSettings();
|
||||
}
|
||||
}
|
||||
|
||||
private static string StripLive(string url) => System.Text.RegularExpressions.Regex.Replace(url, @"/live/?$", "");
|
||||
}
|
||||
14
src/Poe2Trade.Core/Helpers.cs
Normal file
14
src/Poe2Trade.Core/Helpers.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
namespace Poe2Trade.Core;
|
||||
|
||||
public static class Helpers
|
||||
{
|
||||
private static readonly Random Rng = new();
|
||||
|
||||
public static Task Sleep(int ms) => Task.Delay(ms);
|
||||
|
||||
public static Task RandomDelay(int minMs, int maxMs)
|
||||
{
|
||||
var delay = Rng.Next(minMs, maxMs + 1);
|
||||
return Task.Delay(delay);
|
||||
}
|
||||
}
|
||||
129
src/Poe2Trade.Core/LinkManager.cs
Normal file
129
src/Poe2Trade.Core/LinkManager.cs
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
using Serilog;
|
||||
|
||||
namespace Poe2Trade.Core;
|
||||
|
||||
public class TradeLink
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string Url { get; set; } = "";
|
||||
public string Name { get; set; } = "";
|
||||
public string Label { get; set; } = "";
|
||||
public bool Active { get; set; } = true;
|
||||
public LinkMode Mode { get; set; } = LinkMode.Live;
|
||||
public PostAction PostAction { get; set; } = PostAction.Stash;
|
||||
public string AddedAt { get; set; } = DateTime.UtcNow.ToString("o");
|
||||
}
|
||||
|
||||
public class LinkManager
|
||||
{
|
||||
private readonly Dictionary<string, TradeLink> _links = new();
|
||||
private readonly ConfigStore _store;
|
||||
|
||||
public LinkManager(ConfigStore store)
|
||||
{
|
||||
_store = store;
|
||||
}
|
||||
|
||||
public TradeLink AddLink(string url, string name = "", LinkMode? mode = null, PostAction? postAction = null)
|
||||
{
|
||||
url = StripLive(url);
|
||||
var id = ExtractId(url);
|
||||
var label = ExtractLabel(url);
|
||||
var savedLink = _store.Links.FirstOrDefault(l => l.Url == url);
|
||||
var resolvedMode = mode ?? savedLink?.Mode ?? LinkMode.Live;
|
||||
var link = new TradeLink
|
||||
{
|
||||
Id = id,
|
||||
Url = url,
|
||||
Name = name != "" ? name : savedLink?.Name ?? "",
|
||||
Label = label,
|
||||
Active = savedLink?.Active ?? true,
|
||||
Mode = resolvedMode,
|
||||
PostAction = postAction ?? savedLink?.PostAction ?? (resolvedMode == LinkMode.Scrap ? PostAction.Salvage : PostAction.Stash),
|
||||
AddedAt = DateTime.UtcNow.ToString("o")
|
||||
};
|
||||
_links[id] = link;
|
||||
_store.AddLink(url, link.Name, link.Mode, link.PostAction);
|
||||
Log.Information("Trade link added: {Id} {Url} mode={Mode}", id, url, link.Mode);
|
||||
return link;
|
||||
}
|
||||
|
||||
public void RemoveLink(string id)
|
||||
{
|
||||
if (_links.TryGetValue(id, out var link))
|
||||
{
|
||||
_links.Remove(id);
|
||||
_store.RemoveLink(link.Url);
|
||||
}
|
||||
else
|
||||
{
|
||||
_store.RemoveLinkById(id);
|
||||
}
|
||||
Log.Information("Trade link removed: {Id}", id);
|
||||
}
|
||||
|
||||
public TradeLink? ToggleLink(string id, bool active)
|
||||
{
|
||||
if (!_links.TryGetValue(id, out var link)) return null;
|
||||
link.Active = active;
|
||||
_store.UpdateLinkById(id, l => l.Active = active);
|
||||
Log.Information("Trade link {Action}: {Id}", active ? "activated" : "deactivated", id);
|
||||
return link;
|
||||
}
|
||||
|
||||
public void UpdateName(string id, string name)
|
||||
{
|
||||
if (!_links.TryGetValue(id, out var link)) return;
|
||||
link.Name = name;
|
||||
_store.UpdateLinkById(id, l => l.Name = name);
|
||||
}
|
||||
|
||||
public TradeLink? UpdateMode(string id, LinkMode mode)
|
||||
{
|
||||
if (!_links.TryGetValue(id, out var link)) return null;
|
||||
link.Mode = mode;
|
||||
_store.UpdateLinkById(id, l => l.Mode = mode);
|
||||
return link;
|
||||
}
|
||||
|
||||
public TradeLink? UpdatePostAction(string id, PostAction postAction)
|
||||
{
|
||||
if (!_links.TryGetValue(id, out var link)) return null;
|
||||
link.PostAction = postAction;
|
||||
_store.UpdateLinkById(id, l => l.PostAction = postAction);
|
||||
return link;
|
||||
}
|
||||
|
||||
public bool IsActive(string id) => _links.TryGetValue(id, out var link) && link.Active;
|
||||
|
||||
public List<TradeLink> GetLinks() => _links.Values.ToList();
|
||||
|
||||
public TradeLink? GetLink(string id) => _links.GetValueOrDefault(id);
|
||||
|
||||
private static string StripLive(string url) =>
|
||||
System.Text.RegularExpressions.Regex.Replace(url, @"/live/?$", "");
|
||||
|
||||
private static string ExtractId(string url)
|
||||
{
|
||||
var parts = url.Split('/');
|
||||
return parts.Length > 0 ? parts[^1] : url;
|
||||
}
|
||||
|
||||
private static string ExtractLabel(string url)
|
||||
{
|
||||
try
|
||||
{
|
||||
var uri = new Uri(url);
|
||||
var parts = uri.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
var poe2Idx = Array.IndexOf(parts, "poe2");
|
||||
if (poe2Idx >= 0 && parts.Length > poe2Idx + 2)
|
||||
{
|
||||
var league = Uri.UnescapeDataString(parts[poe2Idx + 1]);
|
||||
var searchId = parts[poe2Idx + 2];
|
||||
return $"{league} / {searchId}";
|
||||
}
|
||||
}
|
||||
catch { /* fallback */ }
|
||||
return url.Length > 60 ? url[..60] : url;
|
||||
}
|
||||
}
|
||||
19
src/Poe2Trade.Core/Logging.cs
Normal file
19
src/Poe2Trade.Core/Logging.cs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
using Serilog;
|
||||
using Serilog.Events;
|
||||
|
||||
namespace Poe2Trade.Core;
|
||||
|
||||
public static class Logging
|
||||
{
|
||||
public static void Setup()
|
||||
{
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Information()
|
||||
.WriteTo.Console(
|
||||
outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
|
||||
.WriteTo.File("logs/poe2trade-.log",
|
||||
rollingInterval: RollingInterval.Day,
|
||||
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{Level:u3}] {Message:lj}{NewLine}{Exception}")
|
||||
.CreateLogger();
|
||||
}
|
||||
}
|
||||
16
src/Poe2Trade.Core/Poe2Trade.Core.csproj
Normal file
16
src/Poe2Trade.Core/Poe2Trade.Core.csproj
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Serilog" Version="4.2.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.2" />
|
||||
<PackageReference Include="System.Text.Json" Version="8.0.5" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
70
src/Poe2Trade.Core/Types.cs
Normal file
70
src/Poe2Trade.Core/Types.cs
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
namespace Poe2Trade.Core;
|
||||
|
||||
public record Region(int X, int Y, int Width, int Height);
|
||||
|
||||
public record TradeInfo(
|
||||
string SearchId,
|
||||
List<string> ItemIds,
|
||||
string WhisperText,
|
||||
long Timestamp,
|
||||
string TradeUrl,
|
||||
object? Page // Playwright Page reference
|
||||
);
|
||||
|
||||
public record TradeItem(
|
||||
string Id,
|
||||
int W,
|
||||
int H,
|
||||
int StashX,
|
||||
int StashY,
|
||||
string Account
|
||||
);
|
||||
|
||||
public record LogEvent(
|
||||
DateTime Timestamp,
|
||||
LogEventType Type,
|
||||
Dictionary<string, string> Data
|
||||
);
|
||||
|
||||
public enum LogEventType
|
||||
{
|
||||
AreaEntered,
|
||||
WhisperReceived,
|
||||
TradeAccepted,
|
||||
Unknown
|
||||
}
|
||||
|
||||
public enum TradeState
|
||||
{
|
||||
Idle,
|
||||
Traveling,
|
||||
InSellersHideout,
|
||||
ScanningStash,
|
||||
Buying,
|
||||
WaitingForMore,
|
||||
GoingHome,
|
||||
InHideout,
|
||||
Failed
|
||||
}
|
||||
|
||||
public enum ScrapState
|
||||
{
|
||||
Idle,
|
||||
Traveling,
|
||||
Buying,
|
||||
Salvaging,
|
||||
Storing,
|
||||
Failed
|
||||
}
|
||||
|
||||
public enum LinkMode
|
||||
{
|
||||
Live,
|
||||
Scrap
|
||||
}
|
||||
|
||||
public enum PostAction
|
||||
{
|
||||
Stash,
|
||||
Salvage
|
||||
}
|
||||
111
src/Poe2Trade.Game/ClipboardHelper.cs
Normal file
111
src/Poe2Trade.Game/ClipboardHelper.cs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
|
||||
namespace Poe2Trade.Game;
|
||||
|
||||
/// <summary>
|
||||
/// Win32 clipboard access without WinForms dependency.
|
||||
/// </summary>
|
||||
public static class ClipboardHelper
|
||||
{
|
||||
public static string Read()
|
||||
{
|
||||
if (!ClipboardNative.OpenClipboard(IntPtr.Zero))
|
||||
return "";
|
||||
|
||||
try
|
||||
{
|
||||
var handle = ClipboardNative.GetClipboardData(ClipboardNative.CF_UNICODETEXT);
|
||||
if (handle == IntPtr.Zero) return "";
|
||||
|
||||
var ptr = ClipboardNative.GlobalLock(handle);
|
||||
if (ptr == IntPtr.Zero) return "";
|
||||
|
||||
try
|
||||
{
|
||||
return Marshal.PtrToStringUni(ptr) ?? "";
|
||||
}
|
||||
finally
|
||||
{
|
||||
ClipboardNative.GlobalUnlock(handle);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ClipboardNative.CloseClipboard();
|
||||
}
|
||||
}
|
||||
|
||||
public static void Write(string text)
|
||||
{
|
||||
if (!ClipboardNative.OpenClipboard(IntPtr.Zero))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
ClipboardNative.EmptyClipboard();
|
||||
var bytes = Encoding.Unicode.GetBytes(text + "\0");
|
||||
var hGlobal = ClipboardNative.GlobalAlloc(ClipboardNative.GMEM_MOVEABLE, (UIntPtr)bytes.Length);
|
||||
if (hGlobal == IntPtr.Zero) return;
|
||||
|
||||
var ptr = ClipboardNative.GlobalLock(hGlobal);
|
||||
if (ptr == IntPtr.Zero)
|
||||
{
|
||||
ClipboardNative.GlobalFree(hGlobal);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Marshal.Copy(bytes, 0, ptr, bytes.Length);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ClipboardNative.GlobalUnlock(hGlobal);
|
||||
}
|
||||
|
||||
ClipboardNative.SetClipboardData(ClipboardNative.CF_UNICODETEXT, hGlobal);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ClipboardNative.CloseClipboard();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static partial class ClipboardNative
|
||||
{
|
||||
public const uint CF_UNICODETEXT = 13;
|
||||
public const uint GMEM_MOVEABLE = 0x0002;
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static partial bool OpenClipboard(IntPtr hWndNewOwner);
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static partial bool CloseClipboard();
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static partial bool EmptyClipboard();
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
public static partial IntPtr GetClipboardData(uint uFormat);
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
public static partial IntPtr SetClipboardData(uint uFormat, IntPtr hMem);
|
||||
|
||||
[LibraryImport("kernel32.dll")]
|
||||
public static partial IntPtr GlobalAlloc(uint uFlags, UIntPtr dwBytes);
|
||||
|
||||
[LibraryImport("kernel32.dll")]
|
||||
public static partial IntPtr GlobalLock(IntPtr hMem);
|
||||
|
||||
[LibraryImport("kernel32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static partial bool GlobalUnlock(IntPtr hMem);
|
||||
|
||||
[LibraryImport("kernel32.dll")]
|
||||
public static partial IntPtr GlobalFree(IntPtr hMem);
|
||||
}
|
||||
77
src/Poe2Trade.Game/GameController.cs
Normal file
77
src/Poe2Trade.Game/GameController.cs
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
using Poe2Trade.Core;
|
||||
using Serilog;
|
||||
|
||||
namespace Poe2Trade.Game;
|
||||
|
||||
public class GameController
|
||||
{
|
||||
private readonly WindowManager _windowManager;
|
||||
private readonly InputSender _input;
|
||||
|
||||
public GameController(AppConfig config)
|
||||
{
|
||||
_windowManager = new WindowManager(config.Poe2WindowTitle);
|
||||
_input = new InputSender();
|
||||
}
|
||||
|
||||
public async Task<bool> FocusGame()
|
||||
{
|
||||
var result = _windowManager.FocusWindow();
|
||||
if (result)
|
||||
await Helpers.Sleep(300);
|
||||
return result;
|
||||
}
|
||||
|
||||
public bool IsGameFocused() => _windowManager.IsGameFocused();
|
||||
public RECT? GetWindowRect() => _windowManager.GetWindowRect();
|
||||
|
||||
public async Task SendChat(string message)
|
||||
{
|
||||
Log.Information("Sending chat message: {Message}", message);
|
||||
await _input.PressKey(InputSender.VK.RETURN);
|
||||
await Helpers.RandomDelay(100, 200);
|
||||
await _input.SelectAll();
|
||||
await Helpers.Sleep(50);
|
||||
await _input.PressKey(InputSender.VK.BACK);
|
||||
await Helpers.Sleep(50);
|
||||
await _input.TypeText(message);
|
||||
await Helpers.RandomDelay(50, 100);
|
||||
await _input.PressKey(InputSender.VK.RETURN);
|
||||
await Helpers.Sleep(100);
|
||||
}
|
||||
|
||||
public async Task SendChatViaPaste(string message)
|
||||
{
|
||||
Log.Information("Sending chat message via paste: {Message}", message);
|
||||
ClipboardHelper.Write(message);
|
||||
await Helpers.Sleep(50);
|
||||
await _input.PressKey(InputSender.VK.RETURN);
|
||||
await Helpers.RandomDelay(100, 200);
|
||||
await _input.SelectAll();
|
||||
await Helpers.Sleep(50);
|
||||
await _input.PressKey(InputSender.VK.BACK);
|
||||
await Helpers.Sleep(50);
|
||||
await _input.Paste();
|
||||
await Helpers.RandomDelay(100, 200);
|
||||
await _input.PressKey(InputSender.VK.RETURN);
|
||||
await Helpers.Sleep(100);
|
||||
}
|
||||
|
||||
public Task GoToHideout()
|
||||
{
|
||||
Log.Information("Sending /hideout command");
|
||||
return SendChatViaPaste("/hideout");
|
||||
}
|
||||
|
||||
public Task CtrlRightClickAt(int x, int y) => _input.CtrlRightClick(x, y);
|
||||
public Task MoveMouseTo(int x, int y) => _input.MoveMouse(x, y);
|
||||
public void MoveMouseInstant(int x, int y) => _input.MoveMouseInstant(x, y);
|
||||
public Task MoveMouseFast(int x, int y) => _input.MoveMouseFast(x, y);
|
||||
public Task LeftClickAt(int x, int y) => _input.LeftClick(x, y);
|
||||
public Task RightClickAt(int x, int y) => _input.RightClick(x, y);
|
||||
public Task PressEscape() => _input.PressKey(InputSender.VK.ESCAPE);
|
||||
public Task OpenInventory() => _input.PressKey(InputSender.VK.I);
|
||||
public Task CtrlLeftClickAt(int x, int y) => _input.CtrlLeftClick(x, y);
|
||||
public Task HoldCtrl() => _input.KeyDown(InputSender.VK.CONTROL);
|
||||
public Task ReleaseCtrl() => _input.KeyUp(InputSender.VK.CONTROL);
|
||||
}
|
||||
398
src/Poe2Trade.Game/InputSender.cs
Normal file
398
src/Poe2Trade.Game/InputSender.cs
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
using System.Runtime.InteropServices;
|
||||
using Poe2Trade.Core;
|
||||
|
||||
namespace Poe2Trade.Game;
|
||||
|
||||
public class InputSender
|
||||
{
|
||||
private readonly int _screenWidth;
|
||||
private readonly int _screenHeight;
|
||||
private static readonly Random Rng = new();
|
||||
|
||||
public InputSender()
|
||||
{
|
||||
_screenWidth = InputNative.GetSystemMetrics(InputNative.SM_CXSCREEN);
|
||||
_screenHeight = InputNative.GetSystemMetrics(InputNative.SM_CYSCREEN);
|
||||
}
|
||||
|
||||
// Virtual key codes
|
||||
public static class VK
|
||||
{
|
||||
public const int RETURN = 0x0D;
|
||||
public const int CONTROL = 0x11;
|
||||
public const int MENU = 0x12;
|
||||
public const int SHIFT = 0x10;
|
||||
public const int ESCAPE = 0x1B;
|
||||
public const int TAB = 0x09;
|
||||
public const int SPACE = 0x20;
|
||||
public const int DELETE = 0x2E;
|
||||
public const int BACK = 0x08;
|
||||
public const int V = 0x56;
|
||||
public const int A = 0x41;
|
||||
public const int C = 0x43;
|
||||
public const int I = 0x49;
|
||||
}
|
||||
|
||||
public async Task PressKey(int vkCode)
|
||||
{
|
||||
var scanCode = InputNative.MapVirtualKeyW((uint)vkCode, 0);
|
||||
SendScanKeyDown(scanCode);
|
||||
await Helpers.RandomDelay(30, 50);
|
||||
SendScanKeyUp(scanCode);
|
||||
await Helpers.RandomDelay(20, 40);
|
||||
}
|
||||
|
||||
public async Task KeyDown(int vkCode)
|
||||
{
|
||||
var scanCode = InputNative.MapVirtualKeyW((uint)vkCode, 0);
|
||||
SendScanKeyDown(scanCode);
|
||||
await Helpers.RandomDelay(15, 30);
|
||||
}
|
||||
|
||||
public async Task KeyUp(int vkCode)
|
||||
{
|
||||
var scanCode = InputNative.MapVirtualKeyW((uint)vkCode, 0);
|
||||
SendScanKeyUp(scanCode);
|
||||
await Helpers.RandomDelay(15, 30);
|
||||
}
|
||||
|
||||
public async Task TypeText(string text)
|
||||
{
|
||||
foreach (var ch in text)
|
||||
{
|
||||
SendUnicodeChar(ch);
|
||||
await Helpers.RandomDelay(20, 50);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task Paste()
|
||||
{
|
||||
await KeyDown(VK.CONTROL);
|
||||
await Helpers.Sleep(30);
|
||||
await PressKey(VK.V);
|
||||
await KeyUp(VK.CONTROL);
|
||||
await Helpers.Sleep(50);
|
||||
}
|
||||
|
||||
public async Task SelectAll()
|
||||
{
|
||||
await KeyDown(VK.CONTROL);
|
||||
await Helpers.Sleep(30);
|
||||
await PressKey(VK.A);
|
||||
await KeyUp(VK.CONTROL);
|
||||
await Helpers.Sleep(50);
|
||||
}
|
||||
|
||||
public (int X, int Y) GetCursorPos()
|
||||
{
|
||||
InputNative.GetCursorPos(out var pt);
|
||||
return (pt.X, pt.Y);
|
||||
}
|
||||
|
||||
private void MoveMouseRaw(int x, int y)
|
||||
{
|
||||
var normalizedX = (int)Math.Round((double)x * 65535 / _screenWidth);
|
||||
var normalizedY = (int)Math.Round((double)y * 65535 / _screenHeight);
|
||||
SendMouseInput(normalizedX, normalizedY, 0,
|
||||
InputNative.MOUSEEVENTF_MOVE | InputNative.MOUSEEVENTF_ABSOLUTE);
|
||||
}
|
||||
|
||||
public async Task MoveMouse(int x, int y)
|
||||
{
|
||||
var (sx, sy) = GetCursorPos();
|
||||
var dx = x - sx;
|
||||
var dy = y - sy;
|
||||
var distance = Math.Sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (distance < 10)
|
||||
{
|
||||
MoveMouseRaw(x, y);
|
||||
await Helpers.RandomDelay(10, 20);
|
||||
return;
|
||||
}
|
||||
|
||||
var perpX = -dy / distance;
|
||||
var perpY = dx / distance;
|
||||
var spread = distance * 0.3;
|
||||
|
||||
var cp1X = sx + dx * 0.25 + perpX * (Rng.NextDouble() - 0.5) * spread;
|
||||
var cp1Y = sy + dy * 0.25 + perpY * (Rng.NextDouble() - 0.5) * spread;
|
||||
var cp2X = sx + dx * 0.75 + perpX * (Rng.NextDouble() - 0.5) * spread;
|
||||
var cp2Y = sy + dy * 0.75 + perpY * (Rng.NextDouble() - 0.5) * spread;
|
||||
|
||||
var steps = Math.Clamp((int)Math.Round(distance / 30), 8, 20);
|
||||
|
||||
for (var i = 1; i <= steps; i++)
|
||||
{
|
||||
var rawT = (double)i / steps;
|
||||
var t = EaseInOutQuad(rawT);
|
||||
var (px, py) = CubicBezier(t, sx, sy, cp1X, cp1Y, cp2X, cp2Y, x, y);
|
||||
|
||||
var jitterX = i < steps ? (int)Math.Round((Rng.NextDouble() - 0.5) * 2) : 0;
|
||||
var jitterY = i < steps ? (int)Math.Round((Rng.NextDouble() - 0.5) * 2) : 0;
|
||||
|
||||
MoveMouseRaw((int)Math.Round(px) + jitterX, (int)Math.Round(py) + jitterY);
|
||||
await Task.Delay(1 + Rng.Next(2));
|
||||
}
|
||||
|
||||
MoveMouseRaw(x, y);
|
||||
await Helpers.RandomDelay(5, 15);
|
||||
}
|
||||
|
||||
public void MoveMouseInstant(int x, int y) => MoveMouseRaw(x, y);
|
||||
|
||||
public async Task MoveMouseFast(int x, int y)
|
||||
{
|
||||
var (sx, sy) = GetCursorPos();
|
||||
var dx = x - sx;
|
||||
var dy = y - sy;
|
||||
var distance = Math.Sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (distance < 10)
|
||||
{
|
||||
MoveMouseRaw(x, y);
|
||||
return;
|
||||
}
|
||||
|
||||
var perpX = -dy / distance;
|
||||
var perpY = dx / distance;
|
||||
var spread = distance * 0.15;
|
||||
|
||||
var cp1X = sx + dx * 0.3 + perpX * (Rng.NextDouble() - 0.5) * spread;
|
||||
var cp1Y = sy + dy * 0.3 + perpY * (Rng.NextDouble() - 0.5) * spread;
|
||||
var cp2X = sx + dx * 0.7 + perpX * (Rng.NextDouble() - 0.5) * spread;
|
||||
var cp2Y = sy + dy * 0.7 + perpY * (Rng.NextDouble() - 0.5) * spread;
|
||||
|
||||
for (var i = 1; i <= 5; i++)
|
||||
{
|
||||
var t = EaseInOutQuad((double)i / 5);
|
||||
var (px, py) = CubicBezier(t, sx, sy, cp1X, cp1Y, cp2X, cp2Y, x, y);
|
||||
MoveMouseRaw((int)Math.Round(px), (int)Math.Round(py));
|
||||
await Task.Delay(2);
|
||||
}
|
||||
MoveMouseRaw(x, y);
|
||||
}
|
||||
|
||||
public async Task LeftClick(int x, int y)
|
||||
{
|
||||
await MoveMouse(x, y);
|
||||
await Helpers.RandomDelay(20, 50);
|
||||
SendMouseInput(0, 0, 0, InputNative.MOUSEEVENTF_LEFTDOWN);
|
||||
await Helpers.RandomDelay(15, 40);
|
||||
SendMouseInput(0, 0, 0, InputNative.MOUSEEVENTF_LEFTUP);
|
||||
await Helpers.RandomDelay(15, 30);
|
||||
}
|
||||
|
||||
public async Task RightClick(int x, int y)
|
||||
{
|
||||
await MoveMouse(x, y);
|
||||
await Helpers.RandomDelay(20, 50);
|
||||
SendMouseInput(0, 0, 0, InputNative.MOUSEEVENTF_RIGHTDOWN);
|
||||
await Helpers.RandomDelay(15, 40);
|
||||
SendMouseInput(0, 0, 0, InputNative.MOUSEEVENTF_RIGHTUP);
|
||||
await Helpers.RandomDelay(15, 30);
|
||||
}
|
||||
|
||||
public async Task CtrlRightClick(int x, int y)
|
||||
{
|
||||
await KeyDown(VK.CONTROL);
|
||||
await Helpers.RandomDelay(30, 60);
|
||||
await RightClick(x, y);
|
||||
await KeyUp(VK.CONTROL);
|
||||
await Helpers.RandomDelay(30, 60);
|
||||
}
|
||||
|
||||
public async Task CtrlLeftClick(int x, int y)
|
||||
{
|
||||
await KeyDown(VK.CONTROL);
|
||||
await Helpers.RandomDelay(30, 60);
|
||||
await LeftClick(x, y);
|
||||
await KeyUp(VK.CONTROL);
|
||||
await Helpers.RandomDelay(30, 60);
|
||||
}
|
||||
|
||||
// -- Private helpers --
|
||||
|
||||
private void SendMouseInput(int dx, int dy, int mouseData, uint flags)
|
||||
{
|
||||
var input = new InputNative.INPUT
|
||||
{
|
||||
type = InputNative.INPUT_MOUSE,
|
||||
u = new InputNative.InputUnion
|
||||
{
|
||||
mi = new InputNative.MOUSEINPUT
|
||||
{
|
||||
dx = dx, dy = dy,
|
||||
mouseData = mouseData,
|
||||
dwFlags = flags,
|
||||
time = 0,
|
||||
dwExtraInfo = UIntPtr.Zero
|
||||
}
|
||||
}
|
||||
};
|
||||
InputNative.SendInput(1, [input], Marshal.SizeOf<InputNative.INPUT>());
|
||||
}
|
||||
|
||||
private void SendScanKeyDown(uint scanCode)
|
||||
{
|
||||
var input = new InputNative.INPUT
|
||||
{
|
||||
type = InputNative.INPUT_KEYBOARD,
|
||||
u = new InputNative.InputUnion
|
||||
{
|
||||
ki = new InputNative.KEYBDINPUT
|
||||
{
|
||||
wVk = 0,
|
||||
wScan = (ushort)scanCode,
|
||||
dwFlags = InputNative.KEYEVENTF_SCANCODE,
|
||||
time = 0,
|
||||
dwExtraInfo = UIntPtr.Zero
|
||||
}
|
||||
}
|
||||
};
|
||||
InputNative.SendInput(1, [input], Marshal.SizeOf<InputNative.INPUT>());
|
||||
}
|
||||
|
||||
private void SendScanKeyUp(uint scanCode)
|
||||
{
|
||||
var input = new InputNative.INPUT
|
||||
{
|
||||
type = InputNative.INPUT_KEYBOARD,
|
||||
u = new InputNative.InputUnion
|
||||
{
|
||||
ki = new InputNative.KEYBDINPUT
|
||||
{
|
||||
wVk = 0,
|
||||
wScan = (ushort)scanCode,
|
||||
dwFlags = InputNative.KEYEVENTF_SCANCODE | InputNative.KEYEVENTF_KEYUP,
|
||||
time = 0,
|
||||
dwExtraInfo = UIntPtr.Zero
|
||||
}
|
||||
}
|
||||
};
|
||||
InputNative.SendInput(1, [input], Marshal.SizeOf<InputNative.INPUT>());
|
||||
}
|
||||
|
||||
private void SendUnicodeChar(char ch)
|
||||
{
|
||||
var code = (ushort)ch;
|
||||
var down = new InputNative.INPUT
|
||||
{
|
||||
type = InputNative.INPUT_KEYBOARD,
|
||||
u = new InputNative.InputUnion
|
||||
{
|
||||
ki = new InputNative.KEYBDINPUT
|
||||
{
|
||||
wVk = 0, wScan = code,
|
||||
dwFlags = InputNative.KEYEVENTF_UNICODE,
|
||||
time = 0, dwExtraInfo = UIntPtr.Zero
|
||||
}
|
||||
}
|
||||
};
|
||||
var up = new InputNative.INPUT
|
||||
{
|
||||
type = InputNative.INPUT_KEYBOARD,
|
||||
u = new InputNative.InputUnion
|
||||
{
|
||||
ki = new InputNative.KEYBDINPUT
|
||||
{
|
||||
wVk = 0, wScan = code,
|
||||
dwFlags = InputNative.KEYEVENTF_UNICODE | InputNative.KEYEVENTF_KEYUP,
|
||||
time = 0, dwExtraInfo = UIntPtr.Zero
|
||||
}
|
||||
}
|
||||
};
|
||||
InputNative.SendInput(1, [down], Marshal.SizeOf<InputNative.INPUT>());
|
||||
InputNative.SendInput(1, [up], Marshal.SizeOf<InputNative.INPUT>());
|
||||
}
|
||||
|
||||
private static double EaseInOutQuad(double t) =>
|
||||
t < 0.5 ? 2 * t * t : 1 - Math.Pow(-2 * t + 2, 2) / 2;
|
||||
|
||||
private static (double X, double Y) CubicBezier(double t,
|
||||
double p0x, double p0y, double p1x, double p1y,
|
||||
double p2x, double p2y, double p3x, double p3y)
|
||||
{
|
||||
var u = 1 - t;
|
||||
var u2 = u * u;
|
||||
var u3 = u2 * u;
|
||||
var t2 = t * t;
|
||||
var t3 = t2 * t;
|
||||
return (
|
||||
u3 * p0x + 3 * u2 * t * p1x + 3 * u * t2 * p2x + t3 * p3x,
|
||||
u3 * p0y + 3 * u2 * t * p1y + 3 * u * t2 * p2y + t3 * p3y
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
internal static partial class InputNative
|
||||
{
|
||||
public const uint INPUT_MOUSE = 0;
|
||||
public const uint INPUT_KEYBOARD = 1;
|
||||
public const uint KEYEVENTF_SCANCODE = 0x0008;
|
||||
public const uint KEYEVENTF_KEYUP = 0x0002;
|
||||
public const uint KEYEVENTF_UNICODE = 0x0004;
|
||||
public const uint MOUSEEVENTF_MOVE = 0x0001;
|
||||
public const uint MOUSEEVENTF_LEFTDOWN = 0x0002;
|
||||
public const uint MOUSEEVENTF_LEFTUP = 0x0004;
|
||||
public const uint MOUSEEVENTF_RIGHTDOWN = 0x0008;
|
||||
public const uint MOUSEEVENTF_RIGHTUP = 0x0010;
|
||||
public const uint MOUSEEVENTF_ABSOLUTE = 0x8000;
|
||||
public const int SM_CXSCREEN = 0;
|
||||
public const int SM_CYSCREEN = 1;
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct MOUSEINPUT
|
||||
{
|
||||
public int dx;
|
||||
public int dy;
|
||||
public int mouseData;
|
||||
public uint dwFlags;
|
||||
public uint time;
|
||||
public UIntPtr dwExtraInfo;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct KEYBDINPUT
|
||||
{
|
||||
public ushort wVk;
|
||||
public ushort wScan;
|
||||
public uint dwFlags;
|
||||
public uint time;
|
||||
public UIntPtr dwExtraInfo;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Explicit)]
|
||||
public struct InputUnion
|
||||
{
|
||||
[FieldOffset(0)] public MOUSEINPUT mi;
|
||||
[FieldOffset(0)] public KEYBDINPUT ki;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct INPUT
|
||||
{
|
||||
public uint type;
|
||||
public InputUnion u;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct POINT
|
||||
{
|
||||
public int X;
|
||||
public int Y;
|
||||
}
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
public static partial uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize);
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
public static partial uint MapVirtualKeyW(uint uCode, uint uMapType);
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
public static partial int GetSystemMetrics(int nIndex);
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static partial bool GetCursorPos(out POINT lpPoint);
|
||||
}
|
||||
11
src/Poe2Trade.Game/Poe2Trade.Game.csproj
Normal file
11
src/Poe2Trade.Game/Poe2Trade.Game.csproj
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Poe2Trade.Core\Poe2Trade.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
113
src/Poe2Trade.Game/WindowManager.cs
Normal file
113
src/Poe2Trade.Game/WindowManager.cs
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
using System.Runtime.InteropServices;
|
||||
using Serilog;
|
||||
|
||||
namespace Poe2Trade.Game;
|
||||
|
||||
public class WindowManager
|
||||
{
|
||||
private IntPtr _hwnd = IntPtr.Zero;
|
||||
private readonly string _windowTitle;
|
||||
|
||||
public WindowManager(string windowTitle)
|
||||
{
|
||||
_windowTitle = windowTitle;
|
||||
}
|
||||
|
||||
public IntPtr FindWindow()
|
||||
{
|
||||
_hwnd = NativeMethods.FindWindowW(null, _windowTitle);
|
||||
if (_hwnd == IntPtr.Zero)
|
||||
Log.Warning("Window not found: {Title}", _windowTitle);
|
||||
else
|
||||
Log.Information("Window found: {Title} hwnd={Hwnd}", _windowTitle, _hwnd);
|
||||
return _hwnd;
|
||||
}
|
||||
|
||||
public bool FocusWindow()
|
||||
{
|
||||
if (_hwnd == IntPtr.Zero || !NativeMethods.IsWindow(_hwnd))
|
||||
FindWindow();
|
||||
if (_hwnd == IntPtr.Zero) return false;
|
||||
|
||||
// Restore if minimized
|
||||
NativeMethods.ShowWindow(_hwnd, NativeMethods.SW_RESTORE);
|
||||
|
||||
// Alt-key trick to bypass SetForegroundWindow restriction
|
||||
var altScan = NativeMethods.MapVirtualKeyW(NativeMethods.VK_MENU, 0);
|
||||
NativeMethods.keybd_event(NativeMethods.VK_MENU, (byte)altScan, 0, UIntPtr.Zero);
|
||||
NativeMethods.keybd_event(NativeMethods.VK_MENU, (byte)altScan, NativeMethods.KEYEVENTF_KEYUP, UIntPtr.Zero);
|
||||
|
||||
NativeMethods.BringWindowToTop(_hwnd);
|
||||
var result = NativeMethods.SetForegroundWindow(_hwnd);
|
||||
if (!result)
|
||||
Log.Warning("SetForegroundWindow failed");
|
||||
return result;
|
||||
}
|
||||
|
||||
public RECT? GetWindowRect()
|
||||
{
|
||||
if (_hwnd == IntPtr.Zero || !NativeMethods.IsWindow(_hwnd))
|
||||
FindWindow();
|
||||
if (_hwnd == IntPtr.Zero) return null;
|
||||
|
||||
if (NativeMethods.GetWindowRect(_hwnd, out var rect))
|
||||
return rect;
|
||||
return null;
|
||||
}
|
||||
|
||||
public bool IsGameFocused()
|
||||
{
|
||||
var fg = NativeMethods.GetForegroundWindow();
|
||||
return fg == _hwnd && _hwnd != IntPtr.Zero;
|
||||
}
|
||||
|
||||
public IntPtr Hwnd => _hwnd;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct RECT
|
||||
{
|
||||
public int Left;
|
||||
public int Top;
|
||||
public int Right;
|
||||
public int Bottom;
|
||||
}
|
||||
|
||||
internal static partial class NativeMethods
|
||||
{
|
||||
public const int SW_RESTORE = 9;
|
||||
public const byte VK_MENU = 0x12;
|
||||
public const uint KEYEVENTF_KEYUP = 0x0002;
|
||||
|
||||
[LibraryImport("user32.dll", StringMarshalling = StringMarshalling.Utf16)]
|
||||
public static partial IntPtr FindWindowW(string? lpClassName, string lpWindowName);
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static partial bool SetForegroundWindow(IntPtr hWnd);
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static partial bool ShowWindow(IntPtr hWnd, int nCmdShow);
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static partial bool BringWindowToTop(IntPtr hWnd);
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
public static partial IntPtr GetForegroundWindow();
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static partial bool GetWindowRect(IntPtr hWnd, out RECT lpRect);
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static partial bool IsWindow(IntPtr hWnd);
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
public static partial void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo);
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
public static partial uint MapVirtualKeyW(uint uCode, uint uMapType);
|
||||
}
|
||||
260
src/Poe2Trade.Inventory/InventoryManager.cs
Normal file
260
src/Poe2Trade.Inventory/InventoryManager.cs
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
using Poe2Trade.Core;
|
||||
using Poe2Trade.Game;
|
||||
using Poe2Trade.GameLog;
|
||||
using Poe2Trade.Screen;
|
||||
using Serilog;
|
||||
|
||||
namespace Poe2Trade.Inventory;
|
||||
|
||||
public class InventoryManager
|
||||
{
|
||||
private static readonly string SalvageTemplate = Path.Combine("assets", "salvage.png");
|
||||
|
||||
public InventoryTracker Tracker { get; } = new();
|
||||
|
||||
private bool _atOwnHideout = true;
|
||||
private string _sellerAccount = "";
|
||||
private readonly GameController _game;
|
||||
private readonly ScreenReader _screen;
|
||||
private readonly ClientLogWatcher _logWatcher;
|
||||
private readonly AppConfig _config;
|
||||
|
||||
public bool IsAtOwnHideout => _atOwnHideout;
|
||||
public string SellerAccount => _sellerAccount;
|
||||
|
||||
public InventoryManager(GameController game, ScreenReader screen, ClientLogWatcher logWatcher, AppConfig config)
|
||||
{
|
||||
_game = game;
|
||||
_screen = screen;
|
||||
_logWatcher = logWatcher;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
public void SetLocation(bool atHome, string? seller = null)
|
||||
{
|
||||
_atOwnHideout = atHome;
|
||||
_sellerAccount = seller ?? "";
|
||||
}
|
||||
|
||||
public async Task ScanInventory(PostAction defaultAction = PostAction.Stash)
|
||||
{
|
||||
Log.Information("Scanning inventory...");
|
||||
await _game.FocusGame();
|
||||
await Helpers.Sleep(300);
|
||||
await _game.OpenInventory();
|
||||
|
||||
var result = await _screen.Grid.Scan("inventory");
|
||||
|
||||
var cells = new bool[5, 12];
|
||||
foreach (var cell in result.Occupied)
|
||||
{
|
||||
if (cell.Row < 5 && cell.Col < 12)
|
||||
cells[cell.Row, cell.Col] = true;
|
||||
}
|
||||
Tracker.InitFromScan(cells, result.Items, defaultAction);
|
||||
|
||||
await _game.PressEscape();
|
||||
await Helpers.Sleep(300);
|
||||
}
|
||||
|
||||
public async Task ClearToStash()
|
||||
{
|
||||
Log.Information("Checking inventory for leftover items...");
|
||||
await ScanInventory(PostAction.Stash);
|
||||
|
||||
if (Tracker.GetItems().Count == 0)
|
||||
{
|
||||
Log.Information("Inventory empty, nothing to clear");
|
||||
return;
|
||||
}
|
||||
|
||||
Log.Information("Found {Count} leftover items, depositing to stash", Tracker.GetItems().Count);
|
||||
await DepositItemsToStash(Tracker.GetItems());
|
||||
Tracker.Clear();
|
||||
Log.Information("Inventory cleared to stash");
|
||||
}
|
||||
|
||||
public async Task<bool> EnsureAtOwnHideout()
|
||||
{
|
||||
if (_atOwnHideout)
|
||||
{
|
||||
Log.Information("Already at own hideout");
|
||||
return true;
|
||||
}
|
||||
|
||||
await _game.FocusGame();
|
||||
await Helpers.Sleep(300);
|
||||
|
||||
var arrived = await WaitForAreaTransition(_config.TravelTimeoutMs, () => _game.GoToHideout());
|
||||
if (!arrived)
|
||||
{
|
||||
Log.Error("Timed out going to own hideout");
|
||||
return false;
|
||||
}
|
||||
|
||||
await Helpers.Sleep(1500);
|
||||
_atOwnHideout = true;
|
||||
_sellerAccount = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task DepositItemsToStash(List<PlacedItem> items)
|
||||
{
|
||||
if (items.Count == 0) return;
|
||||
|
||||
var stashPos = await FindAndClickNameplate("Stash");
|
||||
if (stashPos == null)
|
||||
{
|
||||
Log.Error("Could not find Stash nameplate");
|
||||
return;
|
||||
}
|
||||
await Helpers.Sleep(1000);
|
||||
|
||||
var inventoryLayout = GridLayouts.Inventory;
|
||||
Log.Information("Depositing {Count} items to stash", items.Count);
|
||||
|
||||
await _game.HoldCtrl();
|
||||
foreach (var item in items)
|
||||
{
|
||||
var center = _screen.Grid.GetCellCenter(inventoryLayout, item.Row, item.Col);
|
||||
await _game.LeftClickAt(center.X, center.Y);
|
||||
await Helpers.Sleep(150);
|
||||
}
|
||||
await _game.ReleaseCtrl();
|
||||
await Helpers.Sleep(500);
|
||||
|
||||
await _game.PressEscape();
|
||||
await Helpers.Sleep(500);
|
||||
Log.Information("Items deposited to stash");
|
||||
}
|
||||
|
||||
public async Task<bool> SalvageItems(List<PlacedItem> items)
|
||||
{
|
||||
if (items.Count == 0) return true;
|
||||
|
||||
var nameplate = await FindAndClickNameplate("SALVAGE BENCH");
|
||||
if (nameplate == null)
|
||||
{
|
||||
Log.Error("Could not find Salvage nameplate");
|
||||
return false;
|
||||
}
|
||||
await Helpers.Sleep(1000);
|
||||
|
||||
var salvageBtn = await _screen.TemplateMatch(SalvageTemplate);
|
||||
if (salvageBtn != null)
|
||||
{
|
||||
await _game.LeftClickAt(salvageBtn.X, salvageBtn.Y);
|
||||
await Helpers.Sleep(500);
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Warning("Could not find salvage button via template match");
|
||||
}
|
||||
|
||||
var inventoryLayout = GridLayouts.Inventory;
|
||||
Log.Information("Salvaging {Count} inventory items", items.Count);
|
||||
|
||||
await _game.HoldCtrl();
|
||||
foreach (var item in items)
|
||||
{
|
||||
var center = _screen.Grid.GetCellCenter(inventoryLayout, item.Row, item.Col);
|
||||
await _game.LeftClickAt(center.X, center.Y);
|
||||
await Helpers.Sleep(150);
|
||||
}
|
||||
await _game.ReleaseCtrl();
|
||||
await Helpers.Sleep(500);
|
||||
|
||||
await _game.PressEscape();
|
||||
await Helpers.Sleep(500);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task ProcessInventory()
|
||||
{
|
||||
try
|
||||
{
|
||||
var home = await EnsureAtOwnHideout();
|
||||
if (!home)
|
||||
{
|
||||
Log.Error("Cannot process inventory: failed to reach hideout");
|
||||
return;
|
||||
}
|
||||
|
||||
if (Tracker.HasItemsWithAction(PostAction.Salvage))
|
||||
{
|
||||
var salvageItems = Tracker.GetItemsByAction(PostAction.Salvage);
|
||||
if (await SalvageItems(salvageItems))
|
||||
Tracker.RemoveItemsByAction(PostAction.Salvage);
|
||||
else
|
||||
Log.Warning("Salvage failed, depositing all to stash");
|
||||
}
|
||||
|
||||
await ScanInventory(PostAction.Stash);
|
||||
|
||||
var allItems = Tracker.GetItems();
|
||||
if (allItems.Count > 0)
|
||||
await DepositItemsToStash(allItems);
|
||||
|
||||
Tracker.Clear();
|
||||
Log.Information("Inventory processing complete");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Inventory processing failed");
|
||||
try { await _game.PressEscape(); await Helpers.Sleep(300); } catch { }
|
||||
Tracker.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(int X, int Y)?> FindAndClickNameplate(string name, int maxRetries = 3, int retryDelayMs = 1000)
|
||||
{
|
||||
for (var attempt = 1; attempt <= maxRetries; attempt++)
|
||||
{
|
||||
Log.Information("Searching for nameplate '{Name}' (attempt {Attempt}/{Max})", name, attempt, maxRetries);
|
||||
var pos = await _screen.FindTextOnScreen(name, fuzzy: true);
|
||||
if (pos.HasValue)
|
||||
{
|
||||
Log.Information("Clicking nameplate '{Name}' at ({X},{Y})", name, pos.Value.X, pos.Value.Y);
|
||||
await _game.LeftClickAt(pos.Value.X, pos.Value.Y);
|
||||
return pos;
|
||||
}
|
||||
if (attempt < maxRetries)
|
||||
await Helpers.Sleep(retryDelayMs);
|
||||
}
|
||||
|
||||
Log.Warning("Nameplate '{Name}' not found after {Max} retries", name, maxRetries);
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<bool> WaitForAreaTransition(int timeoutMs, Func<Task>? triggerAction = null)
|
||||
{
|
||||
var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
using var cts = new CancellationTokenSource(timeoutMs);
|
||||
cts.Token.Register(() => tcs.TrySetResult(false));
|
||||
|
||||
void Handler(string _) => tcs.TrySetResult(true);
|
||||
|
||||
_logWatcher.AreaEntered += Handler;
|
||||
try
|
||||
{
|
||||
if (triggerAction != null)
|
||||
{
|
||||
try { await triggerAction(); }
|
||||
catch
|
||||
{
|
||||
tcs.TrySetResult(false);
|
||||
}
|
||||
}
|
||||
return await tcs.Task;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_logWatcher.AreaEntered -= Handler;
|
||||
}
|
||||
}
|
||||
|
||||
public (bool[,] Grid, List<PlacedItem> Items, int Free) GetInventoryState()
|
||||
{
|
||||
return (Tracker.GetGrid(), Tracker.GetItems(), Tracker.FreeCells);
|
||||
}
|
||||
}
|
||||
126
src/Poe2Trade.Inventory/InventoryTracker.cs
Normal file
126
src/Poe2Trade.Inventory/InventoryTracker.cs
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
using Poe2Trade.Core;
|
||||
using Poe2Trade.Screen;
|
||||
using Serilog;
|
||||
|
||||
namespace Poe2Trade.Inventory;
|
||||
|
||||
public class PlacedItem
|
||||
{
|
||||
public int Row { get; init; }
|
||||
public int Col { get; init; }
|
||||
public int W { get; init; }
|
||||
public int H { get; init; }
|
||||
public PostAction PostAction { get; init; }
|
||||
}
|
||||
|
||||
public class InventoryTracker
|
||||
{
|
||||
private const int Rows = 5;
|
||||
private const int Cols = 12;
|
||||
|
||||
private readonly bool[,] _grid = new bool[Rows, Cols];
|
||||
private readonly List<PlacedItem> _items = [];
|
||||
|
||||
public void InitFromScan(bool[,] cells, List<GridItem> items, PostAction defaultAction = PostAction.Stash)
|
||||
{
|
||||
Array.Clear(_grid);
|
||||
_items.Clear();
|
||||
|
||||
var rowCount = Math.Min(cells.GetLength(0), Rows);
|
||||
var colCount = Math.Min(cells.GetLength(1), Cols);
|
||||
for (var r = 0; r < rowCount; r++)
|
||||
for (var c = 0; c < colCount; c++)
|
||||
_grid[r, c] = cells[r, c];
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (item.W > 2 || item.H > 4)
|
||||
{
|
||||
Log.Warning("Ignoring oversized item at ({Row},{Col}) {W}x{H}", item.Row, item.Col, item.W, item.H);
|
||||
continue;
|
||||
}
|
||||
_items.Add(new PlacedItem { Row = item.Row, Col = item.Col, W = item.W, H = item.H, PostAction = defaultAction });
|
||||
}
|
||||
|
||||
Log.Information("Inventory initialized: {Occupied} occupied, {Items} items, {Free} free",
|
||||
Rows * Cols - FreeCells, _items.Count, FreeCells);
|
||||
}
|
||||
|
||||
public (int Row, int Col)? TryPlace(int w, int h, PostAction postAction = PostAction.Stash)
|
||||
{
|
||||
for (var col = 0; col <= Cols - w; col++)
|
||||
for (var row = 0; row <= Rows - h; row++)
|
||||
{
|
||||
if (!Fits(row, col, w, h)) continue;
|
||||
Place(row, col, w, h, postAction);
|
||||
Log.Information("Item placed at ({Row},{Col}) {W}x{H} free={Free}", row, col, w, h, FreeCells);
|
||||
return (row, col);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public bool CanFit(int w, int h)
|
||||
{
|
||||
for (var col = 0; col <= Cols - w; col++)
|
||||
for (var row = 0; row <= Rows - h; row++)
|
||||
if (Fits(row, col, w, h)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
public List<PlacedItem> GetItems() => [.. _items];
|
||||
public List<PlacedItem> GetItemsByAction(PostAction action) => _items.Where(i => i.PostAction == action).ToList();
|
||||
public bool HasItemsWithAction(PostAction action) => _items.Any(i => i.PostAction == action);
|
||||
|
||||
public void RemoveItem(PlacedItem item)
|
||||
{
|
||||
if (!_items.Remove(item)) return;
|
||||
for (var r = item.Row; r < item.Row + item.H; r++)
|
||||
for (var c = item.Col; c < item.Col + item.W; c++)
|
||||
_grid[r, c] = false;
|
||||
}
|
||||
|
||||
public void RemoveItemsByAction(PostAction action)
|
||||
{
|
||||
var toRemove = _items.Where(i => i.PostAction == action).ToList();
|
||||
foreach (var item in toRemove)
|
||||
RemoveItem(item);
|
||||
Log.Information("Removed {Count} items with action {Action}", toRemove.Count, action);
|
||||
}
|
||||
|
||||
public bool[,] GetGrid() => (bool[,])_grid.Clone();
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
Array.Clear(_grid);
|
||||
_items.Clear();
|
||||
Log.Information("Inventory cleared");
|
||||
}
|
||||
|
||||
public int FreeCells
|
||||
{
|
||||
get
|
||||
{
|
||||
var count = 0;
|
||||
for (var r = 0; r < Rows; r++)
|
||||
for (var c = 0; c < Cols; c++)
|
||||
if (!_grid[r, c]) count++;
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
private bool Fits(int row, int col, int w, int h)
|
||||
{
|
||||
for (var r = row; r < row + h; r++)
|
||||
for (var c = col; c < col + w; c++)
|
||||
if (_grid[r, c]) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private void Place(int row, int col, int w, int h, PostAction postAction)
|
||||
{
|
||||
for (var r = row; r < row + h; r++)
|
||||
for (var c = col; c < col + w; c++)
|
||||
_grid[r, c] = true;
|
||||
_items.Add(new PlacedItem { Row = row, Col = col, W = w, H = h, PostAction = postAction });
|
||||
}
|
||||
}
|
||||
13
src/Poe2Trade.Inventory/Poe2Trade.Inventory.csproj
Normal file
13
src/Poe2Trade.Inventory/Poe2Trade.Inventory.csproj
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<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.Log\Poe2Trade.Log.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
59
src/Poe2Trade.Items/ItemReader.cs
Normal file
59
src/Poe2Trade.Items/ItemReader.cs
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
using Poe2Trade.Core;
|
||||
using Poe2Trade.Game;
|
||||
using Serilog;
|
||||
|
||||
namespace Poe2Trade.Items;
|
||||
|
||||
/// <summary>
|
||||
/// Reads item data by hovering and pressing Ctrl+C to copy item text to clipboard.
|
||||
/// Will be wired up to Sidekick's ItemParser once the submodule is added.
|
||||
/// </summary>
|
||||
public class ItemReader
|
||||
{
|
||||
private readonly GameController _game;
|
||||
|
||||
public ItemReader(GameController game)
|
||||
{
|
||||
_game = game;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hover over an item at (x, y), press Ctrl+C, and read clipboard text.
|
||||
/// </summary>
|
||||
public async Task<string?> ReadItemText(int x, int y)
|
||||
{
|
||||
// Move mouse to item position
|
||||
await _game.MoveMouseTo(x, y);
|
||||
await Helpers.Sleep(100);
|
||||
|
||||
// Ctrl+C to copy item text
|
||||
ClipboardHelper.Write(""); // Clear clipboard
|
||||
await Helpers.Sleep(50);
|
||||
|
||||
// Press Ctrl+C
|
||||
var input = new InputSender();
|
||||
await input.KeyDown(InputSender.VK.CONTROL);
|
||||
await Helpers.Sleep(30);
|
||||
await input.PressKey(InputSender.VK.C);
|
||||
await input.KeyUp(InputSender.VK.CONTROL);
|
||||
await Helpers.Sleep(100);
|
||||
|
||||
var text = ClipboardHelper.Read();
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
Log.Warning("No item text in clipboard after Ctrl+C at ({X},{Y})", x, y);
|
||||
return null;
|
||||
}
|
||||
|
||||
Log.Information("Read item text ({Length} chars) from ({X},{Y})", text.Length, x, y);
|
||||
return text;
|
||||
}
|
||||
|
||||
// TODO: Wire up Sidekick's ItemParser
|
||||
// public async Task<ParsedItem?> ParseItem(int x, int y)
|
||||
// {
|
||||
// var text = await ReadItemText(x, y);
|
||||
// if (text == null) return null;
|
||||
// return SidekickItemParser.Parse(text);
|
||||
// }
|
||||
}
|
||||
11
src/Poe2Trade.Items/Poe2Trade.Items.csproj
Normal file
11
src/Poe2Trade.Items/Poe2Trade.Items.csproj
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<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" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
171
src/Poe2Trade.Screen/DaemonTypes.cs
Normal file
171
src/Poe2Trade.Screen/DaemonTypes.cs
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Poe2Trade.Screen;
|
||||
|
||||
public class OcrWord
|
||||
{
|
||||
[JsonPropertyName("text")] public string Text { get; set; } = "";
|
||||
[JsonPropertyName("x")] public int X { get; set; }
|
||||
[JsonPropertyName("y")] public int Y { get; set; }
|
||||
[JsonPropertyName("width")] public int Width { get; set; }
|
||||
[JsonPropertyName("height")] public int Height { get; set; }
|
||||
}
|
||||
|
||||
public class OcrLine
|
||||
{
|
||||
[JsonPropertyName("text")] public string Text { get; set; } = "";
|
||||
[JsonPropertyName("words")] public List<OcrWord> Words { get; set; } = [];
|
||||
}
|
||||
|
||||
public class OcrResponse
|
||||
{
|
||||
public string Text { get; set; } = "";
|
||||
public List<OcrLine> Lines { get; set; } = [];
|
||||
}
|
||||
|
||||
public class GridItem
|
||||
{
|
||||
[JsonPropertyName("row")] public int Row { get; set; }
|
||||
[JsonPropertyName("col")] public int Col { get; set; }
|
||||
[JsonPropertyName("w")] public int W { get; set; }
|
||||
[JsonPropertyName("h")] public int H { get; set; }
|
||||
}
|
||||
|
||||
public class GridMatch
|
||||
{
|
||||
[JsonPropertyName("row")] public int Row { get; set; }
|
||||
[JsonPropertyName("col")] public int Col { get; set; }
|
||||
[JsonPropertyName("similarity")] public double Similarity { get; set; }
|
||||
}
|
||||
|
||||
public class GridScanResult
|
||||
{
|
||||
public bool[][] Cells { get; set; } = [];
|
||||
public List<GridItem> Items { get; set; } = [];
|
||||
public List<GridMatch>? Matches { get; set; }
|
||||
}
|
||||
|
||||
public class DiffOcrResponse
|
||||
{
|
||||
public string Text { get; set; } = "";
|
||||
public List<OcrLine> Lines { get; set; } = [];
|
||||
public Poe2Trade.Core.Region? Region { get; set; }
|
||||
}
|
||||
|
||||
public class TemplateMatchResult
|
||||
{
|
||||
public int X { get; set; }
|
||||
public int Y { get; set; }
|
||||
public int Width { get; set; }
|
||||
public int Height { get; set; }
|
||||
public double Confidence { get; set; }
|
||||
}
|
||||
|
||||
// -- Parameter types --
|
||||
|
||||
public sealed class DiffCropParams
|
||||
{
|
||||
[JsonPropertyName("diffThresh")]
|
||||
public int DiffThresh { get; set; } = 20;
|
||||
|
||||
[JsonPropertyName("rowThreshDiv")]
|
||||
public int RowThreshDiv { get; set; } = 40;
|
||||
|
||||
[JsonPropertyName("colThreshDiv")]
|
||||
public int ColThreshDiv { get; set; } = 8;
|
||||
|
||||
[JsonPropertyName("maxGap")]
|
||||
public int MaxGap { get; set; } = 20;
|
||||
|
||||
[JsonPropertyName("trimCutoff")]
|
||||
public double TrimCutoff { get; set; } = 0.4;
|
||||
|
||||
[JsonPropertyName("ocrPad")]
|
||||
public int OcrPad { get; set; } = 10;
|
||||
}
|
||||
|
||||
public sealed class OcrParams
|
||||
{
|
||||
// preprocessing
|
||||
[JsonPropertyName("kernelSize")]
|
||||
public int KernelSize { get; set; } = 41;
|
||||
|
||||
[JsonPropertyName("upscale")]
|
||||
public int Upscale { get; set; } = 2;
|
||||
|
||||
[JsonPropertyName("useBackgroundSub")]
|
||||
public bool UseBackgroundSub { get; set; } = true;
|
||||
|
||||
[JsonPropertyName("dimPercentile")]
|
||||
public int DimPercentile { get; set; } = 40;
|
||||
|
||||
[JsonPropertyName("textThresh")]
|
||||
public int TextThresh { get; set; } = 60;
|
||||
|
||||
[JsonPropertyName("softThreshold")]
|
||||
public bool SoftThreshold { get; set; } = false;
|
||||
|
||||
// EasyOCR tuning
|
||||
[JsonPropertyName("mergeGap")]
|
||||
public int MergeGap { get; set; } = 0;
|
||||
|
||||
[JsonPropertyName("linkThreshold")]
|
||||
public double? LinkThreshold { get; set; }
|
||||
|
||||
[JsonPropertyName("textThreshold")]
|
||||
public double? TextThreshold { get; set; }
|
||||
|
||||
[JsonPropertyName("lowText")]
|
||||
public double? LowText { get; set; }
|
||||
|
||||
[JsonPropertyName("widthThs")]
|
||||
public double? WidthThs { get; set; }
|
||||
|
||||
[JsonPropertyName("paragraph")]
|
||||
public bool? Paragraph { get; set; }
|
||||
}
|
||||
|
||||
public sealed class DiffOcrParams
|
||||
{
|
||||
[JsonPropertyName("crop")]
|
||||
public DiffCropParams Crop { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("ocr")]
|
||||
public OcrParams Ocr { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class EdgeCropParams
|
||||
{
|
||||
[JsonPropertyName("darkThresh")]
|
||||
public int DarkThresh { get; set; } = 40;
|
||||
|
||||
[JsonPropertyName("minDarkRun")]
|
||||
public int MinDarkRun { get; set; } = 200;
|
||||
|
||||
[JsonPropertyName("runGapTolerance")]
|
||||
public int RunGapTolerance { get; set; } = 15;
|
||||
|
||||
[JsonPropertyName("rowThreshDiv")]
|
||||
public int RowThreshDiv { get; set; } = 40;
|
||||
|
||||
[JsonPropertyName("colThreshDiv")]
|
||||
public int ColThreshDiv { get; set; } = 8;
|
||||
|
||||
[JsonPropertyName("maxGap")]
|
||||
public int MaxGap { get; set; } = 15;
|
||||
|
||||
[JsonPropertyName("trimCutoff")]
|
||||
public double TrimCutoff { get; set; } = 0.3;
|
||||
|
||||
[JsonPropertyName("ocrPad")]
|
||||
public int OcrPad { get; set; } = 10;
|
||||
}
|
||||
|
||||
public sealed class EdgeOcrParams
|
||||
{
|
||||
[JsonPropertyName("crop")]
|
||||
public EdgeCropParams Crop { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("ocr")]
|
||||
public OcrParams Ocr { get; set; } = new();
|
||||
}
|
||||
157
src/Poe2Trade.Screen/DetectGridHandler.cs
Normal file
157
src/Poe2Trade.Screen/DetectGridHandler.cs
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
namespace Poe2Trade.Screen;
|
||||
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using System.Runtime.InteropServices;
|
||||
using Serilog;
|
||||
using Region = Poe2Trade.Core.Region;
|
||||
|
||||
class DetectGridHandler
|
||||
{
|
||||
public DetectGridResult Detect(Region region, int minCellSize = 20, int maxCellSize = 70,
|
||||
string? file = null, bool debug = false)
|
||||
{
|
||||
Bitmap bitmap = ScreenCapture.CaptureOrLoad(file, region);
|
||||
int w = bitmap.Width;
|
||||
int h = bitmap.Height;
|
||||
|
||||
var bmpData = bitmap.LockBits(
|
||||
new Rectangle(0, 0, w, h),
|
||||
ImageLockMode.ReadOnly,
|
||||
PixelFormat.Format32bppArgb
|
||||
);
|
||||
byte[] pixels = new byte[bmpData.Stride * h];
|
||||
Marshal.Copy(bmpData.Scan0, pixels, 0, pixels.Length);
|
||||
bitmap.UnlockBits(bmpData);
|
||||
int stride = bmpData.Stride;
|
||||
|
||||
byte[] gray = new byte[w * h];
|
||||
for (int y = 0; y < h; y++)
|
||||
for (int x = 0; x < w; x++)
|
||||
{
|
||||
int i = y * stride + x * 4;
|
||||
gray[y * w + x] = (byte)((pixels[i] + pixels[i + 1] + pixels[i + 2]) / 3);
|
||||
}
|
||||
|
||||
bitmap.Dispose();
|
||||
|
||||
int bandH = 200;
|
||||
int bandStep = 40;
|
||||
const int veryDarkPixelThresh = 12;
|
||||
const double gridSegThresh = 0.25;
|
||||
|
||||
var candidates = new List<(int bandY, int cellW, double hAc, int hLeft, int hRight)>();
|
||||
|
||||
for (int by = 0; by + bandH <= h; by += bandStep)
|
||||
{
|
||||
double[] darkDensity = new double[w];
|
||||
for (int x = 0; x < w; x++)
|
||||
{
|
||||
int count = 0;
|
||||
for (int y = by; y < by + bandH; y++)
|
||||
{
|
||||
if (gray[y * w + x] < veryDarkPixelThresh) count++;
|
||||
}
|
||||
darkDensity[x] = (double)count / bandH;
|
||||
}
|
||||
|
||||
var gridSegs = SignalProcessing.FindDarkDensitySegments(darkDensity, gridSegThresh, 200);
|
||||
|
||||
foreach (var (segLeft, segRight) in gridSegs)
|
||||
{
|
||||
int segLen = segRight - segLeft;
|
||||
double[] segment = new double[segLen];
|
||||
Array.Copy(darkDensity, segLeft, segment, 0, segLen);
|
||||
|
||||
var (period, acScore) = SignalProcessing.FindPeriodWithScore(segment, minCellSize, maxCellSize);
|
||||
if (period <= 0) continue;
|
||||
|
||||
var (extLeft, extRight) = SignalProcessing.FindGridExtent(segment, period);
|
||||
if (extLeft < 0) continue;
|
||||
|
||||
int absLeft = segLeft + extLeft;
|
||||
int absRight = segLeft + extRight;
|
||||
int extent = absRight - absLeft;
|
||||
|
||||
if (extent < period * 8 || extent < 200) continue;
|
||||
|
||||
candidates.Add((by, period, acScore, absLeft, absRight));
|
||||
}
|
||||
}
|
||||
|
||||
candidates.Sort((a, b) =>
|
||||
{
|
||||
double sa = a.hAc * (a.hRight - a.hLeft);
|
||||
double sb = b.hAc * (b.hRight - b.hLeft);
|
||||
return sb.CompareTo(sa);
|
||||
});
|
||||
|
||||
// Pass 2: Verify vertical periodicity
|
||||
foreach (var cand in candidates.Take(10))
|
||||
{
|
||||
int colSpan = cand.hRight - cand.hLeft;
|
||||
if (colSpan < cand.cellW * 3) continue;
|
||||
|
||||
double[] rowDensity = new double[h];
|
||||
for (int y = 0; y < h; y++)
|
||||
{
|
||||
int count = 0;
|
||||
for (int x = cand.hLeft; x < cand.hRight; x++)
|
||||
{
|
||||
if (gray[y * w + x] < veryDarkPixelThresh) count++;
|
||||
}
|
||||
rowDensity[y] = (double)count / colSpan;
|
||||
}
|
||||
|
||||
var vGridSegs = SignalProcessing.FindDarkDensitySegments(rowDensity, gridSegThresh, 100);
|
||||
if (vGridSegs.Count == 0) continue;
|
||||
|
||||
var (vSegTop, vSegBottom) = vGridSegs.OrderByDescending(s => s.end - s.start).First();
|
||||
int vSegLen = vSegBottom - vSegTop;
|
||||
double[] vSegment = new double[vSegLen];
|
||||
Array.Copy(rowDensity, vSegTop, vSegment, 0, vSegLen);
|
||||
|
||||
var (cellH, vAc) = SignalProcessing.FindPeriodWithScore(vSegment, minCellSize, maxCellSize);
|
||||
if (cellH <= 0) continue;
|
||||
|
||||
var (extTop, extBottom) = SignalProcessing.FindGridExtent(vSegment, cellH);
|
||||
if (extTop < 0) continue;
|
||||
|
||||
int top = vSegTop + extTop;
|
||||
int bottom = vSegTop + extBottom;
|
||||
int vExtent = bottom - top;
|
||||
|
||||
if (vExtent < cellH * 3 || vExtent < 100) continue;
|
||||
|
||||
int gridW = cand.hRight - cand.hLeft;
|
||||
int gridH = bottom - top;
|
||||
int cols = Math.Max(2, (int)Math.Round((double)gridW / cand.cellW));
|
||||
int rows = Math.Max(2, (int)Math.Round((double)gridH / cellH));
|
||||
|
||||
gridW = cols * cand.cellW;
|
||||
gridH = rows * cellH;
|
||||
|
||||
return new DetectGridResult
|
||||
{
|
||||
Detected = true,
|
||||
Region = new Region(region.X + cand.hLeft, region.Y + top, gridW, gridH),
|
||||
Cols = cols,
|
||||
Rows = rows,
|
||||
CellWidth = Math.Round((double)gridW / cols, 1),
|
||||
CellHeight = Math.Round((double)gridH / rows, 1),
|
||||
};
|
||||
}
|
||||
|
||||
return new DetectGridResult { Detected = false };
|
||||
}
|
||||
}
|
||||
|
||||
public class DetectGridResult
|
||||
{
|
||||
public bool Detected { get; set; }
|
||||
public Region? Region { get; set; }
|
||||
public int Cols { get; set; }
|
||||
public int Rows { get; set; }
|
||||
public double CellWidth { get; set; }
|
||||
public double CellHeight { get; set; }
|
||||
}
|
||||
368
src/Poe2Trade.Screen/DiffCropHandler.cs
Normal file
368
src/Poe2Trade.Screen/DiffCropHandler.cs
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
namespace Poe2Trade.Screen;
|
||||
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using System.Runtime.InteropServices;
|
||||
using Serilog;
|
||||
using Region = Poe2Trade.Core.Region;
|
||||
|
||||
class DiffCropHandler
|
||||
{
|
||||
private Bitmap? _referenceFrame;
|
||||
private Region? _referenceRegion;
|
||||
|
||||
public void HandleSnapshot(string? file = null, Region? region = null)
|
||||
{
|
||||
_referenceFrame?.Dispose();
|
||||
_referenceFrame = ScreenCapture.CaptureOrLoad(file, region);
|
||||
_referenceRegion = region;
|
||||
}
|
||||
|
||||
public void HandleScreenshot(string path, Region? region = null)
|
||||
{
|
||||
var bitmap = _referenceFrame ?? ScreenCapture.CaptureOrLoad(null, region);
|
||||
var format = ImageUtils.GetImageFormat(path);
|
||||
var dir = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
bitmap.Save(path, format);
|
||||
if (bitmap != _referenceFrame) bitmap.Dispose();
|
||||
}
|
||||
|
||||
public byte[] HandleCapture(Region? region = null)
|
||||
{
|
||||
using var bitmap = ScreenCapture.CaptureOrLoad(null, region);
|
||||
using var ms = new MemoryStream();
|
||||
bitmap.Save(ms, System.Drawing.Imaging.ImageFormat.Png);
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Diff detection + crop only. Returns the raw tooltip crop bitmap and region,
|
||||
/// or null if no tooltip detected. Caller is responsible for disposing the bitmaps.
|
||||
/// </summary>
|
||||
public (Bitmap cropped, Bitmap refCropped, Bitmap current, Region region)? DiffCrop(
|
||||
DiffCropParams c, string? file = null, Region? region = null)
|
||||
{
|
||||
if (_referenceFrame == null)
|
||||
return null;
|
||||
|
||||
var diffRegion = region ?? _referenceRegion;
|
||||
int baseX = diffRegion?.X ?? 0;
|
||||
int baseY = diffRegion?.Y ?? 0;
|
||||
var current = ScreenCapture.CaptureOrLoad(file, diffRegion);
|
||||
|
||||
Bitmap refForDiff = _referenceFrame;
|
||||
bool disposeRef = false;
|
||||
|
||||
if (diffRegion != null)
|
||||
{
|
||||
if (_referenceRegion == null)
|
||||
{
|
||||
var croppedRef = CropBitmap(_referenceFrame, diffRegion);
|
||||
if (croppedRef == null)
|
||||
{
|
||||
current.Dispose();
|
||||
return null;
|
||||
}
|
||||
refForDiff = croppedRef;
|
||||
disposeRef = true;
|
||||
}
|
||||
else if (!RegionsEqual(diffRegion, _referenceRegion))
|
||||
{
|
||||
int offX = diffRegion.X - _referenceRegion.X;
|
||||
int offY = diffRegion.Y - _referenceRegion.Y;
|
||||
if (offX < 0 || offY < 0 || offX + diffRegion.Width > _referenceFrame.Width || offY + diffRegion.Height > _referenceFrame.Height)
|
||||
{
|
||||
current.Dispose();
|
||||
return null;
|
||||
}
|
||||
var croppedRef = CropBitmap(_referenceFrame, new Region(offX, offY, diffRegion.Width, diffRegion.Height));
|
||||
if (croppedRef == null)
|
||||
{
|
||||
current.Dispose();
|
||||
return null;
|
||||
}
|
||||
refForDiff = croppedRef;
|
||||
disposeRef = true;
|
||||
}
|
||||
}
|
||||
|
||||
int w = Math.Min(refForDiff.Width, current.Width);
|
||||
int h = Math.Min(refForDiff.Height, current.Height);
|
||||
|
||||
var refData = refForDiff.LockBits(new Rectangle(0, 0, w, h), ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
|
||||
byte[] refPx = new byte[refData.Stride * h];
|
||||
Marshal.Copy(refData.Scan0, refPx, 0, refPx.Length);
|
||||
refForDiff.UnlockBits(refData);
|
||||
int stride = refData.Stride;
|
||||
|
||||
var curData = current.LockBits(new Rectangle(0, 0, w, h), ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
|
||||
byte[] curPx = new byte[curData.Stride * h];
|
||||
Marshal.Copy(curData.Scan0, curPx, 0, curPx.Length);
|
||||
current.UnlockBits(curData);
|
||||
|
||||
int diffThresh = c.DiffThresh;
|
||||
|
||||
// Pass 1: parallel row diff
|
||||
int[] rowCounts = new int[h];
|
||||
Parallel.For(0, h, y =>
|
||||
{
|
||||
int count = 0;
|
||||
int rowOffset = y * stride;
|
||||
for (int x = 0; x < w; x++)
|
||||
{
|
||||
int i = rowOffset + x * 4;
|
||||
int darker = (refPx[i] - curPx[i]) + (refPx[i + 1] - curPx[i + 1]) + (refPx[i + 2] - curPx[i + 2]);
|
||||
if (darker > diffThresh)
|
||||
count++;
|
||||
}
|
||||
rowCounts[y] = count;
|
||||
});
|
||||
|
||||
int totalChanged = 0;
|
||||
for (int y = 0; y < h; y++) totalChanged += rowCounts[y];
|
||||
|
||||
if (totalChanged == 0)
|
||||
{
|
||||
current.Dispose();
|
||||
if (disposeRef) refForDiff.Dispose();
|
||||
return null;
|
||||
}
|
||||
|
||||
int maxGap = c.MaxGap;
|
||||
int rowThresh = w / c.RowThreshDiv;
|
||||
int bestRowStart = 0, bestRowEnd = 0, bestRowLen = 0;
|
||||
int curRowStart = -1, lastActiveRow = -1;
|
||||
for (int y = 0; y < h; y++)
|
||||
{
|
||||
if (rowCounts[y] >= rowThresh)
|
||||
{
|
||||
if (curRowStart < 0) curRowStart = y;
|
||||
lastActiveRow = y;
|
||||
}
|
||||
else if (curRowStart >= 0 && y - lastActiveRow > maxGap)
|
||||
{
|
||||
int len = lastActiveRow - curRowStart + 1;
|
||||
if (len > bestRowLen) { bestRowStart = curRowStart; bestRowEnd = lastActiveRow; bestRowLen = len; }
|
||||
curRowStart = -1;
|
||||
}
|
||||
}
|
||||
if (curRowStart >= 0)
|
||||
{
|
||||
int len = lastActiveRow - curRowStart + 1;
|
||||
if (len > bestRowLen) { bestRowStart = curRowStart; bestRowEnd = lastActiveRow; bestRowLen = len; }
|
||||
}
|
||||
|
||||
// Pass 2: parallel column diff
|
||||
int[] colCounts = new int[w];
|
||||
int rowRangeLen = bestRowEnd - bestRowStart + 1;
|
||||
if (rowRangeLen <= 200)
|
||||
{
|
||||
for (int y = bestRowStart; y <= bestRowEnd; y++)
|
||||
{
|
||||
int rowOffset = y * stride;
|
||||
for (int x = 0; x < w; x++)
|
||||
{
|
||||
int i = rowOffset + x * 4;
|
||||
int darker = (refPx[i] - curPx[i]) + (refPx[i + 1] - curPx[i + 1]) + (refPx[i + 2] - curPx[i + 2]);
|
||||
if (darker > diffThresh)
|
||||
colCounts[x]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Parallel.For(bestRowStart, bestRowEnd + 1,
|
||||
() => new int[w],
|
||||
(y, _, localCols) =>
|
||||
{
|
||||
int rowOffset = y * stride;
|
||||
for (int x = 0; x < w; x++)
|
||||
{
|
||||
int i = rowOffset + x * 4;
|
||||
int darker = (refPx[i] - curPx[i]) + (refPx[i + 1] - curPx[i + 1]) + (refPx[i + 2] - curPx[i + 2]);
|
||||
if (darker > diffThresh)
|
||||
localCols[x]++;
|
||||
}
|
||||
return localCols;
|
||||
},
|
||||
localCols =>
|
||||
{
|
||||
for (int x = 0; x < w; x++)
|
||||
Interlocked.Add(ref colCounts[x], localCols[x]);
|
||||
});
|
||||
}
|
||||
|
||||
int tooltipHeight = bestRowEnd - bestRowStart + 1;
|
||||
int colThresh = tooltipHeight / c.ColThreshDiv;
|
||||
|
||||
int bestColStart = 0, bestColEnd = 0, bestColLen = 0;
|
||||
int curColStart = -1, lastActiveCol = -1;
|
||||
for (int x = 0; x < w; x++)
|
||||
{
|
||||
if (colCounts[x] >= colThresh)
|
||||
{
|
||||
if (curColStart < 0) curColStart = x;
|
||||
lastActiveCol = x;
|
||||
}
|
||||
else if (curColStart >= 0 && x - lastActiveCol > maxGap)
|
||||
{
|
||||
int len = lastActiveCol - curColStart + 1;
|
||||
if (len > bestColLen) { bestColStart = curColStart; bestColEnd = lastActiveCol; bestColLen = len; }
|
||||
curColStart = -1;
|
||||
}
|
||||
}
|
||||
if (curColStart >= 0)
|
||||
{
|
||||
int len = lastActiveCol - curColStart + 1;
|
||||
if (len > bestColLen) { bestColStart = curColStart; bestColEnd = lastActiveCol; bestColLen = len; }
|
||||
}
|
||||
|
||||
Log.Debug("diff-crop: changed={Changed} rows={RowStart}-{RowEnd}({RowLen}) cols={ColStart}-{ColEnd}({ColLen})",
|
||||
totalChanged, bestRowStart, bestRowEnd, bestRowLen, bestColStart, bestColEnd, bestColLen);
|
||||
|
||||
if (bestRowLen < 50 || bestColLen < 50)
|
||||
{
|
||||
Log.Debug("diff-crop: no tooltip-sized region found");
|
||||
current.Dispose();
|
||||
if (disposeRef) refForDiff.Dispose();
|
||||
return null;
|
||||
}
|
||||
|
||||
int minX = bestColStart;
|
||||
int minY = bestRowStart;
|
||||
int maxX = Math.Min(bestColEnd, w - 1);
|
||||
int maxY = Math.Min(bestRowEnd, h - 1);
|
||||
|
||||
// Boundary extension
|
||||
int extRowThresh = Math.Max(1, rowThresh / 4);
|
||||
int extColThresh = Math.Max(1, colThresh / 4);
|
||||
|
||||
int extTop = Math.Max(0, minY - maxGap);
|
||||
for (int y = minY - 1; y >= extTop; y--)
|
||||
{
|
||||
if (rowCounts[y] >= extRowThresh) minY = y;
|
||||
else break;
|
||||
}
|
||||
int extBottom = Math.Min(h - 1, maxY + maxGap);
|
||||
for (int y = maxY + 1; y <= extBottom; y++)
|
||||
{
|
||||
if (rowCounts[y] >= extRowThresh) maxY = y;
|
||||
else break;
|
||||
}
|
||||
int extLeft = Math.Max(0, minX - maxGap);
|
||||
for (int x = minX - 1; x >= extLeft; x--)
|
||||
{
|
||||
if (colCounts[x] >= extColThresh) minX = x;
|
||||
else break;
|
||||
}
|
||||
int extRight = Math.Min(w - 1, maxX + maxGap);
|
||||
for (int x = maxX + 1; x <= extRight; x++)
|
||||
{
|
||||
if (colCounts[x] >= extColThresh) maxX = x;
|
||||
else break;
|
||||
}
|
||||
|
||||
// Trim low-density edges
|
||||
int colSpan = maxX - minX + 1;
|
||||
if (colSpan > 50)
|
||||
{
|
||||
int q1 = minX + colSpan / 4;
|
||||
int q3 = minX + colSpan * 3 / 4;
|
||||
long midSum = 0;
|
||||
int midCount = 0;
|
||||
for (int x = q1; x <= q3; x++) { midSum += colCounts[x]; midCount++; }
|
||||
double avgMidDensity = (double)midSum / Math.Max(1, midCount);
|
||||
double cutoff = avgMidDensity * c.TrimCutoff;
|
||||
|
||||
while (minX < maxX - 50 && colCounts[minX] < cutoff)
|
||||
minX++;
|
||||
while (maxX > minX + 50 && colCounts[maxX] < cutoff)
|
||||
maxX--;
|
||||
}
|
||||
|
||||
int rowSpan = maxY - minY + 1;
|
||||
if (rowSpan > 50)
|
||||
{
|
||||
int q1 = minY + rowSpan / 4;
|
||||
int q3 = minY + rowSpan * 3 / 4;
|
||||
long midSum = 0;
|
||||
int midCount = 0;
|
||||
for (int y = q1; y <= q3; y++) { midSum += rowCounts[y]; midCount++; }
|
||||
double avgMidDensity = (double)midSum / Math.Max(1, midCount);
|
||||
double cutoff = avgMidDensity * c.TrimCutoff;
|
||||
|
||||
while (minY < maxY - 50 && rowCounts[minY] < cutoff)
|
||||
minY++;
|
||||
while (maxY > minY + 50 && rowCounts[maxY] < cutoff)
|
||||
maxY--;
|
||||
}
|
||||
int rw = maxX - minX + 1;
|
||||
int rh = maxY - minY + 1;
|
||||
|
||||
var cropped = CropFromBytes(curPx, stride, minX, minY, rw, rh);
|
||||
var refCropped = CropFromBytes(refPx, stride, minX, minY, rw, rh);
|
||||
var resultRegion = new Region(baseX + minX, baseY + minY, rw, rh);
|
||||
|
||||
Log.Debug("diff-crop: tooltip region ({X},{Y}) {W}x{H}", minX, minY, rw, rh);
|
||||
|
||||
if (disposeRef) refForDiff.Dispose();
|
||||
return (cropped, refCropped, current, resultRegion);
|
||||
}
|
||||
|
||||
private static bool RegionsEqual(Region a, Region b) =>
|
||||
a.X == b.X && a.Y == b.Y && a.Width == b.Width && a.Height == b.Height;
|
||||
|
||||
private static Bitmap? CropBitmap(Bitmap src, Region region)
|
||||
{
|
||||
int cx = Math.Max(0, region.X);
|
||||
int cy = Math.Max(0, region.Y);
|
||||
int cw = Math.Min(region.Width, src.Width - cx);
|
||||
int ch = Math.Min(region.Height, src.Height - cy);
|
||||
if (cw <= 0 || ch <= 0)
|
||||
return null;
|
||||
return src.Clone(new Rectangle(cx, cy, cw, ch), PixelFormat.Format32bppArgb);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fast crop from raw pixel bytes.
|
||||
/// </summary>
|
||||
private static Bitmap CropFromBytes(byte[] px, int srcStride, int cropX, int cropY, int cropW, int cropH)
|
||||
{
|
||||
var bmp = new Bitmap(cropW, cropH, PixelFormat.Format32bppArgb);
|
||||
var data = bmp.LockBits(new Rectangle(0, 0, cropW, cropH), ImageLockMode.WriteOnly, PixelFormat.Format32bppArgb);
|
||||
int dstStride = data.Stride;
|
||||
int rowBytes = cropW * 4;
|
||||
for (int y = 0; y < cropH; y++)
|
||||
{
|
||||
int srcOffset = (cropY + y) * srcStride + cropX * 4;
|
||||
Marshal.Copy(px, srcOffset, data.Scan0 + y * dstStride, rowBytes);
|
||||
}
|
||||
bmp.UnlockBits(data);
|
||||
return bmp;
|
||||
}
|
||||
|
||||
public static double LevenshteinSimilarity(string a, string b)
|
||||
{
|
||||
a = a.ToLowerInvariant();
|
||||
b = b.ToLowerInvariant();
|
||||
if (a == b) return 1.0;
|
||||
|
||||
int la = a.Length, lb = b.Length;
|
||||
if (la == 0 || lb == 0) return 0.0;
|
||||
|
||||
var d = new int[la + 1, lb + 1];
|
||||
for (int i = 0; i <= la; i++) d[i, 0] = i;
|
||||
for (int j = 0; j <= lb; j++) d[0, j] = j;
|
||||
|
||||
for (int i = 1; i <= la; i++)
|
||||
for (int j = 1; j <= lb; j++)
|
||||
{
|
||||
int cost = a[i - 1] == b[j - 1] ? 0 : 1;
|
||||
d[i, j] = Math.Min(Math.Min(d[i - 1, j] + 1, d[i, j - 1] + 1), d[i - 1, j - 1] + cost);
|
||||
}
|
||||
|
||||
return 1.0 - (double)d[la, lb] / Math.Max(la, lb);
|
||||
}
|
||||
}
|
||||
243
src/Poe2Trade.Screen/EdgeCropHandler.cs
Normal file
243
src/Poe2Trade.Screen/EdgeCropHandler.cs
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
namespace Poe2Trade.Screen;
|
||||
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using System.Runtime.InteropServices;
|
||||
using Serilog;
|
||||
using Region = Poe2Trade.Core.Region;
|
||||
|
||||
class EdgeCropHandler
|
||||
{
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct POINT { public int X, Y; }
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool GetCursorPos(out POINT lpPoint);
|
||||
|
||||
public (Bitmap cropped, Bitmap fullCapture, Region region)? EdgeCrop(
|
||||
EdgeCropParams p, int? cursorX = null, int? cursorY = null, string? file = null)
|
||||
{
|
||||
int cx, cy;
|
||||
if (cursorX.HasValue && cursorY.HasValue)
|
||||
{
|
||||
cx = cursorX.Value;
|
||||
cy = cursorY.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
GetCursorPos(out var pt);
|
||||
cx = pt.X;
|
||||
cy = pt.Y;
|
||||
}
|
||||
|
||||
var fullCapture = ScreenCapture.CaptureOrLoad(file, null);
|
||||
int w = fullCapture.Width;
|
||||
int h = fullCapture.Height;
|
||||
|
||||
cx = Math.Clamp(cx, 0, w - 1);
|
||||
cy = Math.Clamp(cy, 0, h - 1);
|
||||
|
||||
var bmpData = fullCapture.LockBits(new Rectangle(0, 0, w, h), ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
|
||||
byte[] px = new byte[bmpData.Stride * h];
|
||||
Marshal.Copy(bmpData.Scan0, px, 0, px.Length);
|
||||
fullCapture.UnlockBits(bmpData);
|
||||
int stride = bmpData.Stride;
|
||||
|
||||
int darkThresh = p.DarkThresh;
|
||||
int colGap = p.RunGapTolerance;
|
||||
int maxGap = p.MaxGap;
|
||||
|
||||
// Phase 1: Per-row horizontal extent
|
||||
int bandHalf = p.MinDarkRun;
|
||||
int bandTop = Math.Max(0, cy - bandHalf);
|
||||
int bandBot = Math.Min(h - 1, cy + bandHalf);
|
||||
|
||||
var leftExtents = new List<int>();
|
||||
var rightExtents = new List<int>();
|
||||
|
||||
for (int y = bandTop; y <= bandBot; y++)
|
||||
{
|
||||
int rowOff = y * stride;
|
||||
int seedX = FindDarkSeedInRow(px, stride, w, rowOff, cx, darkThresh, seedRadius: 6);
|
||||
if (seedX < 0) continue;
|
||||
|
||||
int leftEdge = cx;
|
||||
int gap = 0;
|
||||
bool foundLeft = false;
|
||||
int initialBridge = Math.Max(colGap * 4, 12);
|
||||
for (int x = cx; x >= 0; x--)
|
||||
{
|
||||
int i = rowOff + x * 4;
|
||||
int brightness = (px[i] + px[i + 1] + px[i + 2]) / 3;
|
||||
if (brightness < darkThresh) { leftEdge = x; gap = 0; foundLeft = true; }
|
||||
else if (++gap > (foundLeft ? colGap : initialBridge)) break;
|
||||
}
|
||||
|
||||
int rightEdge = cx;
|
||||
gap = 0;
|
||||
bool foundRight = false;
|
||||
for (int x = cx; x < w; x++)
|
||||
{
|
||||
int i = rowOff + x * 4;
|
||||
int brightness = (px[i] + px[i + 1] + px[i + 2]) / 3;
|
||||
if (brightness < darkThresh) { rightEdge = x; gap = 0; foundRight = true; }
|
||||
else if (++gap > (foundRight ? colGap : initialBridge)) break;
|
||||
}
|
||||
|
||||
leftExtents.Add(leftEdge);
|
||||
rightExtents.Add(rightEdge);
|
||||
}
|
||||
|
||||
if (leftExtents.Count < 10)
|
||||
{
|
||||
Log.Debug("edge-crop: too few dark rows ({Count})", leftExtents.Count);
|
||||
fullCapture.Dispose();
|
||||
return null;
|
||||
}
|
||||
|
||||
leftExtents.Sort();
|
||||
rightExtents.Sort();
|
||||
|
||||
int leftPctIdx = leftExtents.Count / p.RowThreshDiv;
|
||||
int rightPctIdx = rightExtents.Count * (p.ColThreshDiv - 1) / p.ColThreshDiv;
|
||||
leftPctIdx = Math.Clamp(leftPctIdx, 0, leftExtents.Count - 1);
|
||||
rightPctIdx = Math.Clamp(rightPctIdx, 0, rightExtents.Count - 1);
|
||||
|
||||
int bestColStart = leftExtents[leftPctIdx];
|
||||
int bestColEnd = rightExtents[rightPctIdx];
|
||||
|
||||
if (bestColEnd - bestColStart + 1 < 50)
|
||||
{
|
||||
Log.Debug("edge-crop: horizontal extent too small");
|
||||
fullCapture.Dispose();
|
||||
return null;
|
||||
}
|
||||
|
||||
// Phase 2: Per-column vertical extent
|
||||
int colBandHalf = (bestColEnd - bestColStart + 1) / 3;
|
||||
int colBandLeft = Math.Max(bestColStart, cx - colBandHalf);
|
||||
int colBandRight = Math.Min(bestColEnd, cx + colBandHalf);
|
||||
|
||||
var topExtents = new List<int>();
|
||||
var bottomExtents = new List<int>();
|
||||
|
||||
int maxGapUp = maxGap * 3;
|
||||
|
||||
for (int x = colBandLeft; x <= colBandRight; x++)
|
||||
{
|
||||
int seedY = FindDarkSeedInColumn(px, stride, h, x, cy, darkThresh, seedRadius: 6);
|
||||
if (seedY < 0) continue;
|
||||
|
||||
int topEdge = cy;
|
||||
int gap = 0;
|
||||
bool foundTop = false;
|
||||
int initialBridgeUp = Math.Max(maxGapUp * 2, 12);
|
||||
for (int y = cy; y >= 0; y--)
|
||||
{
|
||||
int i = y * stride + x * 4;
|
||||
int brightness = (px[i] + px[i + 1] + px[i + 2]) / 3;
|
||||
if (brightness < darkThresh) { topEdge = y; gap = 0; foundTop = true; }
|
||||
else if (++gap > (foundTop ? maxGapUp : initialBridgeUp)) break;
|
||||
}
|
||||
|
||||
int bottomEdge = cy;
|
||||
gap = 0;
|
||||
bool foundBottom = false;
|
||||
int initialBridgeDown = Math.Max(maxGap * 2, 12);
|
||||
for (int y = cy; y < h; y++)
|
||||
{
|
||||
int i = y * stride + x * 4;
|
||||
int brightness = (px[i] + px[i + 1] + px[i + 2]) / 3;
|
||||
if (brightness < darkThresh) { bottomEdge = y; gap = 0; foundBottom = true; }
|
||||
else if (++gap > (foundBottom ? maxGap : initialBridgeDown)) break;
|
||||
}
|
||||
|
||||
topExtents.Add(topEdge);
|
||||
bottomExtents.Add(bottomEdge);
|
||||
}
|
||||
|
||||
if (topExtents.Count < 10)
|
||||
{
|
||||
Log.Debug("edge-crop: too few dark columns ({Count})", topExtents.Count);
|
||||
fullCapture.Dispose();
|
||||
return null;
|
||||
}
|
||||
|
||||
topExtents.Sort();
|
||||
bottomExtents.Sort();
|
||||
|
||||
int topPctIdx = topExtents.Count / p.RowThreshDiv;
|
||||
int botPctIdx = topExtents.Count * (p.ColThreshDiv - 1) / p.ColThreshDiv;
|
||||
topPctIdx = Math.Clamp(topPctIdx, 0, topExtents.Count - 1);
|
||||
botPctIdx = Math.Clamp(botPctIdx, 0, bottomExtents.Count - 1);
|
||||
|
||||
int bestRowStart = topExtents[topPctIdx];
|
||||
int bestRowEnd = bottomExtents[botPctIdx];
|
||||
|
||||
if (bestRowEnd - bestRowStart + 1 < 50)
|
||||
{
|
||||
Log.Debug("edge-crop: vertical extent too small");
|
||||
fullCapture.Dispose();
|
||||
return null;
|
||||
}
|
||||
|
||||
int minX = bestColStart;
|
||||
int minY = bestRowStart;
|
||||
int maxX = bestColEnd;
|
||||
int maxY = bestRowEnd;
|
||||
|
||||
int rw = maxX - minX + 1;
|
||||
int rh = maxY - minY + 1;
|
||||
|
||||
if (rw < 50 || rh < 50)
|
||||
{
|
||||
Log.Debug("edge-crop: region too small ({W}x{H})", rw, rh);
|
||||
fullCapture.Dispose();
|
||||
return null;
|
||||
}
|
||||
|
||||
var cropRect = new Rectangle(minX, minY, rw, rh);
|
||||
var cropped = fullCapture.Clone(cropRect, PixelFormat.Format32bppArgb);
|
||||
var region = new Region(minX, minY, rw, rh);
|
||||
|
||||
return (cropped, fullCapture, region);
|
||||
}
|
||||
|
||||
private static int FindDarkSeedInRow(byte[] px, int stride, int w, int rowOff, int cursorX, int darkThresh, int seedRadius)
|
||||
{
|
||||
int maxR = Math.Min(seedRadius, Math.Min(cursorX, w - 1 - cursorX));
|
||||
for (int r = 0; r <= maxR; r++)
|
||||
{
|
||||
int x1 = cursorX - r;
|
||||
int i1 = rowOff + x1 * 4;
|
||||
int b1 = (px[i1] + px[i1 + 1] + px[i1 + 2]) / 3;
|
||||
if (b1 < darkThresh) return x1;
|
||||
|
||||
int x2 = cursorX + r;
|
||||
int i2 = rowOff + x2 * 4;
|
||||
int b2 = (px[i2] + px[i2 + 1] + px[i2 + 2]) / 3;
|
||||
if (b2 < darkThresh) return x2;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static int FindDarkSeedInColumn(byte[] px, int stride, int h, int x, int cursorY, int darkThresh, int seedRadius)
|
||||
{
|
||||
int maxR = Math.Min(seedRadius, Math.Min(cursorY, h - 1 - cursorY));
|
||||
for (int r = 0; r <= maxR; r++)
|
||||
{
|
||||
int y1 = cursorY - r;
|
||||
int i1 = y1 * stride + x * 4;
|
||||
int b1 = (px[i1] + px[i1 + 1] + px[i1 + 2]) / 3;
|
||||
if (b1 < darkThresh) return y1;
|
||||
|
||||
int y2 = cursorY + r;
|
||||
int i2 = y2 * stride + x * 4;
|
||||
int b2 = (px[i2] + px[i2 + 1] + px[i2 + 2]) / 3;
|
||||
if (b2 < darkThresh) return y2;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
323
src/Poe2Trade.Screen/GridHandler.cs
Normal file
323
src/Poe2Trade.Screen/GridHandler.cs
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
namespace Poe2Trade.Screen;
|
||||
|
||||
using System.Drawing;
|
||||
using Serilog;
|
||||
using Region = Poe2Trade.Core.Region;
|
||||
|
||||
public class GridHandler
|
||||
{
|
||||
private byte[]? _emptyTemplate70Gray;
|
||||
private byte[]? _emptyTemplate70Argb;
|
||||
private int _emptyTemplate70W, _emptyTemplate70H, _emptyTemplate70Stride;
|
||||
private byte[]? _emptyTemplate35Gray;
|
||||
private byte[]? _emptyTemplate35Argb;
|
||||
private int _emptyTemplate35W, _emptyTemplate35H, _emptyTemplate35Stride;
|
||||
|
||||
public GridScanResult Scan(Region region, int cols, int rows,
|
||||
int threshold = 0, int? targetRow = null, int? targetCol = null,
|
||||
string? file = null, bool debug = false)
|
||||
{
|
||||
LoadTemplatesIfNeeded();
|
||||
|
||||
using var bitmap = ScreenCapture.CaptureOrLoad(file, region);
|
||||
float cellW = (float)bitmap.Width / cols;
|
||||
float cellH = (float)bitmap.Height / rows;
|
||||
|
||||
// Pick the right empty template based on cell size
|
||||
int nominalCell = (int)Math.Round(cellW);
|
||||
byte[]? templateGray;
|
||||
byte[]? templateArgb;
|
||||
int templateW, templateH, templateStride;
|
||||
if (nominalCell <= 40 && _emptyTemplate35Gray != null)
|
||||
{
|
||||
templateGray = _emptyTemplate35Gray;
|
||||
templateArgb = _emptyTemplate35Argb!;
|
||||
templateW = _emptyTemplate35W;
|
||||
templateH = _emptyTemplate35H;
|
||||
templateStride = _emptyTemplate35Stride;
|
||||
}
|
||||
else if (_emptyTemplate70Gray != null)
|
||||
{
|
||||
templateGray = _emptyTemplate70Gray;
|
||||
templateArgb = _emptyTemplate70Argb!;
|
||||
templateW = _emptyTemplate70W;
|
||||
templateH = _emptyTemplate70H;
|
||||
templateStride = _emptyTemplate70Stride;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException("Empty cell templates not found in assets/");
|
||||
}
|
||||
|
||||
var (captureGray, captureArgb, captureStride) = ImageUtils.BitmapToGrayAndArgb(bitmap);
|
||||
int captureW = bitmap.Width;
|
||||
|
||||
int border = Math.Max(2, nominalCell / 10);
|
||||
|
||||
// Pre-compute template average for the inner region
|
||||
long templateSum = 0;
|
||||
int innerCount = 0;
|
||||
for (int ty = border; ty < templateH - border; ty++)
|
||||
for (int tx = border; tx < templateW - border; tx++)
|
||||
{
|
||||
templateSum += templateGray[ty * templateW + tx];
|
||||
innerCount++;
|
||||
}
|
||||
double tmplMean = innerCount > 0 ? (double)templateSum / innerCount : 0;
|
||||
|
||||
double diffThreshold = threshold > 0 ? threshold : 5;
|
||||
|
||||
if (debug) Log.Debug("Grid: {Cols}x{Rows}, cellW={CellW:F1}, cellH={CellH:F1}, border={Border}, threshold={Threshold}, tmplMean={TmplMean:F1}",
|
||||
cols, rows, cellW, cellH, border, diffThreshold, tmplMean);
|
||||
|
||||
var cells = new List<List<bool>>();
|
||||
for (int row = 0; row < rows; row++)
|
||||
{
|
||||
var rowList = new List<bool>();
|
||||
for (int col = 0; col < cols; col++)
|
||||
{
|
||||
int cx0 = (int)(col * cellW);
|
||||
int cy0 = (int)(row * cellH);
|
||||
int cw = (int)Math.Min(cellW, captureW - cx0);
|
||||
int ch = (int)Math.Min(cellH, bitmap.Height - cy0);
|
||||
|
||||
int innerW = Math.Min(cw, templateW) - border;
|
||||
int innerH = Math.Min(ch, templateH) - border;
|
||||
|
||||
long cellSum = 0;
|
||||
int compared = 0;
|
||||
for (int py = border; py < innerH; py++)
|
||||
for (int px = border; px < innerW; px++)
|
||||
{
|
||||
cellSum += captureGray[(cy0 + py) * captureW + (cx0 + px)];
|
||||
compared++;
|
||||
}
|
||||
double cellMean = compared > 0 ? (double)cellSum / compared : 0;
|
||||
double offset = cellMean - tmplMean;
|
||||
|
||||
long diffSum = 0;
|
||||
for (int py = border; py < innerH; py++)
|
||||
for (int px = border; px < innerW; px++)
|
||||
{
|
||||
double cellVal = captureGray[(cy0 + py) * captureW + (cx0 + px)];
|
||||
double tmplVal = templateGray[py * templateW + px];
|
||||
diffSum += (long)Math.Abs(cellVal - tmplVal - offset);
|
||||
}
|
||||
double meanDiff = compared > 0 ? (double)diffSum / compared : 0;
|
||||
bool occupied = meanDiff > diffThreshold;
|
||||
rowList.Add(occupied);
|
||||
}
|
||||
cells.Add(rowList);
|
||||
}
|
||||
|
||||
// Item detection: union-find on border pixel comparison
|
||||
int[] parent = new int[rows * cols];
|
||||
for (int i = 0; i < parent.Length; i++) parent[i] = i;
|
||||
|
||||
int Find(int x) { while (parent[x] != x) { parent[x] = parent[parent[x]]; x = parent[x]; } return x; }
|
||||
void Union(int a, int b) { parent[Find(a)] = Find(b); }
|
||||
|
||||
int stripWidth = Math.Max(2, border / 2);
|
||||
int stripInset = (int)(cellW * 0.15);
|
||||
double borderDiffThresh = 15.0;
|
||||
|
||||
for (int row = 0; row < rows; row++)
|
||||
{
|
||||
for (int col = 0; col < cols; col++)
|
||||
{
|
||||
if (!cells[row][col]) continue;
|
||||
int cx0 = (int)(col * cellW);
|
||||
int cy0 = (int)(row * cellH);
|
||||
|
||||
// Check right neighbor
|
||||
if (col + 1 < cols && cells[row][col + 1])
|
||||
{
|
||||
long diffSum = 0; int cnt = 0;
|
||||
int xStart = (int)((col + 1) * cellW) - stripWidth;
|
||||
int yFrom = cy0 + stripInset;
|
||||
int yTo = (int)((row + 1) * cellH) - stripInset;
|
||||
for (int sy = yFrom; sy < yTo; sy += 2)
|
||||
{
|
||||
int tmplY = sy - cy0;
|
||||
for (int sx = xStart; sx < xStart + stripWidth * 2; sx++)
|
||||
{
|
||||
if (sx < 0 || sx >= captureW) continue;
|
||||
int tmplX = sx - cx0;
|
||||
if (tmplX < 0 || tmplX >= templateW) continue;
|
||||
diffSum += Math.Abs(captureGray[sy * captureW + sx] - templateGray[tmplY * templateW + tmplX]);
|
||||
cnt++;
|
||||
}
|
||||
}
|
||||
double meanDiff = cnt > 0 ? (double)diffSum / cnt : 0;
|
||||
if (meanDiff > borderDiffThresh)
|
||||
Union(row * cols + col, row * cols + col + 1);
|
||||
}
|
||||
|
||||
// Check bottom neighbor
|
||||
if (row + 1 < rows && cells[row + 1][col])
|
||||
{
|
||||
long diffSum = 0; int cnt = 0;
|
||||
int yStart = (int)((row + 1) * cellH) - stripWidth;
|
||||
int xFrom = cx0 + stripInset;
|
||||
int xTo = (int)((col + 1) * cellW) - stripInset;
|
||||
for (int sx = xFrom; sx < xTo; sx += 2)
|
||||
{
|
||||
int tmplX = sx - cx0;
|
||||
for (int sy = yStart; sy < yStart + stripWidth * 2; sy++)
|
||||
{
|
||||
if (sy < 0 || sy >= bitmap.Height) continue;
|
||||
int tmplY = sy - cy0;
|
||||
if (tmplY < 0 || tmplY >= templateH) continue;
|
||||
diffSum += Math.Abs(captureGray[sy * captureW + sx] - templateGray[tmplY * templateW + tmplX]);
|
||||
cnt++;
|
||||
}
|
||||
}
|
||||
double meanDiff = cnt > 0 ? (double)diffSum / cnt : 0;
|
||||
if (meanDiff > borderDiffThresh)
|
||||
Union(row * cols + col, (row + 1) * cols + col);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract items from union-find groups
|
||||
var groups = new Dictionary<int, List<(int row, int col)>>();
|
||||
for (int row = 0; row < rows; row++)
|
||||
for (int col = 0; col < cols; col++)
|
||||
if (cells[row][col])
|
||||
{
|
||||
int root = Find(row * cols + col);
|
||||
if (!groups.ContainsKey(root)) groups[root] = [];
|
||||
groups[root].Add((row, col));
|
||||
}
|
||||
|
||||
var items = new List<GridItem>();
|
||||
foreach (var group in groups.Values)
|
||||
{
|
||||
int minR = group.Min(c => c.row);
|
||||
int maxR = group.Max(c => c.row);
|
||||
int minC = group.Min(c => c.col);
|
||||
int maxC = group.Max(c => c.col);
|
||||
items.Add(new GridItem { Row = minR, Col = minC, W = maxC - minC + 1, H = maxR - minR + 1 });
|
||||
}
|
||||
|
||||
// Visual matching
|
||||
List<GridMatch>? matches = null;
|
||||
int tRow = targetRow ?? -1;
|
||||
int tCol = targetCol ?? -1;
|
||||
if (tRow >= 0 && tCol >= 0 && tRow < rows && tCol < cols && cells[tRow][tCol])
|
||||
{
|
||||
matches = FindMatchingCells(
|
||||
captureGray, captureW, bitmap.Height,
|
||||
cells, rows, cols, cellW, cellH, border,
|
||||
tRow, tCol, debug);
|
||||
}
|
||||
|
||||
// Convert cells to bool[][]
|
||||
var cellsArr = cells.Select(r => r.ToArray()).ToArray();
|
||||
|
||||
return new GridScanResult { Cells = cellsArr, Items = items, Matches = matches };
|
||||
}
|
||||
|
||||
private List<GridMatch> FindMatchingCells(
|
||||
byte[] gray, int imgW, int imgH,
|
||||
List<List<bool>> cells, int rows, int cols,
|
||||
float cellW, float cellH, int border,
|
||||
int targetRow, int targetCol, bool debug)
|
||||
{
|
||||
int innerW = (int)cellW - border * 2;
|
||||
int innerH = (int)cellH - border * 2;
|
||||
if (innerW <= 4 || innerH <= 4) return [];
|
||||
|
||||
int tCx0 = (int)(targetCol * cellW) + border;
|
||||
int tCy0 = (int)(targetRow * cellH) + border;
|
||||
int tInnerW = Math.Min(innerW, imgW - tCx0);
|
||||
int tInnerH = Math.Min(innerH, imgH - tCy0);
|
||||
if (tInnerW < innerW || tInnerH < innerH) return [];
|
||||
|
||||
int n = innerW * innerH;
|
||||
|
||||
double[] targetPixels = new double[n];
|
||||
double tMean = 0;
|
||||
for (int py = 0; py < innerH; py++)
|
||||
for (int px = 0; px < innerW; px++)
|
||||
{
|
||||
double v = gray[(tCy0 + py) * imgW + (tCx0 + px)];
|
||||
targetPixels[py * innerW + px] = v;
|
||||
tMean += v;
|
||||
}
|
||||
tMean /= n;
|
||||
|
||||
double tStd = 0;
|
||||
for (int i = 0; i < n; i++)
|
||||
tStd += (targetPixels[i] - tMean) * (targetPixels[i] - tMean);
|
||||
tStd = Math.Sqrt(tStd / n);
|
||||
|
||||
if (tStd < 3.0) return [];
|
||||
|
||||
double matchThreshold = 0.70;
|
||||
var matches = new List<GridMatch>();
|
||||
|
||||
for (int row = 0; row < rows; row++)
|
||||
{
|
||||
for (int col = 0; col < cols; col++)
|
||||
{
|
||||
if (!cells[row][col]) continue;
|
||||
if (row == targetRow && col == targetCol) continue;
|
||||
|
||||
int cx0 = (int)(col * cellW) + border;
|
||||
int cy0 = (int)(row * cellH) + border;
|
||||
int cInnerW = Math.Min(innerW, imgW - cx0);
|
||||
int cInnerH = Math.Min(innerH, imgH - cy0);
|
||||
if (cInnerW < innerW || cInnerH < innerH) continue;
|
||||
|
||||
double cMean = 0;
|
||||
for (int py = 0; py < innerH; py++)
|
||||
for (int px = 0; px < innerW; px++)
|
||||
cMean += gray[(cy0 + py) * imgW + (cx0 + px)];
|
||||
cMean /= n;
|
||||
|
||||
double cStd = 0, cross = 0;
|
||||
for (int py = 0; py < innerH; py++)
|
||||
for (int px = 0; px < innerW; px++)
|
||||
{
|
||||
double cv = gray[(cy0 + py) * imgW + (cx0 + px)] - cMean;
|
||||
double tv = targetPixels[py * innerW + px] - tMean;
|
||||
cStd += cv * cv;
|
||||
cross += tv * cv;
|
||||
}
|
||||
cStd = Math.Sqrt(cStd / n);
|
||||
|
||||
double ncc = (tStd > 0 && cStd > 0) ? cross / (n * tStd * cStd) : 0;
|
||||
|
||||
if (ncc >= matchThreshold)
|
||||
matches.Add(new GridMatch { Row = row, Col = col, Similarity = Math.Round(ncc, 3) });
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
private void LoadTemplatesIfNeeded()
|
||||
{
|
||||
if (_emptyTemplate70Gray != null) return;
|
||||
|
||||
// Templates are in assets/ at project root (working directory)
|
||||
var t70Path = Path.Combine("assets", "empty70.png");
|
||||
var t35Path = Path.Combine("assets", "empty35.png");
|
||||
|
||||
if (File.Exists(t70Path))
|
||||
{
|
||||
using var bmp = new Bitmap(t70Path);
|
||||
_emptyTemplate70W = bmp.Width;
|
||||
_emptyTemplate70H = bmp.Height;
|
||||
(_emptyTemplate70Gray, _emptyTemplate70Argb, _emptyTemplate70Stride) = ImageUtils.BitmapToGrayAndArgb(bmp);
|
||||
}
|
||||
if (File.Exists(t35Path))
|
||||
{
|
||||
using var bmp = new Bitmap(t35Path);
|
||||
_emptyTemplate35W = bmp.Width;
|
||||
_emptyTemplate35H = bmp.Height;
|
||||
(_emptyTemplate35Gray, _emptyTemplate35Argb, _emptyTemplate35Stride) = ImageUtils.BitmapToGrayAndArgb(bmp);
|
||||
}
|
||||
}
|
||||
}
|
||||
136
src/Poe2Trade.Screen/GridReader.cs
Normal file
136
src/Poe2Trade.Screen/GridReader.cs
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
using Poe2Trade.Core;
|
||||
using Serilog;
|
||||
|
||||
namespace Poe2Trade.Screen;
|
||||
|
||||
public class GridLayout
|
||||
{
|
||||
public required Region Region { get; init; }
|
||||
public required int Cols { get; init; }
|
||||
public required int Rows { get; init; }
|
||||
}
|
||||
|
||||
public record CellCoord(int Row, int Col, int X, int Y);
|
||||
|
||||
public class ScanResult
|
||||
{
|
||||
public required GridLayout Layout { get; init; }
|
||||
public required List<CellCoord> Occupied { get; init; }
|
||||
public required List<GridItem> Items { get; init; }
|
||||
public List<GridMatch>? Matches { get; init; }
|
||||
}
|
||||
|
||||
public static class GridLayouts
|
||||
{
|
||||
public static readonly Dictionary<string, GridLayout> All = new()
|
||||
{
|
||||
["inventory"] = new GridLayout
|
||||
{
|
||||
Region = new Region(1696, 788, 840, 350),
|
||||
Cols = 12, Rows = 5
|
||||
},
|
||||
["stash12"] = new GridLayout
|
||||
{
|
||||
Region = new Region(23, 169, 840, 840),
|
||||
Cols = 12, Rows = 12
|
||||
},
|
||||
["stash12_folder"] = new GridLayout
|
||||
{
|
||||
Region = new Region(23, 216, 840, 840),
|
||||
Cols = 12, Rows = 12
|
||||
},
|
||||
["stash24"] = new GridLayout
|
||||
{
|
||||
Region = new Region(23, 169, 840, 840),
|
||||
Cols = 24, Rows = 24
|
||||
},
|
||||
["stash24_folder"] = new GridLayout
|
||||
{
|
||||
Region = new Region(23, 216, 840, 840),
|
||||
Cols = 24, Rows = 24
|
||||
},
|
||||
["seller"] = new GridLayout
|
||||
{
|
||||
Region = new Region(416, 299, 840, 840),
|
||||
Cols = 12, Rows = 12
|
||||
},
|
||||
["shop"] = new GridLayout
|
||||
{
|
||||
Region = new Region(23, 216, 840, 840),
|
||||
Cols = 12, Rows = 12
|
||||
},
|
||||
["vendor"] = new GridLayout
|
||||
{
|
||||
Region = new Region(416, 369, 840, 840),
|
||||
Cols = 12, Rows = 12
|
||||
},
|
||||
};
|
||||
|
||||
public static readonly GridLayout Inventory = All["inventory"];
|
||||
public static readonly GridLayout Seller = All["seller"];
|
||||
}
|
||||
|
||||
public class GridReader
|
||||
{
|
||||
private readonly GridHandler _handler;
|
||||
|
||||
public GridReader(GridHandler handler)
|
||||
{
|
||||
_handler = handler;
|
||||
}
|
||||
|
||||
public Task<ScanResult> Scan(string layoutName, int threshold = 0,
|
||||
int? targetRow = null, int? targetCol = null)
|
||||
{
|
||||
if (!GridLayouts.All.TryGetValue(layoutName, out var layout))
|
||||
throw new ArgumentException($"Unknown grid layout: {layoutName}");
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var result = _handler.Scan(layout.Region, layout.Cols, layout.Rows,
|
||||
threshold, targetRow, targetCol);
|
||||
|
||||
var occupied = new List<CellCoord>();
|
||||
for (var row = 0; row < result.Cells.Length; row++)
|
||||
for (var col = 0; col < result.Cells[row].Length; col++)
|
||||
{
|
||||
if (result.Cells[row][col])
|
||||
{
|
||||
var center = GetCellCenter(layout, row, col);
|
||||
occupied.Add(new CellCoord(row, col, center.X, center.Y));
|
||||
}
|
||||
}
|
||||
|
||||
Log.Information("Grid scan {Layout}: {Occupied} occupied, {Items} items, {Ms}ms",
|
||||
layoutName, occupied.Count, result.Items.Count, sw.ElapsedMilliseconds);
|
||||
|
||||
return Task.FromResult(new ScanResult
|
||||
{
|
||||
Layout = layout,
|
||||
Occupied = occupied,
|
||||
Items = result.Items,
|
||||
Matches = result.Matches
|
||||
});
|
||||
}
|
||||
|
||||
public (int X, int Y) GetCellCenter(GridLayout layout, int row, int col)
|
||||
{
|
||||
var cellW = (double)layout.Region.Width / layout.Cols;
|
||||
var cellH = (double)layout.Region.Height / layout.Rows;
|
||||
return (
|
||||
(int)Math.Round(layout.Region.X + col * cellW + cellW / 2),
|
||||
(int)Math.Round(layout.Region.Y + row * cellH + cellH / 2)
|
||||
);
|
||||
}
|
||||
|
||||
public List<CellCoord> GetAllCells(GridLayout layout)
|
||||
{
|
||||
var cells = new List<CellCoord>();
|
||||
for (var row = 0; row < layout.Rows; row++)
|
||||
for (var col = 0; col < layout.Cols; col++)
|
||||
{
|
||||
var center = GetCellCenter(layout, row, col);
|
||||
cells.Add(new CellCoord(row, col, center.X, center.Y));
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
}
|
||||
218
src/Poe2Trade.Screen/ImagePreprocessor.cs
Normal file
218
src/Poe2Trade.Screen/ImagePreprocessor.cs
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
namespace Poe2Trade.Screen;
|
||||
|
||||
using System.Drawing;
|
||||
using OpenCvSharp;
|
||||
using OpenCvSharp.Extensions;
|
||||
|
||||
static class ImagePreprocessor
|
||||
{
|
||||
/// <summary>
|
||||
/// Pre-process an image for OCR using morphological white top-hat filtering.
|
||||
/// Isolates bright tooltip text, suppresses dim background text visible through overlay.
|
||||
/// Pipeline: grayscale -> morphological top-hat -> Otsu binary -> upscale
|
||||
/// </summary>
|
||||
public static Bitmap PreprocessForOcr(Bitmap src, int kernelSize = 41, int upscale = 2)
|
||||
{
|
||||
using var mat = BitmapConverter.ToMat(src);
|
||||
using var gray = new Mat();
|
||||
Cv2.CvtColor(mat, gray, ColorConversionCodes.BGRA2GRAY);
|
||||
|
||||
// Morphological white top-hat: isolates bright text on dark background
|
||||
using var kernel = Cv2.GetStructuringElement(MorphShapes.Rect, new OpenCvSharp.Size(kernelSize, kernelSize));
|
||||
using var tophat = new Mat();
|
||||
Cv2.MorphologyEx(gray, tophat, MorphTypes.TopHat, kernel);
|
||||
|
||||
// Otsu binarization: automatic threshold, black text on white
|
||||
using var binary = new Mat();
|
||||
Cv2.Threshold(tophat, binary, 0, 255, ThresholdTypes.BinaryInv | ThresholdTypes.Otsu);
|
||||
|
||||
// Upscale for better LSTM recognition
|
||||
if (upscale > 1)
|
||||
{
|
||||
using var upscaled = new Mat();
|
||||
Cv2.Resize(binary, upscaled, new OpenCvSharp.Size(binary.Width * upscale, binary.Height * upscale),
|
||||
interpolation: InterpolationFlags.Cubic);
|
||||
return BitmapConverter.ToBitmap(upscaled);
|
||||
}
|
||||
|
||||
return BitmapConverter.ToBitmap(binary);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Background-subtraction preprocessing: uses the reference frame to remove
|
||||
/// background bleed-through from the semi-transparent tooltip overlay.
|
||||
/// Pipeline: estimate dimming factor -> subtract expected background -> threshold -> upscale
|
||||
/// Returns the upscaled binary Mat directly (caller must dispose).
|
||||
/// </summary>
|
||||
public static Mat PreprocessWithBackgroundSubMat(Bitmap tooltipCrop, Bitmap referenceCrop,
|
||||
int dimPercentile = 25, int textThresh = 30, int upscale = 2, bool softThreshold = true)
|
||||
{
|
||||
using var curMat = BitmapConverter.ToMat(tooltipCrop);
|
||||
using var refMat = BitmapConverter.ToMat(referenceCrop);
|
||||
using var curGray = new Mat();
|
||||
using var refGray = new Mat();
|
||||
Cv2.CvtColor(curMat, curGray, ColorConversionCodes.BGRA2GRAY);
|
||||
Cv2.CvtColor(refMat, refGray, ColorConversionCodes.BGRA2GRAY);
|
||||
|
||||
int rows = curGray.Rows, cols = curGray.Cols;
|
||||
|
||||
// Estimate the dimming factor of the tooltip overlay.
|
||||
var ratios = new List<double>();
|
||||
unsafe
|
||||
{
|
||||
byte* curPtr = (byte*)curGray.Data;
|
||||
byte* refPtr = (byte*)refGray.Data;
|
||||
int curStep = (int)curGray.Step();
|
||||
int refStep = (int)refGray.Step();
|
||||
|
||||
for (int y = 0; y < rows; y++)
|
||||
for (int x = 0; x < cols; x++)
|
||||
{
|
||||
byte r = refPtr[y * refStep + x];
|
||||
byte c = curPtr[y * curStep + x];
|
||||
if (r > 30)
|
||||
ratios.Add((double)c / r);
|
||||
}
|
||||
}
|
||||
|
||||
if (ratios.Count == 0)
|
||||
{
|
||||
using var fallbackBmp = PreprocessForOcr(tooltipCrop, 41, upscale);
|
||||
return BitmapConverter.ToMat(fallbackBmp);
|
||||
}
|
||||
|
||||
ratios.Sort();
|
||||
int idx = Math.Clamp(ratios.Count * dimPercentile / 100, 0, ratios.Count - 1);
|
||||
double dimFactor = ratios[idx];
|
||||
dimFactor = Math.Clamp(dimFactor, 0.05, 0.95);
|
||||
|
||||
// Subtract expected background: text_signal = current - reference * dimFactor
|
||||
using var textSignal = new Mat(rows, cols, MatType.CV_8UC1);
|
||||
unsafe
|
||||
{
|
||||
byte* curPtr = (byte*)curGray.Data;
|
||||
byte* refPtr = (byte*)refGray.Data;
|
||||
byte* outPtr = (byte*)textSignal.Data;
|
||||
int curStep = (int)curGray.Step();
|
||||
int refStep = (int)refGray.Step();
|
||||
int outStep = (int)textSignal.Step();
|
||||
|
||||
for (int y = 0; y < rows; y++)
|
||||
for (int x = 0; x < cols; x++)
|
||||
{
|
||||
double expected = refPtr[y * refStep + x] * dimFactor;
|
||||
double signal = curPtr[y * curStep + x] - expected;
|
||||
outPtr[y * outStep + x] = (byte)Math.Clamp(signal, 0, 255);
|
||||
}
|
||||
}
|
||||
|
||||
Mat result;
|
||||
if (softThreshold)
|
||||
{
|
||||
result = new Mat(rows, cols, MatType.CV_8UC1);
|
||||
unsafe
|
||||
{
|
||||
byte* srcPtr = (byte*)textSignal.Data;
|
||||
byte* dstPtr = (byte*)result.Data;
|
||||
int srcStep = (int)textSignal.Step();
|
||||
int dstStep = (int)result.Step();
|
||||
|
||||
int maxClipped = 1;
|
||||
for (int y = 0; y < rows; y++)
|
||||
for (int x = 0; x < cols; x++)
|
||||
{
|
||||
int val = srcPtr[y * srcStep + x] - textThresh;
|
||||
if (val > maxClipped) maxClipped = val;
|
||||
}
|
||||
|
||||
for (int y = 0; y < rows; y++)
|
||||
for (int x = 0; x < cols; x++)
|
||||
{
|
||||
int clipped = srcPtr[y * srcStep + x] - textThresh;
|
||||
if (clipped <= 0)
|
||||
{
|
||||
dstPtr[y * dstStep + x] = 255;
|
||||
}
|
||||
else
|
||||
{
|
||||
int stretched = clipped * 255 / maxClipped;
|
||||
dstPtr[y * dstStep + x] = (byte)(255 - stretched);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
result = new Mat();
|
||||
Cv2.Threshold(textSignal, result, textThresh, 255, ThresholdTypes.BinaryInv);
|
||||
}
|
||||
|
||||
using var _result = result;
|
||||
return UpscaleMat(result, upscale);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Background-subtraction preprocessing returning a Bitmap (convenience wrapper).
|
||||
/// </summary>
|
||||
public static Bitmap PreprocessWithBackgroundSub(Bitmap tooltipCrop, Bitmap referenceCrop,
|
||||
int dimPercentile = 25, int textThresh = 30, int upscale = 2, bool softThreshold = true)
|
||||
{
|
||||
using var mat = PreprocessWithBackgroundSubMat(tooltipCrop, referenceCrop, dimPercentile, textThresh, upscale, softThreshold);
|
||||
return BitmapConverter.ToBitmap(mat);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detect text lines via horizontal projection on a binary image.
|
||||
/// Binary should be inverted: text=black(0), background=white(255).
|
||||
/// Returns list of (yStart, yEnd) row ranges for each detected text line.
|
||||
/// </summary>
|
||||
public static List<(int yStart, int yEnd)> DetectTextLines(
|
||||
Mat binary, int minRowPixels = 2, int gapTolerance = 5)
|
||||
{
|
||||
int rows = binary.Rows, cols = binary.Cols;
|
||||
|
||||
var rowCounts = new int[rows];
|
||||
unsafe
|
||||
{
|
||||
byte* ptr = (byte*)binary.Data;
|
||||
int step = (int)binary.Step();
|
||||
for (int y = 0; y < rows; y++)
|
||||
for (int x = 0; x < cols; x++)
|
||||
if (ptr[y * step + x] < 128)
|
||||
rowCounts[y]++;
|
||||
}
|
||||
|
||||
var lines = new List<(int yStart, int yEnd)>();
|
||||
int lineStart = -1, lastActive = -1;
|
||||
for (int y = 0; y < rows; y++)
|
||||
{
|
||||
if (rowCounts[y] >= minRowPixels)
|
||||
{
|
||||
if (lineStart < 0) lineStart = y;
|
||||
lastActive = y;
|
||||
}
|
||||
else if (lineStart >= 0 && y - lastActive > gapTolerance)
|
||||
{
|
||||
lines.Add((lineStart, lastActive));
|
||||
lineStart = -1;
|
||||
}
|
||||
}
|
||||
if (lineStart >= 0)
|
||||
lines.Add((lineStart, lastActive));
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/// <summary>Returns a new Mat (caller must dispose). Does NOT dispose src.</summary>
|
||||
private static Mat UpscaleMat(Mat src, int factor)
|
||||
{
|
||||
if (factor > 1)
|
||||
{
|
||||
var upscaled = new Mat();
|
||||
Cv2.Resize(src, upscaled, new OpenCvSharp.Size(src.Width * factor, src.Height * factor),
|
||||
interpolation: InterpolationFlags.Cubic);
|
||||
return upscaled;
|
||||
}
|
||||
return src.Clone();
|
||||
}
|
||||
}
|
||||
39
src/Poe2Trade.Screen/ImageUtils.cs
Normal file
39
src/Poe2Trade.Screen/ImageUtils.cs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
namespace Poe2Trade.Screen;
|
||||
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using System.Runtime.InteropServices;
|
||||
using SdImageFormat = System.Drawing.Imaging.ImageFormat;
|
||||
|
||||
static class ImageUtils
|
||||
{
|
||||
public static (byte[] gray, byte[] argb, int stride) BitmapToGrayAndArgb(Bitmap bmp)
|
||||
{
|
||||
int w = bmp.Width, h = bmp.Height;
|
||||
var data = bmp.LockBits(new Rectangle(0, 0, w, h), ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
|
||||
byte[] argb = new byte[data.Stride * h];
|
||||
Marshal.Copy(data.Scan0, argb, 0, argb.Length);
|
||||
bmp.UnlockBits(data);
|
||||
int stride = data.Stride;
|
||||
|
||||
byte[] gray = new byte[w * h];
|
||||
for (int y = 0; y < h; y++)
|
||||
for (int x = 0; x < w; x++)
|
||||
{
|
||||
int i = y * stride + x * 4;
|
||||
gray[y * w + x] = (byte)((argb[i] + argb[i + 1] + argb[i + 2]) / 3);
|
||||
}
|
||||
return (gray, argb, stride);
|
||||
}
|
||||
|
||||
public static SdImageFormat GetImageFormat(string path)
|
||||
{
|
||||
var ext = Path.GetExtension(path).ToLowerInvariant();
|
||||
return ext switch
|
||||
{
|
||||
".jpg" or ".jpeg" => SdImageFormat.Jpeg,
|
||||
".bmp" => SdImageFormat.Bmp,
|
||||
_ => SdImageFormat.Png,
|
||||
};
|
||||
}
|
||||
}
|
||||
17
src/Poe2Trade.Screen/Poe2Trade.Screen.csproj
Normal file
17
src/Poe2Trade.Screen/Poe2Trade.Screen.csproj
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="OpenCvSharp4" Version="4.11.0.*" />
|
||||
<PackageReference Include="OpenCvSharp4.Extensions" Version="4.11.0.*" />
|
||||
<PackageReference Include="OpenCvSharp4.runtime.win" Version="4.11.0.*" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="8.0.12" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Poe2Trade.Core\Poe2Trade.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
175
src/Poe2Trade.Screen/PythonOcrBridge.cs
Normal file
175
src/Poe2Trade.Screen/PythonOcrBridge.cs
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
namespace Poe2Trade.Screen;
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Drawing;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Serilog;
|
||||
using SdImageFormat = System.Drawing.Imaging.ImageFormat;
|
||||
|
||||
/// <summary>
|
||||
/// Manages a persistent Python subprocess for EasyOCR.
|
||||
/// Lazy-starts on first request; reuses the process for subsequent calls.
|
||||
/// Same stdin/stdout JSON-per-line protocol.
|
||||
/// </summary>
|
||||
class PythonOcrBridge : IDisposable
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
private Process? _proc;
|
||||
private readonly string _daemonScript;
|
||||
private readonly string _pythonExe;
|
||||
private readonly object _lock = new();
|
||||
|
||||
public PythonOcrBridge()
|
||||
{
|
||||
// Resolve paths relative to working directory
|
||||
_daemonScript = Path.GetFullPath(Path.Combine("tools", "python-ocr", "daemon.py"));
|
||||
|
||||
var venvPython = Path.GetFullPath(Path.Combine("tools", "python-ocr", ".venv", "Scripts", "python.exe"));
|
||||
_pythonExe = File.Exists(venvPython) ? venvPython : "python";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Run OCR on a bitmap via the Python EasyOCR engine (base64 PNG over pipe).
|
||||
/// </summary>
|
||||
public OcrResponse OcrFromBitmap(Bitmap bitmap, OcrParams? ocrParams = null)
|
||||
{
|
||||
EnsureRunning();
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
bitmap.Save(ms, SdImageFormat.Png);
|
||||
var imageBase64 = Convert.ToBase64String(ms.ToArray());
|
||||
|
||||
var pyReq = BuildPythonRequest(ocrParams);
|
||||
pyReq["imageBase64"] = imageBase64;
|
||||
return SendPythonRequest(pyReq);
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> BuildPythonRequest(OcrParams? ocrParams)
|
||||
{
|
||||
var req = new Dictionary<string, object?> { ["cmd"] = "ocr", ["engine"] = "easyocr" };
|
||||
if (ocrParams == null) return req;
|
||||
|
||||
if (ocrParams.MergeGap > 0) req["mergeGap"] = ocrParams.MergeGap;
|
||||
if (ocrParams.LinkThreshold.HasValue) req["linkThreshold"] = ocrParams.LinkThreshold.Value;
|
||||
if (ocrParams.TextThreshold.HasValue) req["textThreshold"] = ocrParams.TextThreshold.Value;
|
||||
if (ocrParams.LowText.HasValue) req["lowText"] = ocrParams.LowText.Value;
|
||||
if (ocrParams.WidthThs.HasValue) req["widthThs"] = ocrParams.WidthThs.Value;
|
||||
if (ocrParams.Paragraph.HasValue) req["paragraph"] = ocrParams.Paragraph.Value;
|
||||
|
||||
return req;
|
||||
}
|
||||
|
||||
private OcrResponse SendPythonRequest(object pyReq)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(pyReq, JsonOptions);
|
||||
|
||||
string responseLine;
|
||||
lock (_lock)
|
||||
{
|
||||
_proc!.StandardInput.WriteLine(json);
|
||||
_proc.StandardInput.Flush();
|
||||
responseLine = _proc.StandardOutput.ReadLine()
|
||||
?? throw new Exception("Python daemon returned null");
|
||||
}
|
||||
|
||||
var resp = JsonSerializer.Deserialize<PythonResponse>(responseLine, JsonOptions);
|
||||
if (resp == null)
|
||||
throw new Exception("Failed to parse Python OCR response");
|
||||
if (!resp.Ok)
|
||||
throw new Exception(resp.Error ?? "Python OCR failed");
|
||||
|
||||
return new OcrResponse
|
||||
{
|
||||
Text = resp.Text ?? "",
|
||||
Lines = resp.Lines ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
private void EnsureRunning()
|
||||
{
|
||||
if (_proc != null && !_proc.HasExited)
|
||||
return;
|
||||
|
||||
_proc?.Dispose();
|
||||
_proc = null;
|
||||
|
||||
if (!File.Exists(_daemonScript))
|
||||
throw new Exception($"Python OCR daemon not found at {_daemonScript}");
|
||||
|
||||
Log.Information("Spawning Python OCR daemon: {Python} {Script}", _pythonExe, _daemonScript);
|
||||
|
||||
_proc = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = _pythonExe,
|
||||
Arguments = $"\"{_daemonScript}\"",
|
||||
UseShellExecute = false,
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
}
|
||||
};
|
||||
|
||||
_proc.ErrorDataReceived += (_, e) =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(e.Data))
|
||||
Log.Debug("[python-ocr] {Line}", e.Data);
|
||||
};
|
||||
|
||||
_proc.Start();
|
||||
_proc.BeginErrorReadLine();
|
||||
|
||||
// Wait for ready signal (up to 30s for first model load)
|
||||
var readyLine = _proc.StandardOutput.ReadLine();
|
||||
if (readyLine == null)
|
||||
throw new Exception("Python OCR daemon exited before ready signal");
|
||||
|
||||
var ready = JsonSerializer.Deserialize<PythonResponse>(readyLine, JsonOptions);
|
||||
if (ready?.Ready != true)
|
||||
throw new Exception($"Python OCR daemon did not send ready signal: {readyLine}");
|
||||
|
||||
Log.Information("Python OCR daemon ready");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_proc != null && !_proc.HasExited)
|
||||
{
|
||||
try
|
||||
{
|
||||
_proc.StandardInput.Close();
|
||||
_proc.WaitForExit(3000);
|
||||
if (!_proc.HasExited) _proc.Kill();
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
_proc?.Dispose();
|
||||
_proc = null;
|
||||
}
|
||||
|
||||
private class PythonResponse
|
||||
{
|
||||
[JsonPropertyName("ok")]
|
||||
public bool Ok { get; set; }
|
||||
|
||||
[JsonPropertyName("ready")]
|
||||
public bool? Ready { get; set; }
|
||||
|
||||
[JsonPropertyName("text")]
|
||||
public string? Text { get; set; }
|
||||
|
||||
[JsonPropertyName("lines")]
|
||||
public List<OcrLine>? Lines { get; set; }
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; set; }
|
||||
}
|
||||
}
|
||||
66
src/Poe2Trade.Screen/ScreenCapture.cs
Normal file
66
src/Poe2Trade.Screen/ScreenCapture.cs
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
namespace Poe2Trade.Screen;
|
||||
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using System.Runtime.InteropServices;
|
||||
using Region = Poe2Trade.Core.Region;
|
||||
|
||||
static class ScreenCapture
|
||||
{
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool SetProcessDPIAware();
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern int GetSystemMetrics(int nIndex);
|
||||
|
||||
public static void InitDpiAwareness() => SetProcessDPIAware();
|
||||
|
||||
/// <summary>
|
||||
/// Capture from screen, or load from file if specified.
|
||||
/// When file is set, loads the image and crops to region.
|
||||
/// </summary>
|
||||
public static Bitmap CaptureOrLoad(string? file, Region? region)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(file))
|
||||
{
|
||||
var fullBmp = new Bitmap(file);
|
||||
if (region != null)
|
||||
{
|
||||
int cx = Math.Max(0, region.X);
|
||||
int cy = Math.Max(0, region.Y);
|
||||
int cw = Math.Min(region.Width, fullBmp.Width - cx);
|
||||
int ch = Math.Min(region.Height, fullBmp.Height - cy);
|
||||
var cropped = fullBmp.Clone(new Rectangle(cx, cy, cw, ch), PixelFormat.Format32bppArgb);
|
||||
fullBmp.Dispose();
|
||||
return cropped;
|
||||
}
|
||||
return fullBmp;
|
||||
}
|
||||
return CaptureScreen(region);
|
||||
}
|
||||
|
||||
public static Bitmap CaptureScreen(Region? region)
|
||||
{
|
||||
int x, y, w, h;
|
||||
if (region != null)
|
||||
{
|
||||
x = region.X;
|
||||
y = region.Y;
|
||||
w = region.Width;
|
||||
h = region.Height;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Primary monitor only (0,0 origin, SM_CXSCREEN / SM_CYSCREEN)
|
||||
x = 0;
|
||||
y = 0;
|
||||
w = GetSystemMetrics(0); // SM_CXSCREEN
|
||||
h = GetSystemMetrics(1); // SM_CYSCREEN
|
||||
}
|
||||
|
||||
var bitmap = new Bitmap(w, h, PixelFormat.Format32bppArgb);
|
||||
using var g = Graphics.FromImage(bitmap);
|
||||
g.CopyFromScreen(x, y, 0, 0, new System.Drawing.Size(w, h), CopyPixelOperation.SourceCopy);
|
||||
return bitmap;
|
||||
}
|
||||
}
|
||||
259
src/Poe2Trade.Screen/ScreenReader.cs
Normal file
259
src/Poe2Trade.Screen/ScreenReader.cs
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
using Poe2Trade.Core;
|
||||
using OpenCvSharp.Extensions;
|
||||
using Serilog;
|
||||
|
||||
namespace Poe2Trade.Screen;
|
||||
|
||||
public class ScreenReader : IDisposable
|
||||
{
|
||||
private readonly DiffCropHandler _diffCrop = new();
|
||||
private readonly GridHandler _gridHandler = new();
|
||||
private readonly TemplateMatchHandler _templateMatch = new();
|
||||
private readonly EdgeCropHandler _edgeCrop = new();
|
||||
private readonly PythonOcrBridge _pythonBridge = new();
|
||||
private bool _initialized;
|
||||
|
||||
public GridReader Grid { get; }
|
||||
|
||||
public ScreenReader()
|
||||
{
|
||||
Grid = new GridReader(_gridHandler);
|
||||
}
|
||||
|
||||
public Task Warmup()
|
||||
{
|
||||
if (!_initialized)
|
||||
{
|
||||
ScreenCapture.InitDpiAwareness();
|
||||
_initialized = true;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// -- Capture --
|
||||
|
||||
public Task<byte[]> CaptureScreen()
|
||||
{
|
||||
return Task.FromResult(_diffCrop.HandleCapture());
|
||||
}
|
||||
|
||||
public Task<byte[]> CaptureRegion(Region region)
|
||||
{
|
||||
return Task.FromResult(_diffCrop.HandleCapture(region));
|
||||
}
|
||||
|
||||
// -- OCR --
|
||||
|
||||
public Task<OcrResponse> Ocr(Region? region = null, string? preprocess = null)
|
||||
{
|
||||
using var bitmap = ScreenCapture.CaptureOrLoad(null, region);
|
||||
|
||||
if (preprocess == "tophat")
|
||||
{
|
||||
using var processed = ImagePreprocessor.PreprocessForOcr(bitmap);
|
||||
return Task.FromResult(_pythonBridge.OcrFromBitmap(processed));
|
||||
}
|
||||
|
||||
return Task.FromResult(_pythonBridge.OcrFromBitmap(bitmap));
|
||||
}
|
||||
|
||||
public async Task<(int X, int Y)?> FindTextOnScreen(string searchText, bool fuzzy = false)
|
||||
{
|
||||
var result = await Ocr();
|
||||
var pos = FindWordInOcrResult(result, searchText, fuzzy);
|
||||
if (pos.HasValue)
|
||||
Log.Information("Found text '{Text}' at ({X},{Y})", searchText, pos.Value.X, pos.Value.Y);
|
||||
else
|
||||
Log.Information("Text '{Text}' not found on screen", searchText);
|
||||
return pos;
|
||||
}
|
||||
|
||||
public async Task<string> ReadFullScreen()
|
||||
{
|
||||
var result = await Ocr();
|
||||
return result.Text;
|
||||
}
|
||||
|
||||
public async Task<(int X, int Y)?> FindTextInRegion(Region region, string searchText)
|
||||
{
|
||||
var result = await Ocr(region);
|
||||
var pos = FindWordInOcrResult(result, searchText);
|
||||
if (pos.HasValue)
|
||||
return (region.X + pos.Value.X, region.Y + pos.Value.Y);
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<string> ReadRegionText(Region region)
|
||||
{
|
||||
var result = await Ocr(region);
|
||||
return result.Text;
|
||||
}
|
||||
|
||||
public async Task<bool> CheckForText(Region region, string searchText)
|
||||
{
|
||||
var pos = await FindTextInRegion(region, searchText);
|
||||
return pos.HasValue;
|
||||
}
|
||||
|
||||
// -- Snapshot / Diff OCR --
|
||||
|
||||
public Task Snapshot()
|
||||
{
|
||||
_diffCrop.HandleSnapshot();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<DiffOcrResponse> DiffOcr(string? savePath = null, Region? region = null)
|
||||
{
|
||||
var p = new DiffOcrParams();
|
||||
var cropResult = _diffCrop.DiffCrop(p.Crop, region: region);
|
||||
if (cropResult == null)
|
||||
return Task.FromResult(new DiffOcrResponse { Text = "", Lines = [] });
|
||||
|
||||
var (cropped, refCropped, current, cropRegion) = cropResult.Value;
|
||||
using var _current = current;
|
||||
using var _cropped = cropped;
|
||||
using var _refCropped = refCropped;
|
||||
|
||||
// Save raw crop if path is provided
|
||||
if (!string.IsNullOrEmpty(savePath))
|
||||
{
|
||||
var dir = Path.GetDirectoryName(savePath);
|
||||
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
cropped.Save(savePath, ImageUtils.GetImageFormat(savePath));
|
||||
}
|
||||
|
||||
// Preprocess with background subtraction
|
||||
var ocr = p.Ocr;
|
||||
using var processedBmp = ocr.UseBackgroundSub
|
||||
? ImagePreprocessor.PreprocessWithBackgroundSub(cropped, refCropped, ocr.DimPercentile, ocr.TextThresh, 1, ocr.SoftThreshold)
|
||||
: ImagePreprocessor.PreprocessForOcr(cropped, ocr.KernelSize, 1);
|
||||
|
||||
var ocrResult = _pythonBridge.OcrFromBitmap(processedBmp, ocr);
|
||||
|
||||
// Offset coordinates to screen space
|
||||
foreach (var line in ocrResult.Lines)
|
||||
foreach (var word in line.Words)
|
||||
{
|
||||
word.X += cropRegion.X;
|
||||
word.Y += cropRegion.Y;
|
||||
}
|
||||
|
||||
return Task.FromResult(new DiffOcrResponse
|
||||
{
|
||||
Text = ocrResult.Text,
|
||||
Lines = ocrResult.Lines,
|
||||
Region = cropRegion,
|
||||
});
|
||||
}
|
||||
|
||||
// -- Template matching --
|
||||
|
||||
public Task<TemplateMatchResult?> TemplateMatch(string templatePath, Region? region = null)
|
||||
{
|
||||
var result = _templateMatch.Match(templatePath, region);
|
||||
if (result != null)
|
||||
Log.Information("Template match found: ({X},{Y}) confidence={Conf:F3}", result.X, result.Y, result.Confidence);
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
// -- Save --
|
||||
|
||||
public Task SaveScreenshot(string path)
|
||||
{
|
||||
_diffCrop.HandleScreenshot(path);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SaveRegion(Region region, string path)
|
||||
{
|
||||
_diffCrop.HandleScreenshot(path, region);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Dispose() => _pythonBridge.Dispose();
|
||||
|
||||
// -- OCR text matching --
|
||||
|
||||
private static (int X, int Y)? FindWordInOcrResult(OcrResponse result, string needle, bool fuzzy = false)
|
||||
{
|
||||
var lower = needle.ToLowerInvariant();
|
||||
const double fuzzyThreshold = 0.55;
|
||||
|
||||
if (lower.Contains(' '))
|
||||
{
|
||||
var needleNorm = Normalize(needle);
|
||||
foreach (var line in result.Lines)
|
||||
{
|
||||
if (line.Words.Count == 0) continue;
|
||||
if (line.Text.ToLowerInvariant().Contains(lower))
|
||||
return LineBoundsCenter(line);
|
||||
|
||||
if (fuzzy)
|
||||
{
|
||||
var lineNorm = Normalize(line.Text);
|
||||
var windowLen = needleNorm.Length;
|
||||
for (var i = 0; i <= lineNorm.Length - windowLen + 2; i++)
|
||||
{
|
||||
var end = Math.Min(i + windowLen + 2, lineNorm.Length);
|
||||
var window = lineNorm[i..end];
|
||||
if (BigramSimilarity(needleNorm, window) >= fuzzyThreshold)
|
||||
return LineBoundsCenter(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
var needleN = Normalize(needle);
|
||||
foreach (var line in result.Lines)
|
||||
{
|
||||
foreach (var word in line.Words)
|
||||
{
|
||||
if (word.Text.ToLowerInvariant().Contains(lower))
|
||||
return (word.X + word.Width / 2, word.Y + word.Height / 2);
|
||||
|
||||
if (fuzzy && BigramSimilarity(needleN, Normalize(word.Text)) >= fuzzyThreshold)
|
||||
return (word.X + word.Width / 2, word.Y + word.Height / 2);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static (int X, int Y) LineBoundsCenter(OcrLine line)
|
||||
{
|
||||
var first = line.Words[0];
|
||||
var last = line.Words[^1];
|
||||
var x1 = first.X;
|
||||
var y1 = first.Y;
|
||||
var x2 = last.X + last.Width;
|
||||
var y2 = line.Words.Max(w => w.Y + w.Height);
|
||||
return ((x1 + x2) / 2, (y1 + y2) / 2);
|
||||
}
|
||||
|
||||
private static string Normalize(string s) =>
|
||||
new(s.ToLowerInvariant().Where(char.IsLetterOrDigit).ToArray());
|
||||
|
||||
private static double BigramSimilarity(string a, string b)
|
||||
{
|
||||
if (a.Length < 2 || b.Length < 2) return a == b ? 1 : 0;
|
||||
var bigramsA = new Dictionary<string, int>();
|
||||
for (var i = 0; i < a.Length - 1; i++)
|
||||
{
|
||||
var bg = a.Substring(i, 2);
|
||||
bigramsA[bg] = bigramsA.GetValueOrDefault(bg) + 1;
|
||||
}
|
||||
var matches = 0;
|
||||
for (var i = 0; i < b.Length - 1; i++)
|
||||
{
|
||||
var bg = b.Substring(i, 2);
|
||||
if (bigramsA.TryGetValue(bg, out var count) && count > 0)
|
||||
{
|
||||
matches++;
|
||||
bigramsA[bg] = count - 1;
|
||||
}
|
||||
}
|
||||
return 2.0 * matches / (a.Length - 1 + b.Length - 1);
|
||||
}
|
||||
}
|
||||
164
src/Poe2Trade.Screen/SignalProcessing.cs
Normal file
164
src/Poe2Trade.Screen/SignalProcessing.cs
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
namespace Poe2Trade.Screen;
|
||||
|
||||
static class SignalProcessing
|
||||
{
|
||||
/// <summary>
|
||||
/// Find the dominant period in a signal using autocorrelation.
|
||||
/// Returns (period, score) where score is the autocorrelation strength.
|
||||
/// </summary>
|
||||
public static (int period, double score) FindPeriodWithScore(double[] signal, int minPeriod, int maxPeriod)
|
||||
{
|
||||
int n = signal.Length;
|
||||
if (n < minPeriod * 3) return (-1, 0);
|
||||
|
||||
double mean = signal.Average();
|
||||
double variance = 0;
|
||||
for (int i = 0; i < n; i++)
|
||||
variance += (signal[i] - mean) * (signal[i] - mean);
|
||||
if (variance < 1.0) return (-1, 0);
|
||||
|
||||
int maxLag = Math.Min(maxPeriod, n / 3);
|
||||
double[] ac = new double[maxLag + 1];
|
||||
for (int lag = minPeriod; lag <= maxLag; lag++)
|
||||
{
|
||||
double sum = 0;
|
||||
for (int i = 0; i < n - lag; i++)
|
||||
sum += (signal[i] - mean) * (signal[i + lag] - mean);
|
||||
ac[lag] = sum / variance;
|
||||
}
|
||||
|
||||
for (int lag = minPeriod + 1; lag < maxLag; lag++)
|
||||
{
|
||||
if (ac[lag] > 0.01 && ac[lag] >= ac[lag - 1] && ac[lag] >= ac[lag + 1])
|
||||
return (lag, ac[lag]);
|
||||
}
|
||||
|
||||
return (-1, 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Find contiguous segments where values are ABOVE threshold.
|
||||
/// </summary>
|
||||
public static List<(int start, int end)> FindDarkDensitySegments(double[] profile, double threshold, int minLength)
|
||||
{
|
||||
var segments = new List<(int start, int end)>();
|
||||
int n = profile.Length;
|
||||
int curStart = -1;
|
||||
int maxGap = 5;
|
||||
int gapCount = 0;
|
||||
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
if (profile[i] >= threshold)
|
||||
{
|
||||
if (curStart < 0) curStart = i;
|
||||
gapCount = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (curStart >= 0)
|
||||
{
|
||||
gapCount++;
|
||||
if (gapCount > maxGap)
|
||||
{
|
||||
int end = i - gapCount;
|
||||
if (end - curStart >= minLength)
|
||||
segments.Add((curStart, end));
|
||||
curStart = -1;
|
||||
gapCount = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (curStart >= 0)
|
||||
{
|
||||
int end = gapCount > 0 ? n - gapCount : n;
|
||||
if (end - curStart >= minLength)
|
||||
segments.Add((curStart, end));
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Find the extent of the grid in a 1D profile using local autocorrelation.
|
||||
/// </summary>
|
||||
public static (int start, int end) FindGridExtent(double[] signal, int period)
|
||||
{
|
||||
int n = signal.Length;
|
||||
int halfWin = period * 2;
|
||||
if (n < halfWin * 2 + period) return (-1, -1);
|
||||
|
||||
double[] localAc = new double[n];
|
||||
for (int center = halfWin; center < n - halfWin; center++)
|
||||
{
|
||||
int wStart = center - halfWin;
|
||||
int wEnd = center + halfWin;
|
||||
int count = wEnd - wStart;
|
||||
|
||||
double sum = 0;
|
||||
for (int i = wStart; i < wEnd; i++)
|
||||
sum += signal[i];
|
||||
double mean = sum / count;
|
||||
|
||||
double varSum = 0;
|
||||
for (int i = wStart; i < wEnd; i++)
|
||||
varSum += (signal[i] - mean) * (signal[i] - mean);
|
||||
|
||||
if (varSum < 1.0) continue;
|
||||
|
||||
double acSum = 0;
|
||||
for (int i = wStart; i < wEnd - period; i++)
|
||||
acSum += (signal[i] - mean) * (signal[i + period] - mean);
|
||||
|
||||
localAc[center] = Math.Max(0, acSum / varSum);
|
||||
}
|
||||
|
||||
double maxAc = 0;
|
||||
for (int i = 0; i < n; i++)
|
||||
if (localAc[i] > maxAc) maxAc = localAc[i];
|
||||
if (maxAc < 0.02) return (-1, -1);
|
||||
|
||||
double threshold = maxAc * 0.25;
|
||||
|
||||
int bestStart = -1, bestEnd = -1, bestLen = 0;
|
||||
int curStartPos = -1;
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
if (localAc[i] > threshold)
|
||||
{
|
||||
if (curStartPos < 0) curStartPos = i;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (curStartPos >= 0)
|
||||
{
|
||||
int len = i - curStartPos;
|
||||
if (len > bestLen)
|
||||
{
|
||||
bestLen = len;
|
||||
bestStart = curStartPos;
|
||||
bestEnd = i;
|
||||
}
|
||||
curStartPos = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (curStartPos >= 0)
|
||||
{
|
||||
int len = n - curStartPos;
|
||||
if (len > bestLen)
|
||||
{
|
||||
bestStart = curStartPos;
|
||||
bestEnd = n;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestStart < 0) return (-1, -1);
|
||||
|
||||
bestStart = Math.Max(0, bestStart - period / 4);
|
||||
bestEnd = Math.Min(n - 1, bestEnd + period / 4);
|
||||
|
||||
return (bestStart, bestEnd);
|
||||
}
|
||||
}
|
||||
54
src/Poe2Trade.Screen/TemplateMatchHandler.cs
Normal file
54
src/Poe2Trade.Screen/TemplateMatchHandler.cs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
namespace Poe2Trade.Screen;
|
||||
|
||||
using System.Drawing;
|
||||
using OpenCvSharp;
|
||||
using OpenCvSharp.Extensions;
|
||||
using Region = Poe2Trade.Core.Region;
|
||||
|
||||
class TemplateMatchHandler
|
||||
{
|
||||
public TemplateMatchResult? Match(string templatePath, Region? region = null,
|
||||
string? file = null, double threshold = 0.7)
|
||||
{
|
||||
if (!System.IO.File.Exists(templatePath))
|
||||
throw new FileNotFoundException($"Template file not found: {templatePath}");
|
||||
|
||||
using var screenshot = ScreenCapture.CaptureOrLoad(file, region);
|
||||
using var screenMat = BitmapConverter.ToMat(screenshot);
|
||||
using var template = Cv2.ImRead(templatePath, ImreadModes.Color);
|
||||
|
||||
if (template.Empty())
|
||||
throw new InvalidOperationException($"Failed to load template image: {templatePath}");
|
||||
|
||||
// Convert screenshot from BGRA to BGR if needed
|
||||
using var screenBgr = new Mat();
|
||||
if (screenMat.Channels() == 4)
|
||||
Cv2.CvtColor(screenMat, screenBgr, ColorConversionCodes.BGRA2BGR);
|
||||
else
|
||||
screenMat.CopyTo(screenBgr);
|
||||
|
||||
// Template must fit within screenshot
|
||||
if (template.Rows > screenBgr.Rows || template.Cols > screenBgr.Cols)
|
||||
return null;
|
||||
|
||||
using var result = new Mat();
|
||||
Cv2.MatchTemplate(screenBgr, template, result, TemplateMatchModes.CCoeffNormed);
|
||||
|
||||
Cv2.MinMaxLoc(result, out _, out double maxVal, out _, out OpenCvSharp.Point maxLoc);
|
||||
|
||||
if (maxVal < threshold)
|
||||
return null;
|
||||
|
||||
int offsetX = region?.X ?? 0;
|
||||
int offsetY = region?.Y ?? 0;
|
||||
|
||||
return new TemplateMatchResult
|
||||
{
|
||||
X = offsetX + maxLoc.X + template.Cols / 2,
|
||||
Y = offsetY + maxLoc.Y + template.Rows / 2,
|
||||
Width = template.Cols,
|
||||
Height = template.Rows,
|
||||
Confidence = maxVal,
|
||||
};
|
||||
}
|
||||
}
|
||||
11
src/Poe2Trade.Trade/Poe2Trade.Trade.csproj
Normal file
11
src/Poe2Trade.Trade/Poe2Trade.Trade.csproj
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Playwright" Version="1.49.0" />
|
||||
<ProjectReference Include="..\Poe2Trade.Core\Poe2Trade.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
30
src/Poe2Trade.Trade/Selectors.cs
Normal file
30
src/Poe2Trade.Trade/Selectors.cs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
namespace Poe2Trade.Trade;
|
||||
|
||||
public static class Selectors
|
||||
{
|
||||
public const string LiveSearchButton =
|
||||
"button.livesearch-btn, button:has-text(\"Activate Live Search\")";
|
||||
|
||||
public const string ListingRow =
|
||||
".resultset .row, [class*=\"result\"]";
|
||||
|
||||
public static string ListingById(string id) => $"[data-id=\"{id}\"]";
|
||||
|
||||
public const string TravelToHideoutButton =
|
||||
"button:has-text(\"Travel to Hideout\"), button:has-text(\"Visit Hideout\"), a:has-text(\"Travel to Hideout\"), [class*=\"hideout\"]";
|
||||
|
||||
public const string WhisperButton =
|
||||
".whisper-btn, button[class*=\"whisper\"], [data-tooltip=\"Whisper\"], button:has-text(\"Whisper\")";
|
||||
|
||||
public const string ConfirmDialog =
|
||||
"[class*=\"modal\"], [class*=\"dialog\"], [class*=\"confirm\"]";
|
||||
|
||||
public const string ConfirmYesButton =
|
||||
"button:has-text(\"Yes\"), button:has-text(\"Confirm\"), button:has-text(\"OK\"), button:has-text(\"Accept\")";
|
||||
|
||||
public const string ConfirmNoButton =
|
||||
"button:has-text(\"No\"), button:has-text(\"Cancel\"), button:has-text(\"Decline\")";
|
||||
|
||||
public const string ResultsContainer =
|
||||
".resultset, [class*=\"results\"]";
|
||||
}
|
||||
296
src/Poe2Trade.Trade/TradeMonitor.cs
Normal file
296
src/Poe2Trade.Trade/TradeMonitor.cs
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
using System.Text.Json;
|
||||
using Microsoft.Playwright;
|
||||
using Poe2Trade.Core;
|
||||
using Serilog;
|
||||
|
||||
namespace Poe2Trade.Trade;
|
||||
|
||||
public class TradeMonitor : IAsyncDisposable
|
||||
{
|
||||
private IBrowserContext? _context;
|
||||
private readonly Dictionary<string, IPage> _pages = new();
|
||||
private readonly HashSet<string> _pausedSearches = new();
|
||||
private readonly AppConfig _config;
|
||||
|
||||
private const string StealthScript = """
|
||||
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
||||
Object.defineProperty(navigator, 'plugins', {
|
||||
get: () => [
|
||||
{ name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer' },
|
||||
{ name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai' },
|
||||
{ name: 'Native Client', filename: 'internal-nacl-plugin' },
|
||||
],
|
||||
});
|
||||
Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
|
||||
delete window.__playwright;
|
||||
delete window.__pw_manual;
|
||||
if (!window.chrome) window.chrome = {};
|
||||
if (!window.chrome.runtime) window.chrome.runtime = { id: undefined };
|
||||
const originalQuery = window.navigator.permissions?.query;
|
||||
if (originalQuery) {
|
||||
window.navigator.permissions.query = (params) => {
|
||||
if (params.name === 'notifications')
|
||||
return Promise.resolve({ state: Notification.permission });
|
||||
return originalQuery(params);
|
||||
};
|
||||
}
|
||||
""";
|
||||
|
||||
public event Action<string, List<string>, IPage>? NewListings;
|
||||
|
||||
public TradeMonitor(AppConfig config)
|
||||
{
|
||||
_config = config;
|
||||
}
|
||||
|
||||
public async Task Start(string? dashboardUrl = null)
|
||||
{
|
||||
Log.Information("Launching Playwright browser (stealth mode)...");
|
||||
|
||||
var playwright = await Playwright.CreateAsync();
|
||||
_context = await playwright.Chromium.LaunchPersistentContextAsync(
|
||||
_config.BrowserUserDataDir,
|
||||
new BrowserTypeLaunchPersistentContextOptions
|
||||
{
|
||||
Headless = false,
|
||||
ViewportSize = null,
|
||||
Args = [
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
"--disable-features=AutomationControlled",
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--disable-infobars",
|
||||
],
|
||||
IgnoreDefaultArgs = ["--enable-automation"],
|
||||
});
|
||||
|
||||
await _context.AddInitScriptAsync(StealthScript);
|
||||
|
||||
if (dashboardUrl != null)
|
||||
{
|
||||
var pages = _context.Pages;
|
||||
if (pages.Count > 0)
|
||||
await pages[0].GotoAsync(dashboardUrl);
|
||||
else
|
||||
await (await _context.NewPageAsync()).GotoAsync(dashboardUrl);
|
||||
Log.Information("Dashboard opened: {Url}", dashboardUrl);
|
||||
}
|
||||
|
||||
Log.Information("Browser launched (stealth active)");
|
||||
}
|
||||
|
||||
public async Task AddSearch(string tradeUrl)
|
||||
{
|
||||
if (_context == null) throw new InvalidOperationException("Browser not started");
|
||||
|
||||
var searchId = ExtractSearchId(tradeUrl);
|
||||
if (_pages.ContainsKey(searchId))
|
||||
{
|
||||
Log.Information("Search already open: {SearchId}", searchId);
|
||||
return;
|
||||
}
|
||||
|
||||
Log.Information("Adding trade search: {Url} ({SearchId})", tradeUrl, searchId);
|
||||
|
||||
var page = await _context.NewPageAsync();
|
||||
_pages[searchId] = page;
|
||||
|
||||
await page.GotoAsync(tradeUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
|
||||
await Helpers.Sleep(2000);
|
||||
|
||||
page.WebSocket += (_, ws) => HandleWebSocket(ws, searchId, page);
|
||||
|
||||
try
|
||||
{
|
||||
var liveBtn = page.Locator(Selectors.LiveSearchButton).First;
|
||||
await liveBtn.ClickAsync(new LocatorClickOptions { Timeout = 5000 });
|
||||
Log.Information("Live search activated: {SearchId}", searchId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
Log.Warning("Could not click Activate Live Search: {SearchId}", searchId);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task PauseSearch(string searchId)
|
||||
{
|
||||
_pausedSearches.Add(searchId);
|
||||
if (_pages.TryGetValue(searchId, out var page))
|
||||
{
|
||||
await page.CloseAsync();
|
||||
_pages.Remove(searchId);
|
||||
}
|
||||
Log.Information("Search paused: {SearchId}", searchId);
|
||||
}
|
||||
|
||||
public async Task<bool> ClickTravelToHideout(IPage page, string? itemId = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (itemId != null)
|
||||
{
|
||||
var row = page.Locator(Selectors.ListingById(itemId));
|
||||
if (await WaitForVisible(row, 5000))
|
||||
{
|
||||
var travelBtn = row.Locator(Selectors.TravelToHideoutButton).First;
|
||||
if (await WaitForVisible(travelBtn, 3000))
|
||||
{
|
||||
await travelBtn.ClickAsync();
|
||||
Log.Information("Clicked Travel to Hideout for item {ItemId}", itemId);
|
||||
await HandleConfirmDialog(page);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var btn = page.Locator(Selectors.TravelToHideoutButton).First;
|
||||
await btn.ClickAsync(new LocatorClickOptions { Timeout = 5000 });
|
||||
Log.Information("Clicked Travel to Hideout");
|
||||
await HandleConfirmDialog(page);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Failed to click Travel to Hideout");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(IPage Page, List<TradeItem> Items)> OpenScrapPage(string tradeUrl)
|
||||
{
|
||||
if (_context == null) throw new InvalidOperationException("Browser not started");
|
||||
|
||||
var page = await _context.NewPageAsync();
|
||||
var items = new List<TradeItem>();
|
||||
|
||||
page.Response += async (_, response) =>
|
||||
{
|
||||
if (!response.Url.Contains("/api/trade2/fetch/")) return;
|
||||
try
|
||||
{
|
||||
var body = await response.TextAsync();
|
||||
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(ParseTradeItem(r));
|
||||
}
|
||||
}
|
||||
catch { /* Response may not be JSON */ }
|
||||
};
|
||||
|
||||
await page.GotoAsync(tradeUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
|
||||
await Helpers.Sleep(2000);
|
||||
Log.Information("Scrap page opened: {Url} ({Count} items)", tradeUrl, items.Count);
|
||||
return (page, items);
|
||||
}
|
||||
|
||||
public string ExtractSearchId(string url)
|
||||
{
|
||||
var cleaned = System.Text.RegularExpressions.Regex.Replace(url, @"/live/?$", "");
|
||||
var parts = cleaned.Split('/');
|
||||
return parts.Length > 0 ? parts[^1] : url;
|
||||
}
|
||||
|
||||
public static TradeItem ParseTradeItem(JsonElement r)
|
||||
{
|
||||
var id = r.GetProperty("id").GetString() ?? "";
|
||||
int w = 1, h = 1, stashX = 0, stashY = 0;
|
||||
var account = "";
|
||||
|
||||
if (r.TryGetProperty("item", out var item))
|
||||
{
|
||||
if (item.TryGetProperty("w", out var wProp)) w = wProp.GetInt32();
|
||||
if (item.TryGetProperty("h", out var hProp)) h = hProp.GetInt32();
|
||||
}
|
||||
if (r.TryGetProperty("listing", out var listing))
|
||||
{
|
||||
if (listing.TryGetProperty("stash", out var stash))
|
||||
{
|
||||
if (stash.TryGetProperty("x", out var sx)) stashX = sx.GetInt32();
|
||||
if (stash.TryGetProperty("y", out var sy)) stashY = sy.GetInt32();
|
||||
}
|
||||
if (listing.TryGetProperty("account", out var acc) &&
|
||||
acc.TryGetProperty("name", out var accName))
|
||||
account = accName.GetString() ?? "";
|
||||
}
|
||||
return new TradeItem(id, w, h, stashX, stashY, account);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
foreach (var page in _pages.Values)
|
||||
await page.CloseAsync();
|
||||
_pages.Clear();
|
||||
if (_context != null)
|
||||
{
|
||||
await _context.CloseAsync();
|
||||
_context = null;
|
||||
}
|
||||
Log.Information("Trade monitor stopped");
|
||||
}
|
||||
|
||||
private void HandleWebSocket(IWebSocket ws, string searchId, IPage page)
|
||||
{
|
||||
if (!ws.Url.Contains("/api/trade") || !ws.Url.Contains("/live/"))
|
||||
return;
|
||||
|
||||
Log.Information("WebSocket connected for live search: {SearchId}", searchId);
|
||||
|
||||
ws.FrameReceived += (_, frame) =>
|
||||
{
|
||||
if (_pausedSearches.Contains(searchId)) return;
|
||||
try
|
||||
{
|
||||
var payload = frame.Text ?? "";
|
||||
using var doc = JsonDocument.Parse(payload);
|
||||
if (doc.RootElement.TryGetProperty("new", out var newItems) &&
|
||||
newItems.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var ids = newItems.EnumerateArray()
|
||||
.Select(e => e.GetString()!)
|
||||
.Where(s => s != null)
|
||||
.ToList();
|
||||
if (ids.Count > 0)
|
||||
{
|
||||
Log.Information("New listings: {SearchId} ({Count} items)", searchId, ids.Count);
|
||||
NewListings?.Invoke(searchId, ids, page);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { /* Not all frames are JSON */ }
|
||||
};
|
||||
|
||||
ws.Close += (_, _) => Log.Warning("WebSocket closed: {SearchId}", searchId);
|
||||
}
|
||||
|
||||
private async Task HandleConfirmDialog(IPage page)
|
||||
{
|
||||
await Helpers.Sleep(500);
|
||||
try
|
||||
{
|
||||
var confirmBtn = page.Locator(Selectors.ConfirmYesButton).First;
|
||||
if (await WaitForVisible(confirmBtn, 2000))
|
||||
{
|
||||
await confirmBtn.ClickAsync();
|
||||
Log.Information("Confirmed dialog");
|
||||
}
|
||||
}
|
||||
catch { /* No dialog */ }
|
||||
}
|
||||
|
||||
private static async Task<bool> WaitForVisible(ILocator locator, int timeoutMs)
|
||||
{
|
||||
try
|
||||
{
|
||||
await locator.WaitForAsync(new LocatorWaitForOptions
|
||||
{
|
||||
State = WaitForSelectorState.Visible,
|
||||
Timeout = timeoutMs
|
||||
});
|
||||
return true;
|
||||
}
|
||||
catch (TimeoutException) { return false; }
|
||||
}
|
||||
}
|
||||
17
src/Poe2Trade.Ui/App.axaml
Normal file
17
src/Poe2Trade.Ui/App.axaml
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<Application xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:conv="using:Poe2Trade.Ui.Converters"
|
||||
x:Class="Poe2Trade.Ui.App"
|
||||
RequestedThemeVariant="Dark">
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
</Application.Styles>
|
||||
<Application.Resources>
|
||||
<conv:LogLevelToBrushConverter x:Key="LogLevelBrush" />
|
||||
<conv:BoolToOccupiedBrushConverter x:Key="OccupiedBrush" />
|
||||
<conv:LinkModeToColorConverter x:Key="ModeBrush" />
|
||||
<conv:StatusDotBrushConverter x:Key="StatusDotBrush" />
|
||||
<conv:ActiveOpacityConverter x:Key="ActiveOpacity" />
|
||||
<conv:CellBorderConverter x:Key="CellBorderConverter" />
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
44
src/Poe2Trade.Ui/App.axaml.cs
Normal file
44
src/Poe2Trade.Ui/App.axaml.cs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Poe2Trade.Bot;
|
||||
using Poe2Trade.Core;
|
||||
using Poe2Trade.Ui.ViewModels;
|
||||
using Poe2Trade.Ui.Views;
|
||||
|
||||
namespace Poe2Trade.Ui;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
public override void Initialize()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
var store = new ConfigStore();
|
||||
var config = AppConfig.Load();
|
||||
var bot = new BotOrchestrator(store, config);
|
||||
|
||||
var mainVm = new MainWindowViewModel(bot)
|
||||
{
|
||||
DebugVm = new DebugViewModel(bot),
|
||||
SettingsVm = new SettingsViewModel(bot)
|
||||
};
|
||||
|
||||
var window = new MainWindow { DataContext = mainVm };
|
||||
window.SetConfigStore(store);
|
||||
desktop.MainWindow = window;
|
||||
|
||||
desktop.ShutdownRequested += async (_, _) =>
|
||||
{
|
||||
await bot.DisposeAsync();
|
||||
};
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
}
|
||||
99
src/Poe2Trade.Ui/Converters/ValueConverters.cs
Normal file
99
src/Poe2Trade.Ui/Converters/ValueConverters.cs
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
using System.Globalization;
|
||||
using Avalonia;
|
||||
using Avalonia.Data.Converters;
|
||||
using Avalonia.Media;
|
||||
using Poe2Trade.Core;
|
||||
using Poe2Trade.Ui.ViewModels;
|
||||
|
||||
namespace Poe2Trade.Ui.Converters;
|
||||
|
||||
public class LogLevelToBrushConverter : IValueConverter
|
||||
{
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
return value?.ToString()?.ToUpperInvariant() switch
|
||||
{
|
||||
"INFO" => new SolidColorBrush(Color.Parse("#58a6ff")),
|
||||
"WARN" or "WARNING" => new SolidColorBrush(Color.Parse("#d29922")),
|
||||
"ERROR" => new SolidColorBrush(Color.Parse("#f85149")),
|
||||
"DEBUG" => new SolidColorBrush(Color.Parse("#8b949e")),
|
||||
_ => new SolidColorBrush(Color.Parse("#e6edf3")),
|
||||
};
|
||||
}
|
||||
|
||||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public class BoolToOccupiedBrushConverter : IValueConverter
|
||||
{
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
var occupied = value is true;
|
||||
return new SolidColorBrush(occupied ? Color.Parse("#238636") : Color.Parse("#161b22"));
|
||||
}
|
||||
|
||||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public class LinkModeToColorConverter : IValueConverter
|
||||
{
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
LinkMode.Live => new SolidColorBrush(Color.Parse("#1f6feb")),
|
||||
LinkMode.Scrap => new SolidColorBrush(Color.Parse("#9e6a03")),
|
||||
_ => new SolidColorBrush(Color.Parse("#30363d")),
|
||||
};
|
||||
}
|
||||
|
||||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public class StatusDotBrushConverter : IValueConverter
|
||||
{
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
var state = value?.ToString() ?? "Idle";
|
||||
return state switch
|
||||
{
|
||||
"Idle" => new SolidColorBrush(Color.Parse("#8b949e")),
|
||||
"Paused" => new SolidColorBrush(Color.Parse("#d29922")),
|
||||
_ => new SolidColorBrush(Color.Parse("#3fb950")),
|
||||
};
|
||||
}
|
||||
|
||||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public class ActiveOpacityConverter : IValueConverter
|
||||
{
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> value is true ? 1.0 : 0.5;
|
||||
|
||||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public class CellBorderConverter : IValueConverter
|
||||
{
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is CellState cell)
|
||||
{
|
||||
return new Thickness(
|
||||
cell.BorderLeft ? 2 : 0,
|
||||
cell.BorderTop ? 2 : 0,
|
||||
cell.BorderRight ? 2 : 0,
|
||||
cell.BorderBottom ? 2 : 0);
|
||||
}
|
||||
return new Thickness(0);
|
||||
}
|
||||
|
||||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
19
src/Poe2Trade.Ui/Poe2Trade.Ui.csproj
Normal file
19
src/Poe2Trade.Ui/Poe2Trade.Ui.csproj
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="11.2.3" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.2.3" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.3" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Poe2Trade.Core\Poe2Trade.Core.csproj" />
|
||||
<ProjectReference Include="..\Poe2Trade.Bot\Poe2Trade.Bot.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
19
src/Poe2Trade.Ui/Program.cs
Normal file
19
src/Poe2Trade.Ui/Program.cs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
using Avalonia;
|
||||
using Poe2Trade.Core;
|
||||
|
||||
namespace Poe2Trade.Ui;
|
||||
|
||||
class Program
|
||||
{
|
||||
[STAThread]
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
Logging.Setup();
|
||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||
}
|
||||
|
||||
public static AppBuilder BuildAvaloniaApp()
|
||||
=> AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
.LogToTrace();
|
||||
}
|
||||
201
src/Poe2Trade.Ui/ViewModels/DebugViewModel.cs
Normal file
201
src/Poe2Trade.Ui/ViewModels/DebugViewModel.cs
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Poe2Trade.Bot;
|
||||
using Poe2Trade.Screen;
|
||||
using Serilog;
|
||||
|
||||
namespace Poe2Trade.Ui.ViewModels;
|
||||
|
||||
public partial class DebugViewModel : ObservableObject
|
||||
{
|
||||
private readonly BotOrchestrator _bot;
|
||||
|
||||
[ObservableProperty] private string _findText = "";
|
||||
[ObservableProperty] private string _debugResult = "";
|
||||
[ObservableProperty] private string _selectedGridLayout = "inventory";
|
||||
[ObservableProperty] private decimal? _clickX;
|
||||
[ObservableProperty] private decimal? _clickY;
|
||||
|
||||
public string[] GridLayoutNames { get; } =
|
||||
[
|
||||
"inventory", "stash12", "stash12_folder", "stash24",
|
||||
"stash24_folder", "seller", "shop", "vendor"
|
||||
];
|
||||
|
||||
public DebugViewModel(BotOrchestrator bot)
|
||||
{
|
||||
_bot = bot;
|
||||
}
|
||||
|
||||
private bool EnsureReady()
|
||||
{
|
||||
if (_bot.IsReady) return true;
|
||||
DebugResult = "Bot not started yet. Press Start first.";
|
||||
return false;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task TakeScreenshot()
|
||||
{
|
||||
if (!EnsureReady()) return;
|
||||
try
|
||||
{
|
||||
var path = Path.Combine("debug", $"screenshot-{DateTime.Now:yyyyMMdd-HHmmss}.png");
|
||||
Directory.CreateDirectory("debug");
|
||||
await _bot.Screen.SaveScreenshot(path);
|
||||
DebugResult = $"Screenshot saved: {path}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DebugResult = $"Screenshot failed: {ex.Message}";
|
||||
Log.Error(ex, "Screenshot failed");
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RunOcr()
|
||||
{
|
||||
if (!EnsureReady()) return;
|
||||
try
|
||||
{
|
||||
var text = await _bot.Screen.ReadFullScreen();
|
||||
DebugResult = string.IsNullOrWhiteSpace(text) ? "(no text detected)" : text;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DebugResult = $"OCR failed: {ex.Message}";
|
||||
Log.Error(ex, "OCR failed");
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task GoHideout()
|
||||
{
|
||||
if (!EnsureReady()) return;
|
||||
try
|
||||
{
|
||||
await _bot.Game.FocusGame();
|
||||
await _bot.Game.GoToHideout();
|
||||
DebugResult = "Sent /hideout command";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DebugResult = $"Go hideout failed: {ex.Message}";
|
||||
Log.Error(ex, "Go hideout failed");
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task FindTextOnScreen()
|
||||
{
|
||||
if (!EnsureReady() || string.IsNullOrWhiteSpace(FindText)) return;
|
||||
try
|
||||
{
|
||||
var pos = await _bot.Screen.FindTextOnScreen(FindText, fuzzy: true);
|
||||
DebugResult = pos.HasValue
|
||||
? $"Found '{FindText}' at ({pos.Value.X}, {pos.Value.Y})"
|
||||
: $"Text '{FindText}' not found";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DebugResult = $"Find text failed: {ex.Message}";
|
||||
Log.Error(ex, "Find text failed");
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task FindAndClick()
|
||||
{
|
||||
if (!EnsureReady() || string.IsNullOrWhiteSpace(FindText)) return;
|
||||
try
|
||||
{
|
||||
await _bot.Game.FocusGame();
|
||||
var pos = await _bot.Inventory.FindAndClickNameplate(FindText);
|
||||
DebugResult = pos.HasValue
|
||||
? $"Clicked '{FindText}' at ({pos.Value.X}, {pos.Value.Y})"
|
||||
: $"Text '{FindText}' not found";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DebugResult = $"Find & click failed: {ex.Message}";
|
||||
Log.Error(ex, "Find & click failed");
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ClickAt()
|
||||
{
|
||||
if (!EnsureReady()) return;
|
||||
var x = (int)(ClickX ?? 0);
|
||||
var y = (int)(ClickY ?? 0);
|
||||
try
|
||||
{
|
||||
await _bot.Game.FocusGame();
|
||||
await _bot.Game.LeftClickAt(x, y);
|
||||
DebugResult = $"Clicked at ({x}, {y})";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DebugResult = $"Click failed: {ex.Message}";
|
||||
Log.Error(ex, "Click at failed");
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ScanGrid()
|
||||
{
|
||||
if (!EnsureReady()) return;
|
||||
try
|
||||
{
|
||||
var result = await _bot.Screen.Grid.Scan(SelectedGridLayout);
|
||||
DebugResult = $"Grid scan '{SelectedGridLayout}': " +
|
||||
$"{result.Layout.Cols}x{result.Layout.Rows}, " +
|
||||
$"{result.Occupied.Count} occupied, " +
|
||||
$"{result.Items.Count} items";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DebugResult = $"Grid scan failed: {ex.Message}";
|
||||
Log.Error(ex, "Grid scan failed");
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ClickAnge()
|
||||
{
|
||||
if (!EnsureReady()) return;
|
||||
try
|
||||
{
|
||||
await _bot.Game.FocusGame();
|
||||
var pos = await _bot.Inventory.FindAndClickNameplate("ANGE");
|
||||
DebugResult = pos.HasValue ? $"Clicked ANGE at ({pos.Value.X}, {pos.Value.Y})" : "ANGE not found";
|
||||
}
|
||||
catch (Exception ex) { DebugResult = $"Failed: {ex.Message}"; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ClickStash()
|
||||
{
|
||||
if (!EnsureReady()) return;
|
||||
try
|
||||
{
|
||||
await _bot.Game.FocusGame();
|
||||
var pos = await _bot.Inventory.FindAndClickNameplate("STASH");
|
||||
DebugResult = pos.HasValue ? $"Clicked STASH at ({pos.Value.X}, {pos.Value.Y})" : "STASH not found";
|
||||
}
|
||||
catch (Exception ex) { DebugResult = $"Failed: {ex.Message}"; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ClickSalvage()
|
||||
{
|
||||
if (!EnsureReady()) return;
|
||||
try
|
||||
{
|
||||
await _bot.Game.FocusGame();
|
||||
var pos = await _bot.Inventory.FindAndClickNameplate("SALVAGE BENCH");
|
||||
DebugResult = pos.HasValue ? $"Clicked SALVAGE at ({pos.Value.X}, {pos.Value.Y})" : "SALVAGE BENCH not found";
|
||||
}
|
||||
catch (Exception ex) { DebugResult = $"Failed: {ex.Message}"; }
|
||||
}
|
||||
}
|
||||
183
src/Poe2Trade.Ui/ViewModels/MainWindowViewModel.cs
Normal file
183
src/Poe2Trade.Ui/ViewModels/MainWindowViewModel.cs
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Poe2Trade.Bot;
|
||||
using Poe2Trade.Core;
|
||||
using Serilog;
|
||||
|
||||
namespace Poe2Trade.Ui.ViewModels;
|
||||
|
||||
public class LogEntry
|
||||
{
|
||||
public string Time { get; init; } = "";
|
||||
public string Level { get; init; } = "";
|
||||
public string Message { get; init; } = "";
|
||||
}
|
||||
|
||||
public partial class CellState : ObservableObject
|
||||
{
|
||||
[ObservableProperty] private bool _isOccupied;
|
||||
[ObservableProperty] private bool _borderTop;
|
||||
[ObservableProperty] private bool _borderBottom;
|
||||
[ObservableProperty] private bool _borderLeft;
|
||||
[ObservableProperty] private bool _borderRight;
|
||||
}
|
||||
|
||||
public partial class MainWindowViewModel : ObservableObject
|
||||
{
|
||||
private readonly BotOrchestrator _bot;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _state = "Idle";
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(PauseButtonText))]
|
||||
private bool _isPaused;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(StartCommand))]
|
||||
[NotifyCanExecuteChangedFor(nameof(PauseCommand))]
|
||||
private bool _isStarted;
|
||||
|
||||
[ObservableProperty] private string _newUrl = "";
|
||||
[ObservableProperty] private string _newLinkName = "";
|
||||
[ObservableProperty] private LinkMode _newLinkMode = LinkMode.Live;
|
||||
[ObservableProperty] private int _tradesCompleted;
|
||||
[ObservableProperty] private int _tradesFailed;
|
||||
[ObservableProperty] private int _activeLinksCount;
|
||||
|
||||
public static LinkMode[] LinkModes { get; } = [LinkMode.Live, LinkMode.Scrap];
|
||||
|
||||
public MainWindowViewModel(BotOrchestrator bot)
|
||||
{
|
||||
_bot = bot;
|
||||
_isPaused = bot.IsPaused;
|
||||
|
||||
for (var i = 0; i < 60; i++)
|
||||
InventoryCells.Add(new CellState());
|
||||
|
||||
bot.StatusUpdated += () =>
|
||||
{
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
State = bot.State;
|
||||
IsPaused = bot.IsPaused;
|
||||
var status = bot.GetStatus();
|
||||
TradesCompleted = status.TradesCompleted;
|
||||
TradesFailed = status.TradesFailed;
|
||||
ActiveLinksCount = status.Links.Count(l => l.Active);
|
||||
OnPropertyChanged(nameof(Links));
|
||||
UpdateInventoryGrid();
|
||||
});
|
||||
};
|
||||
|
||||
bot.LogMessage += (level, message) =>
|
||||
{
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
Logs.Add(new LogEntry
|
||||
{
|
||||
Time = DateTime.Now.ToString("HH:mm:ss"),
|
||||
Level = level.ToUpperInvariant(),
|
||||
Message = message
|
||||
});
|
||||
if (Logs.Count > 500) Logs.RemoveAt(0);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
public string PauseButtonText => IsPaused ? "Resume" : "Pause";
|
||||
public List<TradeLink> Links => _bot.Links.GetLinks();
|
||||
public ObservableCollection<LogEntry> Logs { get; } = [];
|
||||
public ObservableCollection<CellState> InventoryCells { get; } = [];
|
||||
public int InventoryFreeCells => _bot.IsReady ? _bot.Inventory.Tracker.FreeCells : 60;
|
||||
|
||||
// Sub-ViewModels for tabs
|
||||
public DebugViewModel? DebugVm { get; set; }
|
||||
public SettingsViewModel? SettingsVm { get; set; }
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanStart))]
|
||||
private async Task Start()
|
||||
{
|
||||
try
|
||||
{
|
||||
await _bot.Start(_bot.Config.TradeUrls);
|
||||
IsStarted = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Failed to start bot");
|
||||
Logs.Add(new LogEntry
|
||||
{
|
||||
Time = DateTime.Now.ToString("HH:mm:ss"),
|
||||
Level = "ERROR",
|
||||
Message = $"Start failed: {ex.Message}"
|
||||
});
|
||||
}
|
||||
}
|
||||
private bool CanStart() => !IsStarted;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanPause))]
|
||||
private void Pause()
|
||||
{
|
||||
if (_bot.IsPaused) _bot.Resume();
|
||||
else _bot.Pause();
|
||||
}
|
||||
private bool CanPause() => IsStarted;
|
||||
|
||||
[RelayCommand]
|
||||
private void AddLink()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(NewUrl)) return;
|
||||
_bot.AddLink(NewUrl, NewLinkName, NewLinkMode);
|
||||
NewUrl = "";
|
||||
NewLinkName = "";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void RemoveLink(string? id)
|
||||
{
|
||||
if (id != null) _bot.RemoveLink(id);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ToggleLink(string? id)
|
||||
{
|
||||
if (id == null) return;
|
||||
var link = _bot.Links.GetLink(id);
|
||||
if (link != null) _bot.ToggleLink(id, !link.Active);
|
||||
}
|
||||
|
||||
private void UpdateInventoryGrid()
|
||||
{
|
||||
if (!_bot.IsReady) return;
|
||||
var (grid, items, _) = _bot.Inventory.GetInventoryState();
|
||||
|
||||
for (var r = 0; r < 5; r++)
|
||||
for (var c = 0; c < 12; c++)
|
||||
{
|
||||
var cell = InventoryCells[r * 12 + c];
|
||||
cell.IsOccupied = grid[r, c];
|
||||
cell.BorderTop = false;
|
||||
cell.BorderBottom = false;
|
||||
cell.BorderLeft = false;
|
||||
cell.BorderRight = false;
|
||||
}
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
for (var r = item.Row; r < item.Row + item.H; r++)
|
||||
for (var c = item.Col; c < item.Col + item.W; c++)
|
||||
{
|
||||
if (r >= 5 || c >= 12) continue;
|
||||
var cell = InventoryCells[r * 12 + c];
|
||||
if (r == item.Row) cell.BorderTop = true;
|
||||
if (r == item.Row + item.H - 1) cell.BorderBottom = true;
|
||||
if (c == item.Col) cell.BorderLeft = true;
|
||||
if (c == item.Col + item.W - 1) cell.BorderRight = true;
|
||||
}
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(InventoryFreeCells));
|
||||
}
|
||||
}
|
||||
65
src/Poe2Trade.Ui/ViewModels/SettingsViewModel.cs
Normal file
65
src/Poe2Trade.Ui/ViewModels/SettingsViewModel.cs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Poe2Trade.Bot;
|
||||
|
||||
namespace Poe2Trade.Ui.ViewModels;
|
||||
|
||||
public partial class SettingsViewModel : ObservableObject
|
||||
{
|
||||
private readonly BotOrchestrator _bot;
|
||||
|
||||
[ObservableProperty] private string _poe2LogPath = "";
|
||||
[ObservableProperty] private string _windowTitle = "";
|
||||
[ObservableProperty] private decimal? _travelTimeoutMs = 15000;
|
||||
[ObservableProperty] private decimal? _stashScanTimeoutMs = 10000;
|
||||
[ObservableProperty] private decimal? _waitForMoreItemsMs = 20000;
|
||||
[ObservableProperty] private decimal? _betweenTradesDelayMs = 5000;
|
||||
[ObservableProperty] private bool _isSaved;
|
||||
|
||||
public SettingsViewModel(BotOrchestrator bot)
|
||||
{
|
||||
_bot = bot;
|
||||
LoadFromConfig();
|
||||
}
|
||||
|
||||
private void LoadFromConfig()
|
||||
{
|
||||
var s = _bot.Store.Settings;
|
||||
Poe2LogPath = s.Poe2LogPath;
|
||||
WindowTitle = s.Poe2WindowTitle;
|
||||
TravelTimeoutMs = s.TravelTimeoutMs;
|
||||
StashScanTimeoutMs = s.StashScanTimeoutMs;
|
||||
WaitForMoreItemsMs = s.WaitForMoreItemsMs;
|
||||
BetweenTradesDelayMs = s.BetweenTradesDelayMs;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void SaveSettings()
|
||||
{
|
||||
_bot.Store.UpdateSettings(s =>
|
||||
{
|
||||
s.Poe2LogPath = Poe2LogPath;
|
||||
s.Poe2WindowTitle = WindowTitle;
|
||||
s.TravelTimeoutMs = (int)(TravelTimeoutMs ?? 15000);
|
||||
s.StashScanTimeoutMs = (int)(StashScanTimeoutMs ?? 10000);
|
||||
s.WaitForMoreItemsMs = (int)(WaitForMoreItemsMs ?? 20000);
|
||||
s.BetweenTradesDelayMs = (int)(BetweenTradesDelayMs ?? 5000);
|
||||
});
|
||||
|
||||
_bot.Config.Poe2LogPath = Poe2LogPath;
|
||||
_bot.Config.Poe2WindowTitle = WindowTitle;
|
||||
_bot.Config.TravelTimeoutMs = (int)(TravelTimeoutMs ?? 15000);
|
||||
_bot.Config.StashScanTimeoutMs = (int)(StashScanTimeoutMs ?? 10000);
|
||||
_bot.Config.WaitForMoreItemsMs = (int)(WaitForMoreItemsMs ?? 20000);
|
||||
_bot.Config.BetweenTradesDelayMs = (int)(BetweenTradesDelayMs ?? 5000);
|
||||
|
||||
IsSaved = true;
|
||||
}
|
||||
|
||||
partial void OnPoe2LogPathChanged(string value) => IsSaved = false;
|
||||
partial void OnWindowTitleChanged(string value) => IsSaved = false;
|
||||
partial void OnTravelTimeoutMsChanged(decimal? value) => IsSaved = false;
|
||||
partial void OnStashScanTimeoutMsChanged(decimal? value) => IsSaved = false;
|
||||
partial void OnWaitForMoreItemsMsChanged(decimal? value) => IsSaved = false;
|
||||
partial void OnBetweenTradesDelayMsChanged(decimal? value) => IsSaved = false;
|
||||
}
|
||||
329
src/Poe2Trade.Ui/Views/MainWindow.axaml
Normal file
329
src/Poe2Trade.Ui/Views/MainWindow.axaml
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:Poe2Trade.Ui.ViewModels"
|
||||
x:Class="Poe2Trade.Ui.Views.MainWindow"
|
||||
x:DataType="vm:MainWindowViewModel"
|
||||
Title="POE2 Trade Bot"
|
||||
Width="960" Height="720"
|
||||
Background="#0d1117">
|
||||
|
||||
<DockPanel Margin="12">
|
||||
|
||||
<!-- STATUS HEADER -->
|
||||
<Border DockPanel.Dock="Top" Padding="12" Margin="0,0,0,8"
|
||||
Background="#161b22" BorderBrush="#30363d"
|
||||
BorderThickness="1" CornerRadius="8">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<!-- Status dot + text -->
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="8"
|
||||
VerticalAlignment="Center">
|
||||
<Ellipse Width="10" Height="10"
|
||||
Fill="{Binding State, Converter={StaticResource StatusDotBrush}}" />
|
||||
<TextBlock Text="{Binding State}" FontWeight="SemiBold"
|
||||
Foreground="#e6edf3" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Stats cards -->
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="16"
|
||||
HorizontalAlignment="Center">
|
||||
<Border Background="#21262d" CornerRadius="6" Padding="16,8">
|
||||
<StackPanel HorizontalAlignment="Center">
|
||||
<TextBlock Text="{Binding ActiveLinksCount}"
|
||||
FontSize="20" FontWeight="Bold" Foreground="#58a6ff"
|
||||
HorizontalAlignment="Center" />
|
||||
<TextBlock Text="ACTIVE LINKS" FontSize="10" Foreground="#8b949e"
|
||||
HorizontalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Background="#21262d" CornerRadius="6" Padding="16,8">
|
||||
<StackPanel HorizontalAlignment="Center">
|
||||
<TextBlock Text="{Binding TradesCompleted}"
|
||||
FontSize="20" FontWeight="Bold" Foreground="#3fb950"
|
||||
HorizontalAlignment="Center" />
|
||||
<TextBlock Text="TRADES DONE" FontSize="10" Foreground="#8b949e"
|
||||
HorizontalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Background="#21262d" CornerRadius="6" Padding="16,8">
|
||||
<StackPanel HorizontalAlignment="Center">
|
||||
<TextBlock Text="{Binding TradesFailed}"
|
||||
FontSize="20" FontWeight="Bold" Foreground="#f85149"
|
||||
HorizontalAlignment="Center" />
|
||||
<TextBlock Text="FAILED" FontSize="10" Foreground="#8b949e"
|
||||
HorizontalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Controls -->
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="Start" Command="{Binding StartCommand}" />
|
||||
<Button Content="{Binding PauseButtonText}" Command="{Binding PauseCommand}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- TABBED CONTENT -->
|
||||
<TabControl>
|
||||
|
||||
<!-- ========== MAIN TAB ========== -->
|
||||
<TabItem Header="Main">
|
||||
<Grid RowDefinitions="Auto,*" Margin="0,8,0,0">
|
||||
|
||||
<!-- Inventory Grid (12x5) -->
|
||||
<Border Grid.Row="0" Background="#161b22" BorderBrush="#30363d"
|
||||
BorderThickness="1" CornerRadius="8" Padding="10" Margin="0,0,0,8">
|
||||
<DockPanel>
|
||||
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="0,0,0,6">
|
||||
<TextBlock Text="INVENTORY" FontSize="11" FontWeight="SemiBold"
|
||||
Foreground="#8b949e" />
|
||||
<TextBlock Text="{Binding InventoryFreeCells, StringFormat='{}{0}/60 free'}"
|
||||
FontSize="11" Foreground="#8b949e" Margin="12,0,0,0" />
|
||||
</StackPanel>
|
||||
<ItemsControl ItemsSource="{Binding InventoryCells}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<UniformGrid Columns="12" Rows="5" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:CellState">
|
||||
<Border Margin="1" CornerRadius="2" Height="22"
|
||||
Background="{Binding IsOccupied, Converter={StaticResource OccupiedBrush}}"
|
||||
BorderBrush="#3fb950" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Links + Logs split -->
|
||||
<Grid Grid.Row="1" ColumnDefinitions="350,*">
|
||||
|
||||
<!-- Left: Trade Links -->
|
||||
<Border Grid.Column="0" Background="#161b22" BorderBrush="#30363d"
|
||||
BorderThickness="1" CornerRadius="8" Padding="10" Margin="0,0,8,0">
|
||||
<DockPanel>
|
||||
<TextBlock DockPanel.Dock="Top" Text="TRADE LINKS"
|
||||
FontSize="11" FontWeight="SemiBold" Foreground="#8b949e"
|
||||
Margin="0,0,0,8" />
|
||||
|
||||
<!-- Add link form -->
|
||||
<StackPanel DockPanel.Dock="Top" Spacing="6" Margin="0,0,0,8">
|
||||
<TextBox Text="{Binding NewLinkName}" Watermark="Name (optional)" />
|
||||
<TextBox Text="{Binding NewUrl}" Watermark="Paste trade URL..." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<ComboBox ItemsSource="{x:Static vm:MainWindowViewModel.LinkModes}"
|
||||
SelectedItem="{Binding NewLinkMode}" Width="100" />
|
||||
<Button Content="Add" Command="{Binding AddLinkCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Links list -->
|
||||
<ScrollViewer>
|
||||
<ItemsControl ItemsSource="{Binding Links}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Margin="0,2" Padding="8" Background="#21262d"
|
||||
CornerRadius="4"
|
||||
Opacity="{Binding Active, Converter={StaticResource ActiveOpacity}}">
|
||||
<DockPanel>
|
||||
<Button DockPanel.Dock="Right" Content="X" FontSize="10"
|
||||
VerticalAlignment="Center"
|
||||
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).RemoveLinkCommand}"
|
||||
CommandParameter="{Binding Id}" />
|
||||
<CheckBox DockPanel.Dock="Left"
|
||||
IsChecked="{Binding Active}"
|
||||
Margin="0,0,8,0" VerticalAlignment="Center" />
|
||||
<StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Border Background="{Binding Mode, Converter={StaticResource ModeBrush}}"
|
||||
CornerRadius="4" Padding="6,2">
|
||||
<TextBlock Text="{Binding Mode}"
|
||||
FontSize="10" FontWeight="Bold"
|
||||
Foreground="White" />
|
||||
</Border>
|
||||
<TextBlock Text="{Binding Name}" FontSize="12"
|
||||
FontWeight="SemiBold" Foreground="#e6edf3" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding Label}" FontSize="10"
|
||||
Foreground="#8b949e" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Right: Logs -->
|
||||
<Border Grid.Column="1" Background="#161b22" BorderBrush="#30363d"
|
||||
BorderThickness="1" CornerRadius="8" Padding="10">
|
||||
<DockPanel>
|
||||
<TextBlock DockPanel.Dock="Top" Text="ACTIVITY LOG"
|
||||
FontSize="11" FontWeight="SemiBold" Foreground="#8b949e"
|
||||
Margin="0,0,0,8" />
|
||||
<ListBox ItemsSource="{Binding Logs}" x:Name="LogList"
|
||||
Background="Transparent" BorderThickness="0">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:LogEntry">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Text="{Binding Time}" Foreground="#484f58"
|
||||
FontSize="11" FontFamily="Consolas" />
|
||||
<TextBlock Text="{Binding Message}" FontSize="11"
|
||||
FontFamily="Consolas" TextWrapping="Wrap"
|
||||
Foreground="{Binding Level, Converter={StaticResource LogLevelBrush}}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</TabItem>
|
||||
|
||||
<!-- ========== DEBUG TAB ========== -->
|
||||
<TabItem Header="Debug">
|
||||
<ScrollViewer DataContext="{Binding DebugVm}" Margin="0,8,0,0">
|
||||
<StackPanel Spacing="12" Margin="8" x:DataType="vm:DebugViewModel">
|
||||
|
||||
<!-- Row 1: Quick actions -->
|
||||
<Border Background="#161b22" BorderBrush="#30363d" BorderThickness="1"
|
||||
CornerRadius="8" Padding="12">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="QUICK ACTIONS" FontSize="11" FontWeight="SemiBold"
|
||||
Foreground="#8b949e" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="Screenshot" Command="{Binding TakeScreenshotCommand}" />
|
||||
<Button Content="OCR Screen" Command="{Binding RunOcrCommand}" />
|
||||
<Button Content="Go Hideout" Command="{Binding GoHideoutCommand}" />
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="ANGE" Command="{Binding ClickAngeCommand}" />
|
||||
<Button Content="STASH" Command="{Binding ClickStashCommand}" />
|
||||
<Button Content="SALVAGE" Command="{Binding ClickSalvageCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Row 2: Find text -->
|
||||
<Border Background="#161b22" BorderBrush="#30363d" BorderThickness="1"
|
||||
CornerRadius="8" Padding="12">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="FIND TEXT" FontSize="11" FontWeight="SemiBold"
|
||||
Foreground="#8b949e" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBox Text="{Binding FindText}" Watermark="Text to find..."
|
||||
Width="300" />
|
||||
<Button Content="Find" Command="{Binding FindTextOnScreenCommand}" />
|
||||
<Button Content="Find & Click" Command="{Binding FindAndClickCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Row 3: Grid scan -->
|
||||
<Border Background="#161b22" BorderBrush="#30363d" BorderThickness="1"
|
||||
CornerRadius="8" Padding="12">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="GRID SCAN" FontSize="11" FontWeight="SemiBold"
|
||||
Foreground="#8b949e" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<ComboBox ItemsSource="{Binding GridLayoutNames}"
|
||||
SelectedItem="{Binding SelectedGridLayout}" Width="160" />
|
||||
<Button Content="Scan" Command="{Binding ScanGridCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Row 4: Click At -->
|
||||
<Border Background="#161b22" BorderBrush="#30363d" BorderThickness="1"
|
||||
CornerRadius="8" Padding="12">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="CLICK AT POSITION" FontSize="11" FontWeight="SemiBold"
|
||||
Foreground="#8b949e" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<NumericUpDown Value="{Binding ClickX}" Watermark="X"
|
||||
Width="100" Minimum="0" Maximum="2560" />
|
||||
<NumericUpDown Value="{Binding ClickY}" Watermark="Y"
|
||||
Width="100" Minimum="0" Maximum="1440" />
|
||||
<Button Content="Click" Command="{Binding ClickAtCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Debug result output -->
|
||||
<Border Background="#161b22" BorderBrush="#30363d" BorderThickness="1"
|
||||
CornerRadius="8" Padding="12" MinHeight="60">
|
||||
<StackPanel>
|
||||
<TextBlock Text="OUTPUT" FontSize="11" FontWeight="SemiBold"
|
||||
Foreground="#8b949e" Margin="0,0,0,6" />
|
||||
<TextBlock Text="{Binding DebugResult}" FontFamily="Consolas"
|
||||
FontSize="11" Foreground="#e6edf3" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
<!-- ========== SETTINGS TAB ========== -->
|
||||
<TabItem Header="Settings">
|
||||
<ScrollViewer DataContext="{Binding SettingsVm}" Margin="0,8,0,0">
|
||||
<StackPanel Spacing="12" Margin="8" MaxWidth="600"
|
||||
x:DataType="vm:SettingsViewModel">
|
||||
|
||||
<Border Background="#161b22" BorderBrush="#30363d" BorderThickness="1"
|
||||
CornerRadius="8" Padding="16">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="GENERAL SETTINGS" FontSize="11" FontWeight="SemiBold"
|
||||
Foreground="#8b949e" />
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="POE2 Client.txt Path" FontSize="11" Foreground="#8b949e" />
|
||||
<TextBox Text="{Binding Poe2LogPath}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Window Title" FontSize="11" Foreground="#8b949e" />
|
||||
<TextBox Text="{Binding WindowTitle}" />
|
||||
</StackPanel>
|
||||
|
||||
<Grid ColumnDefinitions="*,*" RowDefinitions="Auto,Auto">
|
||||
<StackPanel Grid.Row="0" Grid.Column="0" Spacing="4" Margin="0,0,6,8">
|
||||
<TextBlock Text="Travel Timeout (ms)" FontSize="11" Foreground="#8b949e" />
|
||||
<NumericUpDown Value="{Binding TravelTimeoutMs}" Minimum="1000"
|
||||
Maximum="60000" Increment="1000" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Row="0" Grid.Column="1" Spacing="4" Margin="6,0,0,8">
|
||||
<TextBlock Text="Stash Scan Timeout (ms)" FontSize="11" Foreground="#8b949e" />
|
||||
<NumericUpDown Value="{Binding StashScanTimeoutMs}" Minimum="1000"
|
||||
Maximum="60000" Increment="1000" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Row="1" Grid.Column="0" Spacing="4" Margin="0,0,6,0">
|
||||
<TextBlock Text="Wait for More Items (ms)" FontSize="11" Foreground="#8b949e" />
|
||||
<NumericUpDown Value="{Binding WaitForMoreItemsMs}" Minimum="1000"
|
||||
Maximum="120000" Increment="1000" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Row="1" Grid.Column="1" Spacing="4" Margin="6,0,0,0">
|
||||
<TextBlock Text="Delay Between Trades (ms)" FontSize="11" Foreground="#8b949e" />
|
||||
<NumericUpDown Value="{Binding BetweenTradesDelayMs}" Minimum="0"
|
||||
Maximum="60000" Increment="1000" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,4,0,0">
|
||||
<Button Content="Save Settings" Command="{Binding SaveSettingsCommand}" />
|
||||
<TextBlock Text="Saved!" Foreground="#3fb950" VerticalAlignment="Center"
|
||||
IsVisible="{Binding IsSaved}" FontWeight="SemiBold" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
</TabControl>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
69
src/Poe2Trade.Ui/Views/MainWindow.axaml.cs
Normal file
69
src/Poe2Trade.Ui/Views/MainWindow.axaml.cs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
using System.Collections.Specialized;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Poe2Trade.Core;
|
||||
using Poe2Trade.Ui.ViewModels;
|
||||
|
||||
namespace Poe2Trade.Ui.Views;
|
||||
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
private ConfigStore? _store;
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
public void SetConfigStore(ConfigStore store)
|
||||
{
|
||||
_store = store;
|
||||
var s = store.Settings;
|
||||
if (s.WindowWidth.HasValue && s.WindowHeight.HasValue)
|
||||
{
|
||||
Width = s.WindowWidth.Value;
|
||||
Height = s.WindowHeight.Value;
|
||||
}
|
||||
if (s.WindowX.HasValue && s.WindowY.HasValue)
|
||||
{
|
||||
Position = new PixelPoint((int)s.WindowX.Value, (int)s.WindowY.Value);
|
||||
WindowStartupLocation = WindowStartupLocation.Manual;
|
||||
}
|
||||
else
|
||||
{
|
||||
WindowStartupLocation = WindowStartupLocation.CenterScreen;
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnDataContextChanged(EventArgs e)
|
||||
{
|
||||
base.OnDataContextChanged(e);
|
||||
if (DataContext is MainWindowViewModel vm)
|
||||
{
|
||||
vm.Logs.CollectionChanged += (_, args) =>
|
||||
{
|
||||
if (args.Action == NotifyCollectionChangedAction.Add)
|
||||
{
|
||||
var logList = this.FindControl<ListBox>("LogList");
|
||||
if (logList != null && vm.Logs.Count > 0)
|
||||
logList.ScrollIntoView(vm.Logs[^1]);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnClosing(WindowClosingEventArgs e)
|
||||
{
|
||||
if (_store != null)
|
||||
{
|
||||
_store.UpdateSettings(s =>
|
||||
{
|
||||
s.WindowX = Position.X;
|
||||
s.WindowY = Position.Y;
|
||||
s.WindowWidth = Width;
|
||||
s.WindowHeight = Height;
|
||||
});
|
||||
}
|
||||
base.OnClosing(e);
|
||||
}
|
||||
}
|
||||
10
src/Poe2Trade.Ui/app.manifest
Normal file
10
src/Poe2Trade.Ui/app.manifest
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="Poe2Trade"/>
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
</assembly>
|
||||
Loading…
Add table
Add a link
Reference in a new issue