This commit is contained in:
Boki 2026-02-26 20:45:24 -05:00
parent 53641fc8e7
commit 657d307485
28 changed files with 2045 additions and 161 deletions

BIN
assets/merchant.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7 MiB

After

Width:  |  Height:  |  Size: 7.1 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6 MiB

After

Width:  |  Height:  |  Size: 5.9 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 319 KiB

After

Width:  |  Height:  |  Size: 344 KiB

Before After
Before After

View file

@ -0,0 +1,225 @@
using Poe2Trade.Core;
using Poe2Trade.Game;
using Poe2Trade.Inventory;
using Poe2Trade.Navigation;
using Poe2Trade.Screen;
using Serilog;
namespace Poe2Trade.Bot;
/// <summary>
/// Captures the full endgame atlas as a panorama image.
/// Registers AtlasPanorama as a pipeline consumer so it shares the single DXGI capture.
/// </summary>
public class AtlasExecutor : GameExecutor
{
private readonly FramePipeline _pipeline;
public event Action<AtlasProgress>? ProgressUpdated;
public event Action<string>? StateChanged;
public AtlasPanorama? ActivePanorama { get; private set; }
/// <summary>
/// Perspective correction factor. Updated by CalibratePerspective(), used by CaptureAtlasPanorama().
/// </summary>
public float PerspectiveFactor { get; set; } = 0.115f;
private string _state = "Idle";
public string State => _state;
public AtlasExecutor(IGameController game, IScreenReader screen,
IInventoryManager inventory, SavedSettings config, FramePipeline pipeline)
: base(game, screen, inventory, config)
{
_pipeline = pipeline;
}
private void SetState(string s)
{
_state = s;
StateChanged?.Invoke(s);
}
/// <summary>
/// Capture the full endgame atlas panorama.
/// The atlas map must already be open — just focuses the game and starts capturing.
/// Returns the path to the saved PNG, or null on failure.
/// </summary>
public async Task<string?> CaptureAtlasPanorama(string outputDir = "atlas")
{
ResetStop();
var panorama = new AtlasPanorama(PerspectiveFactor);
panorama.ProgressUpdated += p => ProgressUpdated?.Invoke(p);
ActivePanorama = panorama;
_pipeline.AddConsumer(panorama);
try
{
await _game.FocusGame();
await Sleep(300);
SetState("Capturing");
Log.Information("AtlasExecutor: starting panorama capture (factor={F:F3})", PerspectiveFactor);
await panorama.Run(StopToken);
// Save result
if (!Directory.Exists(outputDir))
Directory.CreateDirectory(outputDir);
var timestamp = DateTime.Now.ToString("yyyyMMdd-HHmmss");
var path = Path.Combine(outputDir, $"atlas-{timestamp}.png");
panorama.SaveResult(path);
SetState("Idle");
Log.Information("AtlasExecutor: panorama saved to {Path}", path);
return path;
}
catch (OperationCanceledException)
{
Log.Information("AtlasExecutor: capture cancelled");
SetState("Idle");
return null;
}
catch (Exception ex)
{
Log.Error(ex, "AtlasExecutor: capture failed");
SetState("Failed");
return null;
}
finally
{
_pipeline.RemoveConsumer(panorama);
ActivePanorama = null;
panorama.Dispose();
}
}
/// <summary>
/// Auto-scroll the atlas in 4 directions, collect frames, and test different
/// perspective factors to find the optimal one. Updates PerspectiveFactor on success.
/// </summary>
public async Task<CalibrationResult?> CalibratePerspective()
{
ResetStop();
var calibrator = new PerspectiveCalibrator();
calibrator.FrameCollected += n =>
ProgressUpdated?.Invoke(new AtlasProgress(n, 0, "Collecting"));
_pipeline.AddConsumer(calibrator);
try
{
await _game.FocusGame();
await Sleep(300);
SetState("Calibrating");
Log.Information("AtlasExecutor: calibration started — auto-scrolling atlas");
// Auto-scroll in 4 directions via click-drag
await AutoScrollCircle(calibrator);
_pipeline.RemoveConsumer(calibrator);
if (calibrator.FramesCollected < 3)
{
Log.Warning("AtlasExecutor: not enough frames for calibration ({N})", calibrator.FramesCollected);
SetState("Idle");
return null;
}
SetState("Analyzing");
ProgressUpdated?.Invoke(new AtlasProgress(calibrator.FramesCollected, 0, "Analyzing"));
var result = calibrator.Calibrate();
PerspectiveFactor = result.BestFactor;
SetState("Idle");
Log.Information("AtlasExecutor: calibration complete — factor={F:F3} conf={C:F4}",
result.BestFactor, result.BestConfidence);
return result;
}
catch (OperationCanceledException)
{
_pipeline.RemoveConsumer(calibrator);
// Still try to calibrate with whatever frames we got
if (calibrator.FramesCollected >= 3)
{
SetState("Analyzing");
var result = calibrator.Calibrate();
PerspectiveFactor = result.BestFactor;
SetState("Idle");
return result;
}
SetState("Idle");
return null;
}
catch (Exception ex)
{
_pipeline.RemoveConsumer(calibrator);
Log.Error(ex, "AtlasExecutor: calibration failed");
SetState("Failed");
return null;
}
finally
{
calibrator.Dispose();
}
}
/// <summary>
/// Click-drag the atlas in 4 directions (right, down, left, up) to collect
/// frames with movement in all directions for calibration.
/// </summary>
private async Task AutoScrollCircle(PerspectiveCalibrator calibrator)
{
const int cx = 1280; // screen center at 2560x1440
const int cy = 720;
const int dragDist = 500;
const int dragMs = 3000;
const int stepDelayMs = 30;
// 8 legs: R, D, L, U, then diagonals for more direction variety
(int dx, int dy)[] dirs =
[
(dragDist, 0), (0, dragDist), (-dragDist, 0), (0, -dragDist),
(dragDist, dragDist / 2), (-dragDist, -dragDist / 2),
(-dragDist / 2, dragDist), (dragDist / 2, -dragDist),
];
foreach (var (dx, dy) in dirs)
{
if (StopToken.IsCancellationRequested || calibrator.FramesCollected >= 100) break;
// Move to center (no drag)
_game.MoveMouseInstant(cx, cy);
await Sleep(200);
// Start drag
_game.LeftMouseDown();
await Sleep(50);
// Slow drag over ~2 seconds
var steps = dragMs / stepDelayMs;
for (int i = 1; i <= steps; i++)
{
if (StopToken.IsCancellationRequested) break;
var t = (float)i / steps;
var x = (int)(cx + dx * t);
var y = (int)(cy + dy * t);
_game.MoveMouseInstant(x, y);
await Task.Delay(stepDelayMs, StopToken);
}
_game.LeftMouseUp();
await Sleep(200);
}
// Return mouse to center
_game.MoveMouseInstant(cx, cy);
}
}

View file

@ -47,9 +47,11 @@ public class BotOrchestrator : IAsyncDisposable
public FrameSaver FrameSaver { get; }
public LootDebugDetector LootDebugDetector { get; }
public KulemakExecutor KulemakExecutor { get; }
public AtlasExecutor AtlasExecutor { get; }
public volatile bool ShowYoloOverlay = true;
public volatile bool ShowFightPositionOverlay = true;
private readonly Dictionary<string, ScrapExecutor> _scrapExecutors = new();
private readonly Dictionary<string, DiamondExecutor> _diamondExecutors = new();
// Events
public event Action? StatusUpdated;
@ -94,6 +96,7 @@ public class BotOrchestrator : IAsyncDisposable
enemyDetector: EnemyDetector);
KulemakExecutor = new KulemakExecutor(game, screen, inventory, logWatcher, store.Settings, BossDetector, HudReader, Navigation);
AtlasExecutor = new AtlasExecutor(game, screen, inventory, store.Settings, pipelineService.Pipeline);
logWatcher.AreaEntered += area =>
{
@ -144,6 +147,31 @@ public class BotOrchestrator : IAsyncDisposable
}
}
public async Task EmergencyStop()
{
Log.Warning("EMERGENCY STOP triggered");
_paused = true;
Store.SetPaused(true);
// Stop all trade executors
foreach (var exec in _scrapExecutors.Values)
await exec.Stop();
_scrapExecutors.Clear();
foreach (var exec in _diamondExecutors.Values)
await exec.Stop();
_diamondExecutors.Clear();
TradeQueue.Clear();
// Stop navigation and mapping
await Navigation.Stop();
KulemakExecutor.Stop();
State = "Stopped (END)";
StatusUpdated?.Invoke();
}
public void Pause()
{
_paused = true;
@ -185,6 +213,29 @@ public class BotOrchestrator : IAsyncDisposable
_ = DeactivateLink(id);
}
public void ChangeLinkMode(string id, LinkMode newMode)
{
var link = Links.UpdateMode(id, newMode);
if (link == null) return;
StatusUpdated?.Invoke();
if (!_started || !link.Active) return;
_ = Task.Run(async () =>
{
try
{
await DeactivateLink(id);
await ActivateLink(link);
}
catch (Exception ex)
{
Log.Error(ex, "Failed to change link mode: {Id} → {Mode}", id, newMode);
Emit("error", $"Failed to switch mode: {link.Name}");
}
});
}
public BotStatus GetStatus() => new()
{
Paused = _paused,
@ -211,6 +262,14 @@ public class BotOrchestrator : IAsyncDisposable
return;
}
}
foreach (var diamondExec in _diamondExecutors.Values)
{
if (diamondExec.State != DiamondState.Idle && diamondExec.State != DiamondState.WaitingForListings)
{
State = diamondExec.State.ToString();
return;
}
}
if (KulemakExecutor.State != MappingState.Idle)
{
State = KulemakExecutor.State.ToString();
@ -273,6 +332,10 @@ public class BotOrchestrator : IAsyncDisposable
await Inventory.ClearToStash();
Emit("info", "Inventory cleared");
// Wire trade monitor events before activating links to avoid race
TradeMonitor.NewListings += OnNewListings;
TradeMonitor.DiamondListings += OnDiamondListings;
// Load links
var allUrls = new HashSet<string>(cliUrls);
foreach (var l in Store.Settings.Links)
@ -287,9 +350,6 @@ public class BotOrchestrator : IAsyncDisposable
Emit("info", $"Loaded (inactive): {link.Name}");
}
// Wire trade monitor events
TradeMonitor.NewListings += OnNewListings;
Emit("info", $"Loaded {allUrls.Count} trade link(s)");
Log.Information("Bot started");
}
@ -360,6 +420,8 @@ public class BotOrchestrator : IAsyncDisposable
Log.Information("Shutting down bot...");
foreach (var exec in _scrapExecutors.Values)
await exec.Stop();
foreach (var exec in _diamondExecutors.Values)
await exec.Stop();
EnemyDetector.Dispose();
Screen.Dispose();
await TradeMonitor.DisposeAsync();
@ -367,25 +429,46 @@ public class BotOrchestrator : IAsyncDisposable
PipelineService.Dispose();
}
private void OnNewListings(string searchId, List<string> itemIds)
private void OnNewListings(string searchId, List<TradeItem> items)
{
if (_paused)
{
Emit("warn", $"New listings ({itemIds.Count}) skipped - bot paused");
Emit("warn", $"New listings ({items.Count}) skipped - bot paused");
return;
}
if (!Links.IsActive(searchId))
{
Emit("warn", $"New listings ({items.Count}) skipped - link {searchId} inactive");
return;
}
Log.Information("New listings: {SearchId} ({Count} items)", searchId, items.Count);
Emit("info", $"New listings: {items.Count} items from {searchId}");
TradeQueue.Enqueue(new TradeInfo(
SearchId: searchId,
Items: items,
Timestamp: DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
));
}
private void OnDiamondListings(string searchId, List<PricedTradeItem> items)
{
if (_paused)
{
Emit("warn", $"Diamond listings ({items.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}");
foreach (var item in items)
{
var display = DiamondSettings.KnownDiamonds.GetValueOrDefault(item.Name, item.Name);
Emit("info", $"Diamond: {display} @ {item.PriceAmount} {item.PriceCurrency}");
}
TradeQueue.Enqueue(new TradeInfo(
SearchId: searchId,
ItemIds: itemIds,
WhisperText: "",
Timestamp: DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
TradeUrl: ""
));
if (_diamondExecutors.TryGetValue(searchId, out var exec))
exec.EnqueueItems(items);
}
private async Task ActivateLink(TradeLink link)
@ -412,6 +495,29 @@ public class BotOrchestrator : IAsyncDisposable
}
});
}
else if (link.Mode == LinkMode.Diamond)
{
var searchId = TradeMonitor.ExtractSearchId(link.Url);
var diamondExec = new DiamondExecutor(searchId, Game, Screen, TradeMonitor, Inventory, Config);
diamondExec.StateChanged += _ => UpdateExecutorState();
diamondExec.ItemBought += () => { Interlocked.Increment(ref _tradesCompleted); StatusUpdated?.Invoke(); };
diamondExec.ItemFailed += () => { Interlocked.Increment(ref _tradesFailed); StatusUpdated?.Invoke(); };
_diamondExecutors[searchId] = diamondExec;
await TradeMonitor.AddDiamondSearch(link.Url);
Emit("info", $"Diamond search started: {link.Name}");
StatusUpdated?.Invoke();
_ = diamondExec.RunLoop().ContinueWith(t =>
{
if (t.IsFaulted)
{
Log.Error(t.Exception!, "Diamond loop error: {LinkId}", link.Id);
Emit("error", $"Diamond loop failed: {link.Name}");
_diamondExecutors.Remove(searchId);
}
});
}
else
{
await TradeMonitor.AddSearch(link.Url);
@ -433,6 +539,14 @@ public class BotOrchestrator : IAsyncDisposable
await scrapExec.Stop();
_scrapExecutors.Remove(id);
}
// Diamond executors are keyed by searchId, not link id — but they're the same
if (_diamondExecutors.TryGetValue(id, out var diamondExec))
{
await diamondExec.Stop();
_diamondExecutors.Remove(id);
}
await TradeMonitor.PauseSearch(id);
}

