test
BIN
assets/merchant.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
atlas/atlas-20260224-135428.png
Normal file
|
After Width: | Height: | Size: 12 MiB |
BIN
atlas/atlas-20260224-142451.png
Normal file
|
After Width: | Height: | Size: 39 MiB |
|
Before Width: | Height: | Size: 7 MiB After Width: | Height: | Size: 7.1 MiB |
|
Before Width: | Height: | Size: 6 MiB After Width: | Height: | Size: 5.9 MiB |
|
Before Width: | Height: | Size: 319 KiB After Width: | Height: | Size: 344 KiB |
225
src/Poe2Trade.Bot/AtlasExecutor.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
264
src/Poe2Trade.Bot/DiamondExecutor.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
352
src/Poe2Trade.Navigation/AtlasPanorama.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
264
src/Poe2Trade.Navigation/PerspectiveCalibrator.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
150
src/Poe2Trade.Ui/ViewModels/AtlasViewModel.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||