diff --git a/assets/merchant.png b/assets/merchant.png new file mode 100644 index 0000000..db0d74c Binary files /dev/null and b/assets/merchant.png differ diff --git a/atlas/atlas-20260224-135428.png b/atlas/atlas-20260224-135428.png new file mode 100644 index 0000000..7daa245 Binary files /dev/null and b/atlas/atlas-20260224-135428.png differ diff --git a/atlas/atlas-20260224-142451.png b/atlas/atlas-20260224-142451.png new file mode 100644 index 0000000..d62d65f Binary files /dev/null and b/atlas/atlas-20260224-142451.png differ diff --git a/debug_loot_capture.png b/debug_loot_capture.png index caf2708..ce062e7 100644 Binary files a/debug_loot_capture.png and b/debug_loot_capture.png differ diff --git a/debug_loot_detected.png b/debug_loot_detected.png index d4a2737..e96616c 100644 Binary files a/debug_loot_detected.png and b/debug_loot_detected.png differ diff --git a/debug_loot_edges.png b/debug_loot_edges.png index 68e41d6..25d92de 100644 Binary files a/debug_loot_edges.png and b/debug_loot_edges.png differ diff --git a/src/Poe2Trade.Bot/AtlasExecutor.cs b/src/Poe2Trade.Bot/AtlasExecutor.cs new file mode 100644 index 0000000..b31aece --- /dev/null +++ b/src/Poe2Trade.Bot/AtlasExecutor.cs @@ -0,0 +1,225 @@ +using Poe2Trade.Core; +using Poe2Trade.Game; +using Poe2Trade.Inventory; +using Poe2Trade.Navigation; +using Poe2Trade.Screen; +using Serilog; + +namespace Poe2Trade.Bot; + +/// +/// Captures the full endgame atlas as a panorama image. +/// Registers AtlasPanorama as a pipeline consumer so it shares the single DXGI capture. +/// +public class AtlasExecutor : GameExecutor +{ + private readonly FramePipeline _pipeline; + + public event Action? ProgressUpdated; + public event Action? StateChanged; + + public AtlasPanorama? ActivePanorama { get; private set; } + + /// + /// Perspective correction factor. Updated by CalibratePerspective(), used by CaptureAtlasPanorama(). + /// + 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); + } + + /// + /// 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. + /// + public async Task 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(); + } + } + + /// + /// Auto-scroll the atlas in 4 directions, collect frames, and test different + /// perspective factors to find the optimal one. Updates PerspectiveFactor on success. + /// + public async Task 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(); + } + } + + /// + /// Click-drag the atlas in 4 directions (right, down, left, up) to collect + /// frames with movement in all directions for calibration. + /// + 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); + } +} diff --git a/src/Poe2Trade.Bot/BotOrchestrator.cs b/src/Poe2Trade.Bot/BotOrchestrator.cs index 625d1e3..605c6b4 100644 --- a/src/Poe2Trade.Bot/BotOrchestrator.cs +++ b/src/Poe2Trade.Bot/BotOrchestrator.cs @@ -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 _scrapExecutors = new(); + private readonly Dictionary _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(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 itemIds) + private void OnNewListings(string searchId, List 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 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); } diff --git a/src/Poe2Trade.Bot/DiamondExecutor.cs b/src/Poe2Trade.Bot/DiamondExecutor.cs new file mode 100644 index 0000000..f3e9833 --- /dev/null +++ b/src/Poe2Trade.Bot/DiamondExecutor.cs @@ -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 _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? 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 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 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 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); + } + } +} diff --git a/src/Poe2Trade.Bot/GameExecutor.cs b/src/Poe2Trade.Bot/GameExecutor.cs index 44d4ee4..0d8b143 100644 --- a/src/Poe2Trade.Bot/GameExecutor.cs +++ b/src/Poe2Trade.Bot/GameExecutor.cs @@ -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()); diff --git a/src/Poe2Trade.Bot/KulemakExecutor.cs b/src/Poe2Trade.Bot/KulemakExecutor.cs index 266903e..d80c99e 100644 --- a/src/Poe2Trade.Bot/KulemakExecutor.cs +++ b/src/Poe2Trade.Bot/KulemakExecutor.cs @@ -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 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; diff --git a/src/Poe2Trade.Bot/TradeExecutor.cs b/src/Poe2Trade.Bot/TradeExecutor.cs index d0f9183..66a7384 100644 --- a/src/Poe2Trade.Bot/TradeExecutor.cs +++ b/src/Poe2Trade.Bot/TradeExecutor.cs @@ -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? 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 TravelToSeller(TradeInfo trade) + /// + /// Polls a small screen region for the merchant template. + /// Returns true as soon as the template is detected. + /// + private async Task 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) + { + try { - if (!await _tradeMonitor.ClickTravelToHideout(trade.SearchId, 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; + 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; + } + } + catch (Exception ex) + { + Log.Debug(ex, "Merchant poll error"); + } + + await Task.Delay(MerchantPollMs); } - SetState(TradeState.InSellersHideout); - _inventory.SetLocation(false); - Log.Information("Arrived at seller hideout"); - return true; - } - - private async Task 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; + 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); - } } diff --git a/src/Poe2Trade.Bot/TradeQueue.cs b/src/Poe2Trade.Bot/TradeQueue.cs index 134f3b0..b3eb16a 100644 --- a/src/Poe2Trade.Bot/TradeQueue.cs +++ b/src/Poe2Trade.Bot/TradeQueue.cs @@ -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) { diff --git a/src/Poe2Trade.Core/ConfigStore.cs b/src/Poe2Trade.Core/ConfigStore.cs index 933c68c..14107e4 100644 --- a/src/Poe2Trade.Core/ConfigStore.cs +++ b/src/Poe2Trade.Core/ConfigStore.cs @@ -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 KnownDiamonds = new() + { + ["SanctumJewel"] = "Time-Lost Diamond", + ["SacredFlameJewel"] = "Prism of Belief", + ["TrialmasterJewel"] = "The Adorned", + ["DeliriumJewel"] = "Megalomaniac Diamond", + ["ApostatesHeart"] = "Heart of the Well", + }; + + public List Prices { get; set; } = DefaultPrices(); + + private static List DefaultPrices() => + KnownDiamonds.Select(kv => new DiamondPriceConfig + { + ItemName = kv.Key, + DisplayName = kv.Value, + MaxDivinePrice = 0, + Enabled = false, + }).ToList(); + + /// Ensure all known diamonds exist in the list (adds missing ones as disabled). + public void BackfillKnown() + { + // Remove blank entries + Prices.RemoveAll(p => string.IsNullOrWhiteSpace(p.ItemName)); + + var existing = new HashSet(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; } diff --git a/src/Poe2Trade.Core/LinkManager.cs b/src/Poe2Trade.Core/LinkManager.cs index 0837609..87b10cd 100644 --- a/src/Poe2Trade.Core/LinkManager.cs +++ b/src/Poe2Trade.Core/LinkManager.cs @@ -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) diff --git a/src/Poe2Trade.Core/Types.cs b/src/Poe2Trade.Core/Types.cs index 677f08a..3287afd 100644 --- a/src/Poe2Trade.Core/Types.cs +++ b/src/Poe2Trade.Core/Types.cs @@ -4,10 +4,8 @@ public record Region(int X, int Y, int Width, int Height); public record TradeInfo( string SearchId, - List ItemIds, - string WhisperText, - long Timestamp, - string TradeUrl + List 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, diff --git a/src/Poe2Trade.Inventory/InventoryManager.cs b/src/Poe2Trade.Inventory/InventoryManager.cs index a47582c..bb67f0a 100644 --- a/src/Poe2Trade.Inventory/InventoryManager.cs +++ b/src/Poe2Trade.Inventory/InventoryManager.cs @@ -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"); diff --git a/src/Poe2Trade.Navigation/AtlasPanorama.cs b/src/Poe2Trade.Navigation/AtlasPanorama.cs new file mode 100644 index 0000000..78192e3 --- /dev/null +++ b/src/Poe2Trade.Navigation/AtlasPanorama.cs @@ -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); + +/// +/// Stitches atlas frames into a panorama canvas. +/// Implements IFrameConsumer so it receives shared pipeline frames — no separate DXGI needed. +/// +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? 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; + } + + /// + /// IFrameConsumer — called by the pipeline with each shared screen frame. + /// Crops the atlas region and stitches it onto the canvas. + /// + 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); + } + + /// + /// 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. + /// + 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; + } + + /// + /// Wait until cancelled. Stitching happens via Process() on the pipeline thread. + /// + 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); + } + + /// + /// Return a PNG of the full canvas built so far, downscaled to fit maxDim. + /// + 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; + } + + /// + /// Encode the trimmed canvas as PNG bytes. + /// + public byte[] GetResultPng() + { + using var trimmed = TrimCanvas(); + Cv2.ImEncode(".png", trimmed, out var buf); + return buf; + } + + /// + /// Save the trimmed canvas to a file. + /// + 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); + } + + /// + /// Template-match at reduced resolution, then paste at full resolution. + /// + 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(); + } +} diff --git a/src/Poe2Trade.Navigation/PerspectiveCalibrator.cs b/src/Poe2Trade.Navigation/PerspectiveCalibrator.cs new file mode 100644 index 0000000..fe1e9ac --- /dev/null +++ b/src/Poe2Trade.Navigation/PerspectiveCalibrator.cs @@ -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 AllResults); + +/// +/// 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. +/// +public class PerspectiveCalibrator : IFrameConsumer, IDisposable +{ + private readonly List _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? 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(); + + // 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); + } + + /// + /// 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. + /// + 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(); + 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(); + + 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(); + } +} diff --git a/src/Poe2Trade.Trade/ITradeMonitor.cs b/src/Poe2Trade.Trade/ITradeMonitor.cs index 726350b..24a0917 100644 --- a/src/Poe2Trade.Trade/ITradeMonitor.cs +++ b/src/Poe2Trade.Trade/ITradeMonitor.cs @@ -4,9 +4,11 @@ namespace Poe2Trade.Trade; public interface ITradeMonitor : IAsyncDisposable { - event Action>? NewListings; + event Action>? NewListings; + event Action>? DiamondListings; Task Start(string? dashboardUrl = null); Task AddSearch(string tradeUrl); + Task AddDiamondSearch(string tradeUrl); Task PauseSearch(string searchId); Task ClickTravelToHideout(string pageId, string? itemId = null); Task<(string ScrapId, List Items)> OpenScrapPage(string tradeUrl); diff --git a/src/Poe2Trade.Trade/TradeDaemonBridge.cs b/src/Poe2Trade.Trade/TradeDaemonBridge.cs index 080d5b1..459ff89 100644 --- a/src/Poe2Trade.Trade/TradeDaemonBridge.cs +++ b/src/Poe2Trade.Trade/TradeDaemonBridge.cs @@ -22,7 +22,8 @@ public class TradeDaemonBridge : ITradeMonitor private readonly string _daemonScript; private readonly string _nodeExe; - public event Action>? NewListings; + public event Action>? NewListings; + public event Action>? 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 ParsePricedItems(JsonElement resp) + { + var items = new List(); + 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 ParseItems(JsonElement resp) { var items = new List(); diff --git a/src/Poe2Trade.Ui/App.axaml.cs b/src/Poe2Trade.Ui/App.axaml.cs index 6a3c9c2..590fa93 100644 --- a/src/Poe2Trade.Ui/App.axaml.cs +++ b/src/Poe2Trade.Ui/App.axaml.cs @@ -58,6 +58,7 @@ public partial class App : Application services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); var provider = services.BuildServiceProvider(); @@ -68,6 +69,7 @@ public partial class App : Application mainVm.DebugVm = provider.GetRequiredService(); mainVm.SettingsVm = provider.GetRequiredService(); mainVm.MappingVm = provider.GetRequiredService(); + mainVm.AtlasVm = provider.GetRequiredService(); var window = new MainWindow { DataContext = mainVm }; window.SetConfigStore(store); diff --git a/src/Poe2Trade.Ui/Converters/ValueConverters.cs b/src/Poe2Trade.Ui/Converters/ValueConverters.cs index f9db3f2..6546878 100644 --- a/src/Poe2Trade.Ui/Converters/ValueConverters.cs +++ b/src/Poe2Trade.Ui/Converters/ValueConverters.cs @@ -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")), }; } diff --git a/src/Poe2Trade.Ui/ViewModels/AtlasViewModel.cs b/src/Poe2Trade.Ui/ViewModels/AtlasViewModel.cs new file mode 100644 index 0000000..abd00e4 --- /dev/null +++ b/src/Poe2Trade.Ui/ViewModels/AtlasViewModel.cs @@ -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; + } + } + + /// + /// Poll ActivePanorama for viewport snapshots at ~2fps while capturing. + /// + 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(); + } +} diff --git a/src/Poe2Trade.Ui/ViewModels/MainWindowViewModel.cs b/src/Poe2Trade.Ui/ViewModels/MainWindowViewModel.cs index 9134473..ece47bd 100644 --- a/src/Poe2Trade.Ui/ViewModels/MainWindowViewModel.cs +++ b/src/Poe2Trade.Ui/ViewModels/MainWindowViewModel.cs @@ -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 _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 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 Links => _bot.Links.GetLinks(); + public ObservableCollection Links { get; } = []; public ObservableCollection Logs { get; } = []; public ObservableCollection 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(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; diff --git a/src/Poe2Trade.Ui/ViewModels/SettingsViewModel.cs b/src/Poe2Trade.Ui/ViewModels/SettingsViewModel.cs index 2f2e631..283f580 100644 --- a/src/Poe2Trade.Ui/ViewModels/SettingsViewModel.cs +++ b/src/Poe2Trade.Ui/ViewModels/SettingsViewModel.cs @@ -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 StashTabs { get; } = []; public ObservableCollection ShopTabs { get; } = []; + public ObservableCollection 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, + }; +} diff --git a/src/Poe2Trade.Ui/Views/MainWindow.axaml b/src/Poe2Trade.Ui/Views/MainWindow.axaml index d4a9089..a8bbbad 100644 --- a/src/Poe2Trade.Ui/Views/MainWindow.axaml +++ b/src/Poe2Trade.Ui/Views/MainWindow.axaml @@ -177,33 +177,107 @@ - - + - -