View file

@ -0,0 +1,264 @@
using System.Collections.Concurrent;
using Poe2Trade.Core;
using Poe2Trade.Game;
using Poe2Trade.Inventory;
using Poe2Trade.Screen;
using Poe2Trade.Trade;
using Serilog;
namespace Poe2Trade.Bot;
public class DiamondExecutor
{
private DiamondState _state = DiamondState.Idle;
private bool _stopped;
private readonly string _searchId;
private readonly ConcurrentQueue<PricedTradeItem> _queue = new();
private readonly SemaphoreSlim _signal = new(0);
private readonly IGameController _game;
private readonly IScreenReader _screen;
private readonly ITradeMonitor _tradeMonitor;
private readonly IInventoryManager _inventory;
private readonly SavedSettings _config;
public event Action<DiamondState>? StateChanged;
public event Action? ItemBought;
public event Action? ItemFailed;
public DiamondExecutor(string searchId, IGameController game, IScreenReader screen,
ITradeMonitor tradeMonitor, IInventoryManager inventory, SavedSettings config)
{
_searchId = searchId;
_game = game;
_screen = screen;
_tradeMonitor = tradeMonitor;
_inventory = inventory;
_config = config;
}
public DiamondState State => _state;
private void SetState(DiamondState s)
{
_state = s;
StateChanged?.Invoke(s);
}
public void EnqueueItems(List<PricedTradeItem> items)
{
foreach (var item in items)
_queue.Enqueue(item);
_signal.Release(items.Count);
}
public Task Stop()
{
_stopped = true;
_signal.Release(); // unblock wait
SetState(DiamondState.Idle);
Log.Information("Diamond executor stopped: {SearchId}", _searchId);
return Task.CompletedTask;
}
public async Task RunLoop()
{
_stopped = false;
Log.Information("Diamond executor started: {SearchId}", _searchId);
await _inventory.ScanInventory(PostAction.Stash);
SetState(DiamondState.WaitingForListings);
while (!_stopped)
{
await _signal.WaitAsync();
if (_stopped) break;
while (_queue.TryDequeue(out var item))
{
if (_stopped) break;
await ProcessItem(item);
}
if (!_stopped)
SetState(DiamondState.WaitingForListings);
}
SetState(DiamondState.Idle);
Log.Information("Diamond executor loop ended: {SearchId}", _searchId);
}
private async Task ProcessItem(PricedTradeItem item)
{
SetState(DiamondState.Filtering);
if (!ShouldBuy(item))
return;
// Check inventory space
if (!_inventory.Tracker.CanFit(item.W, item.H))
{
Log.Information("No room for {W}x{H}, going home to stash", item.W, item.H);
await GoHomeAndStash();
await _inventory.ScanInventory(PostAction.Stash);
if (!_inventory.Tracker.CanFit(item.W, item.H))
{
Log.Warning("Still no room for {W}x{H} after stash, skipping {Name}", item.W, item.H, DisplayName(item));
return;
}
}
if (!await TravelToSeller(item))
return;
await BuyItem(item);
}
private static string DisplayName(PricedTradeItem item) =>
DiamondSettings.KnownDiamonds.GetValueOrDefault(item.Name, item.Name);
// Units per 1 divine
private static readonly Dictionary<string, double> CurrencyPerDivine = new(StringComparer.OrdinalIgnoreCase)
{
["divine"] = 1,
["annul"] = 7,
["exalted"] = 275,
["chaos"] = 269,
["vaal"] = 65,
};
private static double ToDivineEquivalent(double amount, string currency)
{
foreach (var (key, rate) in CurrencyPerDivine)
{
if (currency.Contains(key))
return amount / rate;
}
return -1;
}
private bool ShouldBuy(PricedTradeItem item)
{
var settings = _config.Diamond;
var currency = item.PriceCurrency.ToLowerInvariant();
var name = DisplayName(item);
// Look up per-item config
var priceConfig = settings.Prices.FirstOrDefault(p =>
p.ItemName.Equals(item.Name, StringComparison.OrdinalIgnoreCase));
if (priceConfig == null)
{
Log.Debug("Diamond skip (not configured): {Name} ({Icon}) @ {Amount} {Currency}", name, item.Name, item.PriceAmount, item.PriceCurrency);
return false;
}
if (!priceConfig.Enabled)
{
Log.Debug("Diamond skip (disabled): {Name}", name);
return false;
}
// Convert any currency to divine equivalent
var divinePrice = ToDivineEquivalent(item.PriceAmount, currency);
if (divinePrice < 0)
{
Log.Information("Diamond skip (unknown currency): {Name} @ {Amount} {Currency}", name, item.PriceAmount, item.PriceCurrency);
return false;
}
if (divinePrice <= priceConfig.MaxDivinePrice)
{
Log.Information("Diamond buy: {Name} @ {Amount} {Currency} (={DivPrice:F2} div, max={Max})",
name, item.PriceAmount, item.PriceCurrency, divinePrice, priceConfig.MaxDivinePrice);
return true;
}
Log.Information("Diamond skip (price): {Name} @ {Amount} {Currency} (={DivPrice:F2} div, max={Max})",
name, item.PriceAmount, item.PriceCurrency, divinePrice, priceConfig.MaxDivinePrice);
return false;
}
private async Task<bool> TravelToSeller(PricedTradeItem item)
{
var alreadyAtSeller = !_inventory.IsAtOwnHideout
&& !string.IsNullOrEmpty(item.Account)
&& item.Account == _inventory.SellerAccount;
if (alreadyAtSeller)
{
Log.Information("Already at seller hideout, skipping travel");
return true;
}
SetState(DiamondState.Traveling);
var arrived = await _inventory.WaitForAreaTransition(
_config.TravelTimeoutMs,
async () =>
{
if (!await _tradeMonitor.ClickTravelToHideout(_searchId, item.Id))
throw new Exception("Failed to click Travel to Hideout");
});
if (!arrived)
{
Log.Error("Timed out waiting for hideout arrival: {ItemId}", item.Id);
SetState(DiamondState.Failed);
ItemFailed?.Invoke();
return false;
}
_inventory.SetLocation(false, item.Account);
await _game.FocusGame();
await Helpers.Sleep(Delays.PostTravel);
return true;
}
private async Task BuyItem(PricedTradeItem item)
{
try
{
SetState(DiamondState.Buying);
var sellerLayout = GridLayouts.Seller;
var cellCenter = _screen.Grid.GetCellCenter(sellerLayout, item.StashY, item.StashX);
Log.Information("CTRL+clicking seller stash at ({X},{Y}) for {Name}", cellCenter.X, cellCenter.Y, DisplayName(item));
await _game.CtrlLeftClickAt(cellCenter.X, cellCenter.Y);
await Helpers.RandomDelay(200, 400);
_inventory.Tracker.TryPlace(item.W, item.H, PostAction.Stash);
Log.Information("Diamond bought: {Name} @ {Amount} {Currency} (free={Free})",
DisplayName(item), item.PriceAmount, item.PriceCurrency, _inventory.Tracker.FreeCells);
ItemBought?.Invoke();
}
catch (Exception ex)
{
Log.Error(ex, "Error buying diamond item {Name}", DisplayName(item));
SetState(DiamondState.Failed);
ItemFailed?.Invoke();
}
}
private async Task GoHomeAndStash()
{
try
{
SetState(DiamondState.GoingHome);
var home = await _inventory.EnsureAtOwnHideout();
if (!home)
{
Log.Error("Failed to reach own hideout for stashing");
SetState(DiamondState.Failed);
return;
}
SetState(DiamondState.Storing);
await _inventory.ProcessInventory();
}
catch (Exception ex)
{
Log.Error(ex, "GoHomeAndStash failed");
SetState(DiamondState.Failed);
}
}
}

View file

@ -133,10 +133,10 @@ public abstract class GameExecutor
{
Log.Information("Recovering: escaping and going to hideout");
await _game.FocusGame();
await _game.PressEscape();
await Sleep(Delays.PostEscape);
await _game.PressEscape();
await Sleep(Delays.PostEscape);
// await _game.PressEscape();
// await Sleep(Delays.PostEscape);
// await _game.PressEscape();
// await Sleep(Delays.PostEscape);
var arrived = await _inventory.WaitForAreaTransition(
_config.TravelTimeoutMs, () => _game.GoToHideout());

View file

@ -229,7 +229,7 @@ public class KulemakExecutor : MappingExecutor
SetState(MappingState.WalkingToEntrance);
Log.Information("Walking to Black Cathedral entrance (W+D)");
return await WalkAndMatch(BlackCathedralTemplate, InputSender.VK.W, InputSender.VK.D, 15000);
return await WalkAndMatch(BlackCathedralTemplate, InputSender.VK.W, InputSender.VK.D, 10000);
}
private async Task<bool> UseInvitation(int x, int y)
@ -335,21 +335,21 @@ public class KulemakExecutor : MappingExecutor
}
Log.Information("Phase {Phase} done, walking to well", phase);
await Sleep(500);
await Sleep(100);
await WalkToWorldPosition(wellWorldX, wellWorldY);
await Sleep(500);
if (!await TryClickWell())
await Sleep(100);
for (var attempt = 0; attempt < 5; attempt++)
{
Log.Warning("Well not found, walking A+W to get closer");
if (await TryClickWell()) break;
Log.Warning("Well not found (attempt {Attempt}), walking A+W to get closer", attempt + 1);
await _game.KeyDown(InputSender.VK.A);
await _game.KeyDown(InputSender.VK.W);
await Sleep(1500);
await _game.KeyUp(InputSender.VK.W);
if(attempt == 0) await _game.KeyDown(InputSender.VK.W);
await Sleep(1000);
await _game.KeyUp(InputSender.VK.A);
await Sleep(500);
await TryClickWell();
if(attempt == 0) await _game.KeyUp(InputSender.VK.W);
await Sleep(100);
}
await Sleep(200);
await Sleep(1500);
await WalkToWorldPosition(fightWorldX + 20, fightWorldY +20, cancelWhen: IsBossAlive);
}
@ -378,7 +378,7 @@ public class KulemakExecutor : MappingExecutor
}
Log.Information("Ring phase: using fightArea=({FX:F0},{FY:F0})", fightWorldX, fightWorldY);
await WalkToWorldPosition(-440, -330);
await WalkToWorldPosition(-450, -340);
await Sleep(1000);
if (_stopped) return;

View file

@ -16,6 +16,13 @@ public class TradeExecutor
private readonly IInventoryManager _inventory;
private readonly SavedSettings _config;
// Merchant template for detecting seller stash is open
private static readonly string MerchantTemplatePath = Path.GetFullPath("assets/merchant.png");
private static readonly Region MerchantRegion = new(715, 180, 245, 50);
private const double MerchantThreshold = 0.7;
private const int MerchantTimeoutMs = 15000;
private const int MerchantPollMs = 100;
public event Action<TradeState>? StateChanged;
public TradeExecutor(IGameController game, IScreenReader screen, ITradeMonitor tradeMonitor,
@ -40,19 +47,52 @@ public class TradeExecutor
{
try
{
if (!await TravelToSeller(trade))
// Start travel and begin polling for merchant stash immediately
SetState(TradeState.Traveling);
var firstId = trade.Items[0].Id;
Log.Information("Clicking Travel to Hideout for {SearchId} item {ItemId}...", trade.SearchId, firstId);
// Click travel button in the browser
if (!await _tradeMonitor.ClickTravelToHideout(trade.SearchId, firstId))
{
Log.Error("Failed to click Travel to Hideout");
SetState(TradeState.Failed);
return false;
}
if (!await FindSellerStash())
// Focus game and immediately start polling for merchant stash
await _game.FocusGame();
var merchantFound = await WaitForMerchantStash();
if (!merchantFound)
{
Log.Error("Timed out waiting for merchant stash to appear");
SetState(TradeState.Failed);
return false;
}
SetState(TradeState.ScanningStash);
await ScanAndBuyItems();
_inventory.SetLocation(false);
SetState(TradeState.WaitingForMore);
Log.Information("Waiting {Ms}ms for more items...", _config.WaitForMoreItemsMs);
await Helpers.Sleep(_config.WaitForMoreItemsMs);
await ScanAndBuyItems();
// Merchant stash is visible — buy immediately
SetState(TradeState.Buying);
foreach (var item in trade.Items)
{
if (!_inventory.Tracker.CanFit(item.W, item.H))
{
Log.Warning("No room for {W}x{H}, skipping {Id}", item.W, item.H, item.Id);
continue;
}
var sellerLayout = GridLayouts.Seller;
var cellCenter = _screen.Grid.GetCellCenter(sellerLayout, item.StashY, item.StashX);
Log.Information("CTRL+clicking seller stash at ({X},{Y}) for item {Id} ({W}x{H})",
cellCenter.X, cellCenter.Y, item.Id, item.W, item.H);
await _game.CtrlLeftClickAt(cellCenter.X, cellCenter.Y);
await Helpers.RandomDelay(200, 400);
_inventory.Tracker.TryPlace(item.W, item.H, PostAction.Stash);
}
await ReturnHome();
@ -73,51 +113,35 @@ public class TradeExecutor
}
}
private async Task<bool> TravelToSeller(TradeInfo trade)
/// <summary>
/// Polls a small screen region for the merchant template.
/// Returns true as soon as the template is detected.
/// </summary>
private async Task<bool> WaitForMerchantStash()
{
SetState(TradeState.Traveling);
Log.Information("Clicking Travel to Hideout for {SearchId}...", trade.SearchId);
var sw = System.Diagnostics.Stopwatch.StartNew();
var arrived = await _inventory.WaitForAreaTransition(
_config.TravelTimeoutMs,
async () =>
while (sw.ElapsedMilliseconds < MerchantTimeoutMs)
{
if (!await _tradeMonitor.ClickTravelToHideout(trade.SearchId, trade.ItemIds[0]))
throw new Exception("Failed to click Travel to Hideout");
});
if (!arrived)
try
{
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");
var match = await _screen.TemplateMatch(MerchantTemplatePath, MerchantRegion);
if (match != null && match.Confidence >= MerchantThreshold)
{
Log.Information("Merchant stash detected (confidence={Conf:F3}, elapsed={Ms}ms)",
match.Confidence, sw.ElapsedMilliseconds);
return true;
}
private async Task<bool> FindSellerStash()
{
await _game.FocusGame();
await Helpers.Sleep(Delays.PostTravel);
var angePos = await _inventory.FindAndClickNameplate("Ange");
if (angePos == null)
Log.Warning("Could not find Ange nameplate, trying Stash directly");
else
await Helpers.Sleep(Delays.PostStashOpen);
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(Delays.PostStashOpen);
return true;
catch (Exception ex)
{
Log.Debug(ex, "Merchant poll error");
}
await Task.Delay(MerchantPollMs);
}
return false;
}
private async Task ReturnHome()
@ -147,11 +171,4 @@ public class TradeExecutor
Log.Debug(ex, "Recovery failed");
}
}
private async Task ScanAndBuyItems()
{
var stashText = await _screen.ReadRegionText(GridLayouts.SellerStashOcr);
Log.Information("Stash OCR: {Text}", stashText.Length > 200 ? stashText[..200] : stashText);
SetState(TradeState.Buying);
}
}

View file

@ -19,22 +19,28 @@ public class TradeQueue
public int Length => _queue.Count;
public bool IsProcessing => _processing;
public void Clear()
{
_queue.Clear();
Log.Information("Trade queue cleared");
}
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)
var existingIds = _queue.SelectMany(t => t.Items.Select(i => i.Id)).ToHashSet();
var newItems = trade.Items.Where(i => !existingIds.Contains(i.Id)).ToList();
if (newItems.Count == 0)
{
Log.Information("Skipping duplicate trade: {ItemIds}", string.Join(",", trade.ItemIds));
Log.Information("Skipping duplicate trade: {ItemIds}", string.Join(",", trade.Items.Select(i => i.Id)));
return;
}
var deduped = trade with { ItemIds = newIds };
var deduped = trade with { Items = newItems };
_queue.Enqueue(deduped);
Log.Information("Trade enqueued: {Count} items, queue={QueueLen}", newIds.Count, _queue.Count);
Log.Information("Trade enqueued: {Count} items, queue={QueueLen}", newItems.Count, _queue.Count);
_ = ProcessNext();
}
@ -46,7 +52,7 @@ public class TradeQueue
var trade = _queue.Dequeue();
try
{
Log.Information("Processing trade: {SearchId} ({Count} items)", trade.SearchId, trade.ItemIds.Count);
Log.Information("Processing trade: {SearchId} ({Count} items)", trade.SearchId, trade.Items.Count);
var success = await _executor.ExecuteTrade(trade);
if (success)
{

View file

@ -37,6 +37,58 @@ public class SavedSettings
public bool ShowHudDebug { get; set; }
public string OcrEngine { get; set; } = "WinOCR";
public KulemakSettings Kulemak { get; set; } = new();
public DiamondSettings Diamond { get; set; } = new();
}
public class DiamondPriceConfig
{
public string ItemName { get; set; } = "";
public string DisplayName { get; set; } = "";
public double MaxDivinePrice { get; set; }
public bool Enabled { get; set; } = true;
}
public class DiamondSettings
{
public static readonly Dictionary<string, string> KnownDiamonds = new()
{
["SanctumJewel"] = "Time-Lost Diamond",
["SacredFlameJewel"] = "Prism of Belief",
["TrialmasterJewel"] = "The Adorned",
["DeliriumJewel"] = "Megalomaniac Diamond",
["ApostatesHeart"] = "Heart of the Well",
};
public List<DiamondPriceConfig> Prices { get; set; } = DefaultPrices();
private static List<DiamondPriceConfig> DefaultPrices() =>
KnownDiamonds.Select(kv => new DiamondPriceConfig
{
ItemName = kv.Key,
DisplayName = kv.Value,
MaxDivinePrice = 0,
Enabled = false,
}).ToList();
/// <summary>Ensure all known diamonds exist in the list (adds missing ones as disabled).</summary>
public void BackfillKnown()
{
// Remove blank entries
Prices.RemoveAll(p => string.IsNullOrWhiteSpace(p.ItemName));
var existing = new HashSet<string>(Prices.Select(p => p.ItemName), StringComparer.OrdinalIgnoreCase);
foreach (var kv in KnownDiamonds)
{
if (existing.Contains(kv.Key)) continue;
Prices.Add(new DiamondPriceConfig
{
ItemName = kv.Key,
DisplayName = kv.Value,
MaxDivinePrice = 0,
Enabled = false,
});
}
}
}
public class KulemakSettings
@ -71,7 +123,16 @@ public class ConfigStore
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;
var existing = _data.Links.FirstOrDefault(l => l.Url == url);
if (existing != null)
{
// Update mode/postAction/name if re-added with different settings
existing.Mode = mode;
existing.PostAction = postAction ?? (mode == LinkMode.Scrap ? PostAction.Salvage : PostAction.Stash);
if (!string.IsNullOrEmpty(name)) existing.Name = name;
Save();
return;
}
_data.Links.Add(new SavedLink
{
Url = url,
@ -177,6 +238,9 @@ public class ConfigStore
link.Url = StripLive(link.Url);
}
// Backfill known diamonds
parsed.Diamond.BackfillKnown();
Log.Information("Loaded config.json from {Path} ({LinkCount} links)", _filePath, parsed.Links.Count);
return parsed;
}

View file

@ -22,6 +22,24 @@ public class LinkManager
public LinkManager(ConfigStore store)
{
_store = store;
// Pre-populate from saved config so UI shows links before Start()
foreach (var saved in store.Links)
{
var url = StripLive(saved.Url);
var id = ExtractId(url);
_links[id] = new TradeLink
{
Id = id,
Url = url,
Name = saved.Name,
Label = ExtractLabel(url),
Active = saved.Active,
Mode = saved.Mode,
PostAction = saved.PostAction,
AddedAt = saved.AddedAt,
};
}
}
public TradeLink AddLink(string url, string name = "", LinkMode? mode = null, PostAction? postAction = null)

View file

@ -4,10 +4,8 @@ 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
List<TradeItem> Items,
long Timestamp
);
public record TradeItem(
@ -59,9 +57,34 @@ public enum ScrapState
public enum LinkMode
{
Live,
Scrap
Scrap,
Diamond
}
public enum DiamondState
{
Idle,
WaitingForListings,
Filtering,
Traveling,
Buying,
GoingHome,
Storing,
Failed
}
public record PricedTradeItem(
string Id,
int W,
int H,
int StashX,
int StashY,
string Account,
string Name,
double PriceAmount,
string PriceCurrency
) : TradeItem(Id, W, H, StashX, StashY, Account);
public enum PostAction
{
Stash,

View file

@ -133,6 +133,8 @@ public class InventoryManager : IInventoryManager
Log.Information("Depositing {Count} items to stash", items.Count);
await CtrlClickItems(items, GridLayouts.Inventory);
await SnapshotInventory();
await _game.PressEscape();
await Helpers.Sleep(Delays.PostEscape);
Log.Information("Items deposited to stash");

View file

@ -0,0 +1,352 @@
using System.Diagnostics;
using OpenCvSharp;
using Poe2Trade.Core;
using Poe2Trade.Screen;
using Serilog;
namespace Poe2Trade.Navigation;
public record AtlasProgress(int TilesCaptured, int Row, string Phase);
/// <summary>
/// Stitches atlas frames into a panorama canvas.
/// Implements IFrameConsumer so it receives shared pipeline frames — no separate DXGI needed.
/// </summary>
public class AtlasPanorama : IFrameConsumer, IDisposable
{
// Canvas — CV_8UC3 BGR, growable
private Mat _canvas;
private int _canvasSize;
private int _viewX, _viewY; // current viewport position on canvas
private int _tilesCaptured;
private bool _firstFrame = true;
// HUD-free capture region at 2560x1440
// Top 150px: header + act tabs
// Bottom 200px: skill bar + globes
// Sides 300px: globe frames + padding
private static readonly Region CaptureRegion = new(300, 150, 1960, 1090);
// Canvas management
private const int InitialCanvasSize = 10000;
private const int GrowMargin = 500;
private const int GrowAmount = 4000;
// Template matching — done at reduced resolution for speed
private const int MatchScale = 4; // downscale factor for template matching
private const int TemplateSize = 200; // at full res, becomes 50px at match scale
private const int SearchMargin = 300; // max scroll shift between frames at ~30fps
private const double MatchThreshold = 0.70;
// Movement detection: minimum viewport shift (px) to count as a new tile
private const int MinViewportShift = 20;
// Perspective correction: the atlas camera looks from the south, so the top of the
// screen is further away and horizontally compressed.
// Measured: bottom edge ≈ 24cm, top edge ≈ 17cm on 2560x1440
// Default 0.15 — use CalibratePerspective to find the optimal value.
private readonly float _perspectiveFactor;
private Mat? _warpMatrix;
public int TilesCaptured => _tilesCaptured;
public event Action<AtlasProgress>? ProgressUpdated;
private void ReportProgress(string phase)
{
ProgressUpdated?.Invoke(new AtlasProgress(_tilesCaptured, 0, phase));
}
public AtlasPanorama(float perspectiveFactor = 0.115f)
{
_perspectiveFactor = perspectiveFactor;
_canvasSize = InitialCanvasSize;
_canvas = new Mat(_canvasSize, _canvasSize, MatType.CV_8UC3, Scalar.Black);
_viewX = _canvasSize / 2;
_viewY = _canvasSize / 2;
}
/// <summary>
/// IFrameConsumer — called by the pipeline with each shared screen frame.
/// Crops the atlas region and stitches it onto the canvas.
/// </summary>
public void Process(ScreenFrame frame)
{
var sw = Stopwatch.StartNew();
using var bgr = frame.CropBgr(CaptureRegion);
using var corrected = CorrectPerspective(bgr);
if (_firstFrame)
{
PasteFrame(corrected, _viewX - corrected.Width / 2, _viewY - corrected.Height / 2);
_tilesCaptured++;
_firstFrame = false;
ReportProgress("Capturing");
Log.Debug("AtlasPanorama: first frame pasted in {Ms:F1}ms", sw.Elapsed.TotalMilliseconds);
return;
}
StitchFrame(corrected);
if (sw.ElapsedMilliseconds > 50)
Log.Warning("AtlasPanorama: Process took {Ms}ms", sw.ElapsedMilliseconds);
}
/// <summary>
/// Warp the frame from the tilted atlas camera view to a top-down projection.
/// The top of the screen is further from the camera and appears narrower —
/// we stretch it back to equal width.
/// </summary>
private Mat CorrectPerspective(Mat frame)
{
var w = frame.Width;
var h = frame.Height;
var inset = (int)(w * _perspectiveFactor);
// Compute warp matrix once (all frames are the same size)
if (_warpMatrix == null)
{
// Source: trapezoid as seen on screen (top edge is narrower)
var src = new Point2f[]
{
new(inset, 0), // top-left (shifted inward)
new(w - inset, 0), // top-right (shifted inward)
new(w, h), // bottom-right (full width, close to camera)
new(0, h), // bottom-left
};
// Destination: rectangle (top-down)
var dst = new Point2f[]
{
new(0, 0),
new(w, 0),
new(w, h),
new(0, h),
};
_warpMatrix = Cv2.GetPerspectiveTransform(src, dst);
Log.Information("AtlasPanorama: perspective matrix computed (factor={F}, inset={I}px)",
_perspectiveFactor, inset);
}
var result = new Mat();
Cv2.WarpPerspective(frame, result, _warpMatrix, new Size(w, h));
return result;
}
/// <summary>
/// Wait until cancelled. Stitching happens via Process() on the pipeline thread.
/// </summary>
public async Task Run(CancellationToken ct)
{
Log.Information("AtlasPanorama: started (pipeline consumer)");
_tilesCaptured = 0;
_firstFrame = true;
try
{
await Task.Delay(Timeout.Infinite, ct);
}
catch (OperationCanceledException) { }
Log.Information("AtlasPanorama: stopped — {Tiles} tiles", _tilesCaptured);
}
/// <summary>
/// Return a PNG of the full canvas built so far, downscaled to fit maxDim.
/// </summary>
public byte[]? GetViewportSnapshot(int maxDim = 900)
{
if (_tilesCaptured == 0) return null;
using var trimmed = TrimCanvas();
if (trimmed.Empty()) return null;
// Downscale to fit within maxDim, preserving aspect ratio
var scale = Math.Min((double)maxDim / trimmed.Width, (double)maxDim / trimmed.Height);
if (scale >= 1.0)
{
Cv2.ImEncode(".png", trimmed, out var buf);
return buf;
}
using var small = new Mat();
Cv2.Resize(trimmed, small, new Size(
(int)(trimmed.Width * scale),
(int)(trimmed.Height * scale)));
Cv2.ImEncode(".png", small, out var buf2);
return buf2;
}
/// <summary>
/// Encode the trimmed canvas as PNG bytes.
/// </summary>
public byte[] GetResultPng()
{
using var trimmed = TrimCanvas();
Cv2.ImEncode(".png", trimmed, out var buf);
return buf;
}
/// <summary>
/// Save the trimmed canvas to a file.
/// </summary>
public void SaveResult(string path)
{
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
using var trimmed = TrimCanvas();
Cv2.ImWrite(path, trimmed);
Log.Information("AtlasPanorama: saved to {Path} ({W}x{H})", path, trimmed.Width, trimmed.Height);
}
/// <summary>
/// Template-match at reduced resolution, then paste at full resolution.
/// </summary>
private void StitchFrame(Mat frame)
{
EnsureCapacity();
// Search region at full res
var halfW = frame.Width / 2 + SearchMargin;
var halfH = frame.Height / 2 + SearchMargin;
var sx0 = Math.Max(0, _viewX - halfW);
var sy0 = Math.Max(0, _viewY - halfH);
var sx1 = Math.Min(_canvasSize, _viewX + halfW);
var sy1 = Math.Min(_canvasSize, _viewY + halfH);
var sW = sx1 - sx0;
var sH = sy1 - sy0;
// Template: center strip at full res
var tW = Math.Min(TemplateSize, frame.Width);
var tH = Math.Min(TemplateSize, frame.Height);
var tX = (frame.Width - tW) / 2;
var tY = (frame.Height - tH) / 2;
if (sW <= tW || sH <= tH)
{
Log.Debug("AtlasPanorama: search region too small, pasting at viewport");
PasteFrame(frame, _viewX - frame.Width / 2, _viewY - frame.Height / 2);
_tilesCaptured++;
return;
}
// Downscale template + search region for fast matching
using var templateFull = new Mat(frame, new Rect(tX, tY, tW, tH));
using var templateSmall = new Mat();
Cv2.Resize(templateFull, templateSmall, new Size(tW / MatchScale, tH / MatchScale));
using var searchRoi = new Mat(_canvas, new Rect(sx0, sy0, sW, sH));
using var searchSmall = new Mat();
Cv2.Resize(searchRoi, searchSmall, new Size(sW / MatchScale, sH / MatchScale));
using var result = new Mat();
Cv2.MatchTemplate(searchSmall, templateSmall, result, TemplateMatchModes.CCoeffNormed);
Cv2.MinMaxLoc(result, out _, out var maxVal, out _, out var maxLoc);
if (maxVal >= MatchThreshold)
{
// Scale match position back to full res
var frameCanvasX = sx0 + maxLoc.X * MatchScale - tX;
var frameCanvasY = sy0 + maxLoc.Y * MatchScale - tY;
var newVx = frameCanvasX + frame.Width / 2;
var newVy = frameCanvasY + frame.Height / 2;
var dx = Math.Abs(newVx - _viewX);
var dy = Math.Abs(newVy - _viewY);
// Only paste when the viewport actually moved — prevents cumulative
// drift from the 4x downscale quantization on still frames
if (dx >= MinViewportShift || dy >= MinViewportShift)
{
PasteFrame(frame, frameCanvasX, frameCanvasY);
_viewX = newVx;
_viewY = newVy;
_tilesCaptured++;
ReportProgress("Capturing");
Log.Information("AtlasPanorama: tile {N} at ({X},{Y}) shift=({Dx},{Dy}) conf={Conf:F3}",
_tilesCaptured, frameCanvasX, frameCanvasY, dx, dy, maxVal);
}
}
else
{
Log.Information("AtlasPanorama: match failed (conf={Conf:F3}), skipping frame", maxVal);
}
}
private void PasteFrame(Mat frame, int canvasX, int canvasY)
{
var srcX = Math.Max(0, -canvasX);
var srcY = Math.Max(0, -canvasY);
var dstX = Math.Max(0, canvasX);
var dstY = Math.Max(0, canvasY);
var w = Math.Min(frame.Width - srcX, _canvasSize - dstX);
var h = Math.Min(frame.Height - srcY, _canvasSize - dstY);
if (w <= 0 || h <= 0) return;
using var srcRoi = new Mat(frame, new Rect(srcX, srcY, w, h));
using var dstRoi = new Mat(_canvas, new Rect(dstX, dstY, w, h));
srcRoi.CopyTo(dstRoi);
}
private void EnsureCapacity()
{
if (_viewX >= GrowMargin && _viewY >= GrowMargin &&
_viewX < _canvasSize - GrowMargin && _viewY < _canvasSize - GrowMargin)
return;
var oldSize = _canvasSize;
var newSize = oldSize + GrowAmount;
var offset = GrowAmount / 2;
var newCanvas = new Mat(newSize, newSize, MatType.CV_8UC3, Scalar.Black);
using (var dst = new Mat(newCanvas, new Rect(offset, offset, oldSize, oldSize)))
_canvas.CopyTo(dst);
_canvas.Dispose();
_canvas = newCanvas;
_canvasSize = newSize;
_viewX += offset;
_viewY += offset;
Log.Information("AtlasPanorama: canvas grown {Old}x{Old} -> {New}x{New}", oldSize, oldSize, newSize, newSize);
}
private Mat TrimCanvas()
{
using var gray = new Mat();
Cv2.CvtColor(_canvas, gray, ColorConversionCodes.BGR2GRAY);
using var mask = new Mat();
Cv2.Threshold(gray, mask, 1, 255, ThresholdTypes.Binary);
var points = new Mat();
Cv2.FindNonZero(mask, points);
if (points.Empty())
{
Log.Warning("AtlasPanorama: canvas is empty after trim");
return _canvas.Clone();
}
var bbox = Cv2.BoundingRect(points);
points.Dispose();
const int pad = 10;
var x = Math.Max(0, bbox.X - pad);
var y = Math.Max(0, bbox.Y - pad);
var w = Math.Min(_canvasSize - x, bbox.Width + 2 * pad);
var h = Math.Min(_canvasSize - y, bbox.Height + 2 * pad);
return new Mat(_canvas, new Rect(x, y, w, h)).Clone();
}
public void Dispose()
{
_canvas.Dispose();
_warpMatrix?.Dispose();
}
}

View file

@ -0,0 +1,264 @@
using OpenCvSharp;
using Poe2Trade.Core;
using Poe2Trade.Screen;
using Serilog;
namespace Poe2Trade.Navigation;
public record CalibrationResult(float BestFactor, double BestConfidence, Dictionary<float, double> AllResults);
/// <summary>
/// Collects atlas frames and tests different perspective factors to find the optimal one.
/// For each candidate factor, stitches all frames into a mini-canvas and measures seam
/// quality (pixel difference in overlap regions). The correct factor minimizes seam error
/// because frames from different scroll directions align properly.
/// </summary>
public class PerspectiveCalibrator : IFrameConsumer, IDisposable
{
private readonly List<Mat> _frames = new();
private Mat? _lastKept;
private static readonly Region CaptureRegion = new(300, 150, 1960, 1090);
private const int MaxFrames = 100;
private const double MovementThreshold = 4.0;
public int FramesCollected => _frames.Count;
public event Action<int>? FrameCollected;
public void Process(ScreenFrame frame)
{
if (_frames.Count >= MaxFrames) return;
using var bgr = frame.CropBgr(CaptureRegion);
// Movement check at 1/8 scale
using var small = new Mat();
Cv2.Resize(bgr, small, new Size(bgr.Width / 8, bgr.Height / 8));
if (_lastKept != null)
{
using var diff = new Mat();
Cv2.Absdiff(small, _lastKept, diff);
var mean = Cv2.Mean(diff);
var avgDiff = (mean.Val0 + mean.Val1 + mean.Val2) / 3.0;
if (avgDiff < MovementThreshold)
return;
}
_lastKept?.Dispose();
_lastKept = small.Clone();
_frames.Add(bgr.Clone());
FrameCollected?.Invoke(_frames.Count);
Log.Debug("PerspectiveCalibrator: kept frame {N}/{Max}", _frames.Count, MaxFrames);
}
public CalibrationResult Calibrate()
{
if (_frames.Count < 3)
throw new InvalidOperationException($"Need at least 3 frames, got {_frames.Count}");
Log.Information("PerspectiveCalibrator: analyzing {N} frames with seam-error metric", _frames.Count);
var results = new Dictionary<float, double>();
// Coarse pass: 0.00 to 0.25, step 0.01
for (int fi = 0; fi <= 25; fi++)
{
var f = fi * 0.01f;
var quality = MeasureFactorQuality(f);
results[f] = quality;
Log.Information("Calibrate: factor={F:F2} seamQuality={Q:F6}", f, quality);
}
// Fine pass around the best coarse value
var coarseBest = results.MaxBy(kv => kv.Value).Key;
for (int fi = -9; fi <= 9; fi += 2)
{
var f = MathF.Round(coarseBest + fi * 0.001f, 3);
if (f < 0 || f > 0.30f || results.ContainsKey(f)) continue;
var quality = MeasureFactorQuality(f);
results[f] = quality;
Log.Information("Calibrate (fine): factor={F:F3} seamQuality={Q:F6}", f, quality);
}
var best = results.MaxBy(kv => kv.Value);
Log.Information("PerspectiveCalibrator: BEST factor={F:F3} seamQuality={Q:F6}", best.Key, best.Value);
return new CalibrationResult(best.Key, best.Value, results);
}
/// <summary>
/// Stitches all frames into a mini-canvas using the given perspective factor,
/// and measures the average seam quality (pixel alignment in overlap regions).
/// When the factor is correct, overlapping regions from different scroll directions
/// align perfectly → low pixel difference → high quality score.
/// </summary>
private double MeasureFactorQuality(float factor)
{
const int scale = 4;
var w = _frames[0].Width;
var h = _frames[0].Height;
var sw = w / scale;
var sh = h / scale;
// Compute warp matrix at reduced resolution
Mat? warpMatrix = null;
if (factor > 0.001f)
{
var inset = (int)(sw * factor);
var src = new Point2f[]
{
new(inset, 0), new(sw - inset, 0),
new(sw, sh), new(0, sh),
};
var dst = new Point2f[]
{
new(0, 0), new(sw, 0),
new(sw, sh), new(0, sh),
};
warpMatrix = Cv2.GetPerspectiveTransform(src, dst);
}
var smallFrames = new List<Mat>();
try
{
// Downscale first, then warp
foreach (var frame in _frames)
{
var small = new Mat();
Cv2.Resize(frame, small, new Size(sw, sh));
if (warpMatrix != null)
{
var warped = new Mat();
Cv2.WarpPerspective(small, warped, warpMatrix, new Size(sw, sh));
small.Dispose();
smallFrames.Add(warped);
}
else
{
smallFrames.Add(small);
}
}
// Build mini-canvas and measure seam quality
const int canvasSize = 4000;
using var canvas = new Mat(canvasSize, canvasSize, MatType.CV_8UC3, Scalar.Black);
var vx = canvasSize / 2;
var vy = canvasSize / 2;
// Paste first frame
PasteMini(canvas, canvasSize, smallFrames[0], vx - sw / 2, vy - sh / 2);
const int templateW = 50;
const int templateH = 50;
const int searchMargin = 80; // at 1/4 scale ≈ 320px full res
var seamQualities = new List<double>();
for (int i = 1; i < smallFrames.Count; i++)
{
var frame = smallFrames[i];
// Find position via center template match against canvas
var tx = (sw - templateW) / 2;
var ty = (sh - templateH) / 2;
var halfW = sw / 2 + searchMargin;
var halfH = sh / 2 + searchMargin;
var sx0 = Math.Max(0, vx - halfW);
var sy0 = Math.Max(0, vy - halfH);
var sx1 = Math.Min(canvasSize, vx + halfW);
var sy1 = Math.Min(canvasSize, vy + halfH);
var sW = sx1 - sx0;
var sH = sy1 - sy0;
if (sW <= templateW || sH <= templateH) continue;
using var tmpl = new Mat(frame, new Rect(tx, ty, templateW, templateH));
using var searchRoi = new Mat(canvas, new Rect(sx0, sy0, sW, sH));
using var result = new Mat();
Cv2.MatchTemplate(searchRoi, tmpl, result, TemplateMatchModes.CCoeffNormed);
Cv2.MinMaxLoc(result, out _, out var maxVal, out _, out var maxLoc);
if (maxVal < 0.5) continue;
var frameX = sx0 + maxLoc.X - tx;
var frameY = sy0 + maxLoc.Y - ty;
var newVx = frameX + sw / 2;
var newVy = frameY + sh / 2;
// Skip near-static frames
if (Math.Abs(newVx - vx) < 5 && Math.Abs(newVy - vy) < 5) continue;
// Measure seam quality: pixel difference in overlap with existing canvas
var overlapX0 = Math.Max(frameX, 0);
var overlapY0 = Math.Max(frameY, 0);
var overlapX1 = Math.Min(frameX + sw, canvasSize);
var overlapY1 = Math.Min(frameY + sh, canvasSize);
var overlapW = overlapX1 - overlapX0;
var overlapH = overlapY1 - overlapY0;
if (overlapW > 20 && overlapH > 20)
{
var fsx = overlapX0 - frameX;
var fsy = overlapY0 - frameY;
using var canvasOverlap = new Mat(canvas, new Rect(overlapX0, overlapY0, overlapW, overlapH));
using var frameOverlap = new Mat(frame, new Rect(fsx, fsy, overlapW, overlapH));
// Only measure where canvas already has content (non-black)
using var gray = new Mat();
Cv2.CvtColor(canvasOverlap, gray, ColorConversionCodes.BGR2GRAY);
using var mask = new Mat();
Cv2.Threshold(gray, mask, 5, 255, ThresholdTypes.Binary);
var nonZero = Cv2.CountNonZero(mask);
if (nonZero > 500)
{
using var diff = new Mat();
Cv2.Absdiff(canvasOverlap, frameOverlap, diff);
var meanDiff = Cv2.Mean(diff, mask);
var avgDiff = (meanDiff.Val0 + meanDiff.Val1 + meanDiff.Val2) / 3.0;
seamQualities.Add(1.0 - avgDiff / 255.0);
}
}
// Paste frame onto canvas for future overlap comparisons
PasteMini(canvas, canvasSize, frame, frameX, frameY);
vx = newVx;
vy = newVy;
}
return seamQualities.Count > 0 ? seamQualities.Average() : 0;
}
finally
{
foreach (var s in smallFrames) s.Dispose();
warpMatrix?.Dispose();
}
}
private static void PasteMini(Mat canvas, int canvasSize, Mat frame, int canvasX, int canvasY)
{
var srcX = Math.Max(0, -canvasX);
var srcY = Math.Max(0, -canvasY);
var dstX = Math.Max(0, canvasX);
var dstY = Math.Max(0, canvasY);
var w = Math.Min(frame.Width - srcX, canvasSize - dstX);
var h = Math.Min(frame.Height - srcY, canvasSize - dstY);
if (w <= 0 || h <= 0) return;
using var srcRoi = new Mat(frame, new Rect(srcX, srcY, w, h));
using var dstRoi = new Mat(canvas, new Rect(dstX, dstY, w, h));
srcRoi.CopyTo(dstRoi);
}
public void Dispose()
{
foreach (var f in _frames) f.Dispose();
_frames.Clear();
_lastKept?.Dispose();
}
}

View file

@ -4,9 +4,11 @@ namespace Poe2Trade.Trade;
public interface ITradeMonitor : IAsyncDisposable
{
event Action<string, List<string>>? NewListings;
event Action<string, List<TradeItem>>? NewListings;
event Action<string, List<PricedTradeItem>>? DiamondListings;
Task Start(string? dashboardUrl = null);
Task AddSearch(string tradeUrl);
Task AddDiamondSearch(string tradeUrl);
Task PauseSearch(string searchId);
Task<bool> ClickTravelToHideout(string pageId, string? itemId = null);
Task<(string ScrapId, List<TradeItem> Items)> OpenScrapPage(string tradeUrl);

View file

@ -22,7 +22,8 @@ public class TradeDaemonBridge : ITradeMonitor
private readonly string _daemonScript;
private readonly string _nodeExe;
public event Action<string, List<string>>? NewListings;
public event Action<string, List<TradeItem>>? NewListings;
public event Action<string, List<PricedTradeItem>>? DiamondListings;
public TradeDaemonBridge(SavedSettings config)
{
@ -52,6 +53,12 @@ public class TradeDaemonBridge : ITradeMonitor
await SendCommand("addSearch", new { url = tradeUrl });
}
public async Task AddDiamondSearch(string tradeUrl)
{
EnsureDaemonRunning();
await SendCommand("addDiamondSearch", new { url = tradeUrl });
}
public async Task PauseSearch(string searchId)
{
EnsureDaemonRunning();
@ -293,14 +300,21 @@ public class TradeDaemonBridge : ITradeMonitor
{
case "newListings":
var searchId = root.GetProperty("searchId").GetString()!;
var itemIds = root.GetProperty("itemIds").EnumerateArray()
.Select(e => e.GetString()!)
.Where(s => s != null)
.ToList();
if (itemIds.Count > 0)
var tradeItems = ParseItems(root);
if (tradeItems.Count > 0)
{
Log.Information("New listings from daemon: {SearchId} ({Count} items)", searchId, itemIds.Count);
NewListings?.Invoke(searchId, itemIds);
Log.Information("New listings from daemon: {SearchId} ({Count} items)", searchId, tradeItems.Count);
NewListings?.Invoke(searchId, tradeItems);
}
break;
case "diamondListings":
var diamondSearchId = root.GetProperty("searchId").GetString()!;
var pricedItems = ParsePricedItems(root);
if (pricedItems.Count > 0)
{
Log.Information("Diamond listings from daemon: {SearchId} ({Count} items)", diamondSearchId, pricedItems.Count);
DiamondListings?.Invoke(diamondSearchId, pricedItems);
}
break;
@ -315,6 +329,29 @@ public class TradeDaemonBridge : ITradeMonitor
}
}
private static List<PricedTradeItem> ParsePricedItems(JsonElement resp)
{
var items = new List<PricedTradeItem>();
if (resp.TryGetProperty("items", out var arr) && arr.ValueKind == JsonValueKind.Array)
{
foreach (var el in arr.EnumerateArray())
{
items.Add(new PricedTradeItem(
el.GetProperty("id").GetString() ?? "",
el.TryGetProperty("w", out var w) ? w.GetInt32() : 1,
el.TryGetProperty("h", out var h) ? h.GetInt32() : 1,
el.TryGetProperty("stashX", out var sx) ? sx.GetInt32() : 0,
el.TryGetProperty("stashY", out var sy) ? sy.GetInt32() : 0,
el.TryGetProperty("account", out var acc) ? acc.GetString() ?? "" : "",
el.TryGetProperty("name", out var name) ? name.GetString() ?? "" : "",
el.TryGetProperty("priceAmount", out var pa) ? pa.GetDouble() : 0,
el.TryGetProperty("priceCurrency", out var pc) ? pc.GetString() ?? "" : ""
));
}
}
return items;
}
private static List<TradeItem> ParseItems(JsonElement resp)
{
var items = new List<TradeItem>();

View file

@ -58,6 +58,7 @@ public partial class App : Application
services.AddSingleton<DebugViewModel>();
services.AddSingleton<SettingsViewModel>();
services.AddSingleton<MappingViewModel>();
services.AddSingleton<AtlasViewModel>();
var provider = services.BuildServiceProvider();
@ -68,6 +69,7 @@ public partial class App : Application
mainVm.DebugVm = provider.GetRequiredService<DebugViewModel>();
mainVm.SettingsVm = provider.GetRequiredService<SettingsViewModel>();
mainVm.MappingVm = provider.GetRequiredService<MappingViewModel>();
mainVm.AtlasVm = provider.GetRequiredService<AtlasViewModel>();
var window = new MainWindow { DataContext = mainVm };
window.SetConfigStore(store);

View file

@ -45,7 +45,7 @@ public class LinkModeToColorConverter : IValueConverter
{
LinkMode.Live => new SolidColorBrush(Color.Parse("#1f6feb")),
LinkMode.Scrap => new SolidColorBrush(Color.Parse("#9e6a03")),
LinkMode.Diamond => new SolidColorBrush(Color.Parse("#8957e5")),
_ => new SolidColorBrush(Color.Parse("#30363d")),
};
}

View file

@ -0,0 +1,150 @@
using System.IO;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Poe2Trade.Bot;
using Poe2Trade.Navigation;
using Serilog;
namespace Poe2Trade.Ui.ViewModels;
public partial class AtlasViewModel : ObservableObject, IDisposable
{
private readonly BotOrchestrator _bot;
private readonly CancellationTokenSource _pollCts = new();
[ObservableProperty] private Bitmap? _canvasImage;
[ObservableProperty] private bool _isCapturing;
[ObservableProperty] private string _atlasStatus = "";
[ObservableProperty] private int _tilesCaptured;
public AtlasViewModel(BotOrchestrator bot)
{
_bot = bot;
_ = PollLoop(_pollCts.Token);
}
[RelayCommand]
private async Task CaptureAtlas()
{
if (IsCapturing) return;
IsCapturing = true;
AtlasStatus = "Starting...";
TilesCaptured = 0;
void OnProgress(AtlasProgress p)
{
Dispatcher.UIThread.Post(() =>
{
AtlasStatus = $"{p.Phase} ({p.TilesCaptured} tiles)";
TilesCaptured = p.TilesCaptured;
});
}
_bot.AtlasExecutor.ProgressUpdated += OnProgress;
try
{
var path = await Task.Run(() => _bot.AtlasExecutor.CaptureAtlasPanorama());
AtlasStatus = path != null ? $"Saved: {path}" : "Capture failed or cancelled";
}
catch (Exception ex)
{
AtlasStatus = $"Error: {ex.Message}";
}
finally
{
_bot.AtlasExecutor.ProgressUpdated -= OnProgress;
IsCapturing = false;
}
}
[RelayCommand]
private void StopAtlasCapture()
{
_bot.AtlasExecutor.Stop();
AtlasStatus = "Stopping...";
}
[RelayCommand]
private async Task CalibratePerspective()
{
if (IsCapturing) return;
IsCapturing = true;
AtlasStatus = "Auto-scrolling atlas...";
TilesCaptured = 0;
void OnProgress(AtlasProgress p)
{
Dispatcher.UIThread.Post(() =>
{
AtlasStatus = $"{p.Phase} ({p.TilesCaptured} frames)";
TilesCaptured = p.TilesCaptured;
});
}
_bot.AtlasExecutor.ProgressUpdated += OnProgress;
try
{
var result = await Task.Run(() => _bot.AtlasExecutor.CalibratePerspective());
if (result != null)
AtlasStatus = $"Best factor: {result.BestFactor:F3} (conf: {result.BestConfidence:F4})";
else
AtlasStatus = "Calibration cancelled (not enough frames)";
}
catch (Exception ex)
{
AtlasStatus = $"Calibration error: {ex.Message}";
Log.Error(ex, "Calibration failed");
}
finally
{
_bot.AtlasExecutor.ProgressUpdated -= OnProgress;
IsCapturing = false;
}
}
/// <summary>
/// Poll ActivePanorama for viewport snapshots at ~2fps while capturing.
/// </summary>
private async Task PollLoop(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try
{
var bytes = _bot.AtlasExecutor.ActivePanorama?.GetViewportSnapshot();
if (bytes != null)
{
var bmp = new Bitmap(new MemoryStream(bytes));
Dispatcher.UIThread.Post(() =>
{
var old = CanvasImage;
CanvasImage = bmp;
TilesCaptured = _bot.AtlasExecutor.ActivePanorama?.TilesCaptured ?? TilesCaptured;
old?.Dispose();
});
}
await Task.Delay(500, ct);
}
catch (OperationCanceledException) { break; }
catch (Exception ex)
{
Log.Debug(ex, "Atlas poll error");
await Task.Delay(1000, ct);
}
}
}
public void Dispose()
{
_pollCts.Cancel();
_pollCts.Dispose();
CanvasImage?.Dispose();
}
}

View file

@ -27,6 +27,64 @@ public partial class CellState : ObservableObject
[ObservableProperty] private bool _borderRight;
}
public partial class TradeLinkViewModel : ObservableObject
{
private readonly TradeLink _model;
private readonly Action<TradeLinkViewModel, string> _onChanged;
private bool _syncing;
[ObservableProperty] private string _name;
[ObservableProperty] private LinkMode _mode;
[ObservableProperty] private bool _active;
[ObservableProperty] private bool _isExpanded;
public string Id => _model.Id;
public string Url => _model.Url;
public string Label => _model.Label;
public bool IsDiamond => Mode == LinkMode.Diamond;
public TradeLinkViewModel(TradeLink model, Action<TradeLinkViewModel, string> onChanged)
{
_model = model;
_onChanged = onChanged;
_name = model.Name;
_mode = model.Mode;
_active = model.Active;
}
public void SyncFrom(TradeLink model)
{
_syncing = true;
Name = model.Name;
Mode = model.Mode;
Active = model.Active;
_syncing = false;
OnPropertyChanged(nameof(Label));
}
partial void OnNameChanged(string value)
{
if (_syncing) return;
_model.Name = value;
_onChanged(this, nameof(Name));
}
partial void OnModeChanged(LinkMode value)
{
OnPropertyChanged(nameof(IsDiamond));
if (_syncing) return;
_model.Mode = value;
_onChanged(this, nameof(Mode));
}
partial void OnActiveChanged(bool value)
{
if (_syncing) return;
_model.Active = value;
_onChanged(this, nameof(Active));
}
}
public partial class MainWindowViewModel : ObservableObject
{
private readonly BotOrchestrator _bot;
@ -63,7 +121,7 @@ public partial class MainWindowViewModel : ObservableObject
[ObservableProperty] private int _activeLinksCount;
[ObservableProperty] private BotMode _botMode;
public static LinkMode[] LinkModes { get; } = [LinkMode.Live, LinkMode.Scrap];
public static LinkMode[] LinkModes { get; } = [LinkMode.Live, LinkMode.Scrap, LinkMode.Diamond];
public static BotMode[] BotModes { get; } = [BotMode.Trading, BotMode.Mapping];
public MainWindowViewModel(BotOrchestrator bot)
@ -75,6 +133,9 @@ public partial class MainWindowViewModel : ObservableObject
for (var i = 0; i < 60; i++)
InventoryCells.Add(new CellState());
// Pre-populate links from config
SyncLinks();
bot.StatusUpdated += () =>
{
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
@ -86,7 +147,7 @@ public partial class MainWindowViewModel : ObservableObject
TradesCompleted = status.TradesCompleted;
TradesFailed = status.TradesFailed;
ActiveLinksCount = status.Links.Count(l => l.Active);
OnPropertyChanged(nameof(Links));
SyncLinks();
UpdateInventoryGrid();
});
};
@ -110,7 +171,7 @@ public partial class MainWindowViewModel : ObservableObject
}
public string PauseButtonText => IsPaused ? "Resume" : "Pause";
public List<TradeLink> Links => _bot.Links.GetLinks();
public ObservableCollection<TradeLinkViewModel> Links { get; } = [];
public ObservableCollection<LogEntry> Logs { get; } = [];
public ObservableCollection<CellState> InventoryCells { get; } = [];
public int InventoryFreeCells => _bot.IsReady ? _bot.Inventory.Tracker.FreeCells : 60;
@ -119,6 +180,7 @@ public partial class MainWindowViewModel : ObservableObject
public DebugViewModel? DebugVm { get; set; }
public SettingsViewModel? SettingsVm { get; set; }
public MappingViewModel? MappingVm { get; set; }
public AtlasViewModel? AtlasVm { get; set; }
partial void OnBotModeChanged(BotMode value)
{
@ -162,6 +224,7 @@ public partial class MainWindowViewModel : ObservableObject
{
if (string.IsNullOrWhiteSpace(NewUrl)) return;
_bot.AddLink(NewUrl, NewLinkName, NewLinkMode);
SyncLinks();
NewUrl = "";
NewLinkName = "";
}
@ -169,15 +232,48 @@ public partial class MainWindowViewModel : ObservableObject
[RelayCommand]
private void RemoveLink(string? id)
{
if (id != null) _bot.RemoveLink(id);
if (id == null) return;
_bot.RemoveLink(id);
SyncLinks();
}
[RelayCommand]
private void ToggleLink(string? id)
private void SyncLinks()
{
if (id == null) return;
var link = _bot.Links.GetLink(id);
if (link != null) _bot.ToggleLink(id, !link.Active);
var current = _bot.Links.GetLinks();
var currentIds = new HashSet<string>(current.Select(l => l.Id));
// Remove gone
for (var i = Links.Count - 1; i >= 0; i--)
{
if (!currentIds.Contains(Links[i].Id))
Links.RemoveAt(i);
}
// Add new, update existing
foreach (var model in current)
{
var existing = Links.FirstOrDefault(l => l.Id == model.Id);
if (existing != null)
existing.SyncFrom(model);
else
Links.Add(new TradeLinkViewModel(model, OnLinkChanged));
}
}
private void OnLinkChanged(TradeLinkViewModel vm, string prop)
{
switch (prop)
{
case nameof(TradeLinkViewModel.Name):
_bot.Links.UpdateName(vm.Id, vm.Name);
break;
case nameof(TradeLinkViewModel.Mode):
_bot.ChangeLinkMode(vm.Id, vm.Mode);
break;
case nameof(TradeLinkViewModel.Active):
_bot.ToggleLink(vm.Id, vm.Active);
break;
}
}
private async Task RunBackgroundLoop(CancellationToken ct)
@ -193,9 +289,7 @@ public partial class MainWindowViewModel : ObservableObject
if (endDown && !f12WasDown)
{
Log.Information("END pressed — emergency stop");
await _bot.Navigation.Stop();
_bot.KulemakExecutor.Stop();
_bot.Pause();
await _bot.EmergencyStop();
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
IsPaused = true;

View file

@ -25,11 +25,11 @@ public partial class SettingsViewModel : ObservableObject
[ObservableProperty] private string _calibrationStatus = "";
[ObservableProperty] private string _stashCalibratedAt = "";
[ObservableProperty] private string _shopCalibratedAt = "";
public static string[] OcrEngineOptions { get; } = ["WinOCR", "OneOCR", "EasyOCR"];
public ObservableCollection<StashTabViewModel> StashTabs { get; } = [];
public ObservableCollection<StashTabViewModel> ShopTabs { get; } = [];
public ObservableCollection<DiamondPriceViewModel> DiamondPrices { get; } = [];
public SettingsViewModel(BotOrchestrator bot)
{
@ -50,6 +50,9 @@ public partial class SettingsViewModel : ObservableObject
Headless = s.Headless;
ShowHudDebug = s.ShowHudDebug;
OcrEngine = s.OcrEngine;
DiamondPrices.Clear();
foreach (var p in s.Diamond.Prices)
DiamondPrices.Add(new DiamondPriceViewModel(p));
}
private void LoadTabs()
@ -102,6 +105,7 @@ public partial class SettingsViewModel : ObservableObject
s.Headless = Headless;
s.ShowHudDebug = ShowHudDebug;
s.OcrEngine = OcrEngine;
s.Diamond.Prices = DiamondPrices.Select(p => p.ToModel()).ToList();
});
IsSaved = true;
@ -207,6 +211,20 @@ public partial class SettingsViewModel : ObservableObject
}
}
[RelayCommand]
private void AddDiamondPrice()
{
DiamondPrices.Add(new DiamondPriceViewModel(new DiamondPriceConfig()));
IsSaved = false;
}
[RelayCommand]
private void RemoveDiamondPrice(DiamondPriceViewModel? item)
{
if (item != null) DiamondPrices.Remove(item);
IsSaved = false;
}
partial void OnPoe2LogPathChanged(string value) => IsSaved = false;
partial void OnWindowTitleChanged(string value) => IsSaved = false;
partial void OnTravelTimeoutMsChanged(decimal? value) => IsSaved = false;
@ -218,3 +236,29 @@ public partial class SettingsViewModel : ObservableObject
partial void OnShowHudDebugChanged(bool value) => IsSaved = false;
partial void OnOcrEngineChanged(string value) => IsSaved = false;
}
public partial class DiamondPriceViewModel : ObservableObject
{
[ObservableProperty] private string _itemName;
[ObservableProperty] private string _displayName;
[ObservableProperty] private decimal? _maxDivinePrice;
[ObservableProperty] private bool _enabled;
public DiamondPriceViewModel(DiamondPriceConfig model)
{
_itemName = model.ItemName;
_displayName = !string.IsNullOrEmpty(model.DisplayName)
? model.DisplayName
: DiamondSettings.KnownDiamonds.GetValueOrDefault(model.ItemName, "");
_maxDivinePrice = (decimal)model.MaxDivinePrice;
_enabled = model.Enabled;
}
public DiamondPriceConfig ToModel() => new()
{
ItemName = ItemName,
DisplayName = DisplayName,
MaxDivinePrice = (double)(MaxDivinePrice ?? 0),
Enabled = Enabled,
};
}

View file

@ -177,15 +177,21 @@
<ScrollViewer>
<ItemsControl x:Name="LinksControl" ItemsSource="{Binding Links}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Margin="0,2" Padding="6" Background="#21262d"
CornerRadius="4"
<DataTemplate x:DataType="vm:TradeLinkViewModel">
<Border Margin="0,2" Background="#21262d" CornerRadius="4"
Opacity="{Binding Active, Converter={StaticResource ActiveOpacity}}">
<DockPanel>
<StackPanel>
<!-- Header row -->
<DockPanel Margin="6">
<Button DockPanel.Dock="Right" Content="X" FontSize="10"
VerticalAlignment="Center"
Command="{ReflectionBinding #LinksControl.DataContext.RemoveLinkCommand}"
CommandParameter="{Binding Id}" />
<ToggleButton DockPanel.Dock="Right"
IsChecked="{Binding IsExpanded}"
Content="..." FontSize="10"
Padding="6,2" Margin="0,0,6,0"
VerticalAlignment="Center" />
<CheckBox DockPanel.Dock="Left"
IsChecked="{Binding Active}"
Margin="0,0,8,0" VerticalAlignment="Center" />
@ -204,6 +210,74 @@
Foreground="#8b949e" TextTrimming="CharacterEllipsis" />
</StackPanel>
</DockPanel>
<!-- Expanded settings -->
<Border IsVisible="{Binding IsExpanded}"
Background="#161b22" Padding="8,6"
Margin="4,0,4,4" CornerRadius="4">
<StackPanel Spacing="6">
<DockPanel>
<TextBlock Text="Name" FontSize="11" Foreground="#8b949e"
Width="50" VerticalAlignment="Center" />
<TextBox Text="{Binding Name}" />
</DockPanel>
<DockPanel>
<TextBlock Text="URL" FontSize="11" Foreground="#8b949e"
Width="50" VerticalAlignment="Center" />
<TextBox Text="{Binding Url}" IsReadOnly="True"
FontSize="10" Foreground="#8b949e" />
</DockPanel>
<DockPanel>
<TextBlock Text="Mode" FontSize="11" Foreground="#8b949e"
Width="50" VerticalAlignment="Center" />
<ComboBox ItemsSource="{x:Static vm:MainWindowViewModel.LinkModes}"
SelectedItem="{Binding Mode}" MinWidth="120" />
</DockPanel>
<!-- Diamond price settings (visible when Mode == Diamond) -->
<StackPanel IsVisible="{Binding IsDiamond}" Spacing="6"
Margin="0,4,0,0">
<TextBlock Text="DIAMOND PRICES" FontSize="11"
FontWeight="SemiBold" Foreground="#8957e5" />
<ItemsControl x:Name="InlineDiamondPrices"
ItemsSource="{ReflectionBinding #LinksControl.DataContext.SettingsVm.DiamondPrices}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:DiamondPriceViewModel">
<Border Margin="0,2" Padding="6" Background="#21262d" CornerRadius="3">
<DockPanel>
<Button DockPanel.Dock="Right" Content="X" FontSize="9"
Padding="4,1" Margin="4,0,0,0"
VerticalAlignment="Center"
Command="{ReflectionBinding #LinksControl.DataContext.SettingsVm.RemoveDiamondPriceCommand}"
CommandParameter="{Binding}" />
<CheckBox DockPanel.Dock="Left" IsChecked="{Binding Enabled}"
Margin="0,0,6,0" VerticalAlignment="Center" />
<StackPanel DockPanel.Dock="Left" Margin="0,0,8,0"
VerticalAlignment="Center" Width="160">
<TextBlock Text="{Binding DisplayName}" FontSize="12"
FontWeight="SemiBold" Foreground="#e6edf3" />
<TextBlock Text="{Binding ItemName}" FontSize="10"
Foreground="#484f58" />
</StackPanel>
<NumericUpDown Value="{Binding MaxDivinePrice}"
Minimum="0" Maximum="9999" Increment="1"
Width="140" FontSize="11"
VerticalAlignment="Center" />
</DockPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Content="+ Add" FontSize="11" Padding="8,3"
Command="{ReflectionBinding #LinksControl.DataContext.SettingsVm.AddDiamondPriceCommand}" />
<Button Content="Save" FontSize="11" Padding="8,3"
Command="{ReflectionBinding #LinksControl.DataContext.SettingsVm.SaveSettingsCommand}" />
</StackPanel>
</StackPanel>
</StackPanel>
</Border>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
@ -304,6 +378,44 @@
</ScrollViewer>
</TabItem>
<!-- ========== ATLAS TAB ========== -->
<TabItem Header="Atlas">
<DockPanel DataContext="{Binding AtlasVm}" Margin="0,6,0,0"
x:DataType="vm:AtlasViewModel">
<!-- Top bar: buttons + status -->
<Border DockPanel.Dock="Top" Background="#161b22" BorderBrush="#30363d"
BorderThickness="1" CornerRadius="8" Padding="8" Margin="0,0,0,6">
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Capture Atlas"
Command="{Binding CaptureAtlasCommand}"
IsEnabled="{Binding !IsCapturing}" />
<Button Content="Calibrate"
Command="{Binding CalibratePerspectiveCommand}"
IsEnabled="{Binding !IsCapturing}" />
<Button Content="Stop"
Command="{Binding StopAtlasCaptureCommand}"
IsVisible="{Binding IsCapturing}" />
<TextBlock Text="{Binding AtlasStatus}" FontSize="12"
Foreground="#8b949e" VerticalAlignment="Center" />
<TextBlock Text="{Binding TilesCaptured, StringFormat='{}{0} tiles'}"
FontSize="12" Foreground="#58a6ff" VerticalAlignment="Center" />
</StackPanel>
</Border>
<!-- Canvas image fills remaining space -->
<Border Background="#161b22" BorderBrush="#30363d"
BorderThickness="1" CornerRadius="8" Padding="4">
<Grid>
<Image Source="{Binding CanvasImage}" Stretch="Uniform"
RenderOptions.BitmapInterpolationMode="HighQuality" />
<TextBlock Text="Click 'Capture Atlas' to start"
IsVisible="{Binding CanvasImage, Converter={x:Static ObjectConverters.IsNull}}"
HorizontalAlignment="Center" VerticalAlignment="Center"
FontSize="14" Foreground="#484f58" />
</Grid>
</Border>
</DockPanel>
</TabItem>
<!-- ========== DEBUG TAB ========== -->
<TabItem Header="Debug">
<ScrollViewer DataContext="{Binding DebugVm}" Margin="0,6,0,0">

View file

@ -68,6 +68,32 @@ function parseTradeItem(r) {
return { id, w, h, stashX, stashY, account };
}
function parseTradeItemWithPrice(r) {
const base = parseTradeItem(r);
let name = "";
let priceAmount = 0;
let priceCurrency = "";
if (r.item?.icon) {
try {
const pathname = new URL(r.item.icon).pathname;
const filename = pathname.split("/").pop() || "";
name = filename.replace(".png", "");
} catch {
// fallback: regex on last path segment
const match = r.item.icon.match(/\/([^/]+)\.png/);
if (match) name = match[1];
}
}
if (r.listing?.price) {
if (r.listing.price.amount != null) priceAmount = r.listing.price.amount;
if (r.listing.price.currency) priceCurrency = r.listing.price.currency;
}
return { ...base, name, priceAmount, priceCurrency };
}
async function waitForVisible(locator, timeoutMs) {
try {
await locator.waitFor({ state: "visible", timeout: timeoutMs });
@ -97,19 +123,8 @@ function handleWebSocket(ws, searchId) {
ws.on("framereceived", (frame) => {
if (pausedSearches.has(searchId)) return;
try {
const payload = typeof frame === "string" ? frame : frame.payload?.toString() ?? "";
const doc = JSON.parse(payload);
if (doc.new && Array.isArray(doc.new)) {
const ids = doc.new.filter((s) => s != null);
if (ids.length > 0) {
log(`New listings: ${searchId} (${ids.length} items)`);
sendEvent("newListings", { searchId, itemIds: ids });
}
}
} catch {
/* Non-JSON WebSocket frame */
}
// Note: trade site now sends JWTs, not raw item IDs.
// We rely on the fetch response interceptor instead.
});
ws.on("close", () => {
@ -160,14 +175,42 @@ async function cmdAddSearch(reqId, params) {
const page = await context.newPage();
searchPages.set(searchId, page);
// Register WebSocket handler BEFORE navigation so we catch connections during page load
page.on("websocket", (ws) => handleWebSocket(ws, searchId));
// Track whether live search is active — don't emit items from initial page load
let liveActive = false;
const seenIds = new Set();
// Intercept fetch responses to get full item data (WebSocket now sends JWTs, not raw IDs)
page.on("response", async (response) => {
if (!liveActive) return;
if (!response.url().includes("/api/trade2/fetch/")) return;
try {
const body = await response.text();
const doc = JSON.parse(body);
if (doc.result && Array.isArray(doc.result)) {
const items = doc.result
.map((r) => parseTradeItem(r))
.filter((i) => i.id && !seenIds.has(i.id));
items.forEach((i) => seenIds.add(i.id));
if (items.length > 0) {
log(`New listings (fetch): ${searchId} (${items.length} new items)`);
sendEvent("newListings", { searchId, items });
}
}
} catch {
/* Non-JSON trade response */
}
});
await page.goto(url, { waitUntil: "networkidle" });
await new Promise((r) => setTimeout(r, 2000)); // PageLoad delay
page.on("websocket", (ws) => handleWebSocket(ws, searchId));
try {
const liveBtn = page.locator(Selectors.LiveSearchButton).first();
await liveBtn.click({ timeout: 5000 });
liveActive = true;
log(`Live search activated: ${searchId}`);
} catch {
log(`Could not click Activate Live Search: ${searchId}`);
@ -222,6 +265,56 @@ async function cmdClickTravel(reqId, params) {
}
}
async function cmdAddDiamondSearch(reqId, params) {
if (!context) throw new Error("Browser not started");
const { url } = params;
const searchId = extractSearchId(url);
if (searchPages.has(searchId)) {
log(`Diamond search already open: ${searchId}`);
sendResponse(reqId, { searchId });
return;
}
log(`Adding diamond search: ${url} (${searchId})`);
const page = await context.newPage();
searchPages.set(searchId, page);
// Intercept fetch responses for item data + prices
page.on("response", async (response) => {
if (!response.url().includes("/api/trade2/fetch/")) return;
try {
const body = await response.text();
const doc = JSON.parse(body);
if (doc.result && Array.isArray(doc.result)) {
const items = doc.result.map((r) => parseTradeItemWithPrice(r));
if (items.length > 0) {
log(`Diamond listings: ${searchId} (${items.length} items)`);
sendEvent("diamondListings", { searchId, items });
}
}
} catch {
/* Non-JSON trade response */
}
});
// Hook WebSocket BEFORE navigation so we catch connections during page load
page.on("websocket", (ws) => handleWebSocket(ws, searchId));
await page.goto(url, { waitUntil: "networkidle" });
await new Promise((r) => setTimeout(r, 2000));
try {
const liveBtn = page.locator(Selectors.LiveSearchButton).first();
await liveBtn.click({ timeout: 5000 });
log(`Diamond live search activated: ${searchId}`);
} catch {
log(`Could not click Activate Live Search: ${searchId}`);
}
sendResponse(reqId, { searchId });
}
async function cmdOpenScrapPage(reqId, params) {
if (!context) throw new Error("Browser not started");
const { url } = params;
@ -339,6 +432,7 @@ async function cmdStop(reqId) {
const handlers = {
start: cmdStart,
addSearch: cmdAddSearch,
addDiamondSearch: cmdAddDiamondSearch,
pauseSearch: cmdPauseSearch,
clickTravel: cmdClickTravel,
openScrapPage: cmdOpenScrapPage,