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 FrameSaver FrameSaver { get; }
|
||||||
public LootDebugDetector LootDebugDetector { get; }
|
public LootDebugDetector LootDebugDetector { get; }
|
||||||
public KulemakExecutor KulemakExecutor { get; }
|
public KulemakExecutor KulemakExecutor { get; }
|
||||||
|
public AtlasExecutor AtlasExecutor { get; }
|
||||||
public volatile bool ShowYoloOverlay = true;
|
public volatile bool ShowYoloOverlay = true;
|
||||||
public volatile bool ShowFightPositionOverlay = true;
|
public volatile bool ShowFightPositionOverlay = true;
|
||||||
private readonly Dictionary<string, ScrapExecutor> _scrapExecutors = new();
|
private readonly Dictionary<string, ScrapExecutor> _scrapExecutors = new();
|
||||||
|
private readonly Dictionary<string, DiamondExecutor> _diamondExecutors = new();
|
||||||
|
|
||||||
// Events
|
// Events
|
||||||
public event Action? StatusUpdated;
|
public event Action? StatusUpdated;
|
||||||
|
|
@ -94,6 +96,7 @@ public class BotOrchestrator : IAsyncDisposable
|
||||||
enemyDetector: EnemyDetector);
|
enemyDetector: EnemyDetector);
|
||||||
|
|
||||||
KulemakExecutor = new KulemakExecutor(game, screen, inventory, logWatcher, store.Settings, BossDetector, HudReader, Navigation);
|
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 =>
|
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()
|
public void Pause()
|
||||||
{
|
{
|
||||||
_paused = true;
|
_paused = true;
|
||||||
|
|
@ -185,6 +213,29 @@ public class BotOrchestrator : IAsyncDisposable
|
||||||
_ = DeactivateLink(id);
|
_ = 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()
|
public BotStatus GetStatus() => new()
|
||||||
{
|
{
|
||||||
Paused = _paused,
|
Paused = _paused,
|
||||||
|
|
@ -211,6 +262,14 @@ public class BotOrchestrator : IAsyncDisposable
|
||||||
return;
|
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)
|
if (KulemakExecutor.State != MappingState.Idle)
|
||||||
{
|
{
|
||||||
State = KulemakExecutor.State.ToString();
|
State = KulemakExecutor.State.ToString();
|
||||||
|
|
@ -273,6 +332,10 @@ public class BotOrchestrator : IAsyncDisposable
|
||||||
await Inventory.ClearToStash();
|
await Inventory.ClearToStash();
|
||||||
Emit("info", "Inventory cleared");
|
Emit("info", "Inventory cleared");
|
||||||
|
|
||||||
|
// Wire trade monitor events before activating links to avoid race
|
||||||
|
TradeMonitor.NewListings += OnNewListings;
|
||||||
|
TradeMonitor.DiamondListings += OnDiamondListings;
|
||||||
|
|
||||||
// Load links
|
// Load links
|
||||||
var allUrls = new HashSet<string>(cliUrls);
|
var allUrls = new HashSet<string>(cliUrls);
|
||||||
foreach (var l in Store.Settings.Links)
|
foreach (var l in Store.Settings.Links)
|
||||||
|
|
@ -287,9 +350,6 @@ public class BotOrchestrator : IAsyncDisposable
|
||||||
Emit("info", $"Loaded (inactive): {link.Name}");
|
Emit("info", $"Loaded (inactive): {link.Name}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wire trade monitor events
|
|
||||||
TradeMonitor.NewListings += OnNewListings;
|
|
||||||
|
|
||||||
Emit("info", $"Loaded {allUrls.Count} trade link(s)");
|
Emit("info", $"Loaded {allUrls.Count} trade link(s)");
|
||||||
Log.Information("Bot started");
|
Log.Information("Bot started");
|
||||||
}
|
}
|
||||||
|
|
@ -360,6 +420,8 @@ public class BotOrchestrator : IAsyncDisposable
|
||||||
Log.Information("Shutting down bot...");
|
Log.Information("Shutting down bot...");
|
||||||
foreach (var exec in _scrapExecutors.Values)
|
foreach (var exec in _scrapExecutors.Values)
|
||||||
await exec.Stop();
|
await exec.Stop();
|
||||||
|
foreach (var exec in _diamondExecutors.Values)
|
||||||
|
await exec.Stop();
|
||||||
EnemyDetector.Dispose();
|
EnemyDetector.Dispose();
|
||||||
Screen.Dispose();
|
Screen.Dispose();
|
||||||
await TradeMonitor.DisposeAsync();
|
await TradeMonitor.DisposeAsync();
|
||||||
|
|
@ -367,25 +429,46 @@ public class BotOrchestrator : IAsyncDisposable
|
||||||
PipelineService.Dispose();
|
PipelineService.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnNewListings(string searchId, List<string> itemIds)
|
private void OnNewListings(string searchId, List<TradeItem> items)
|
||||||
{
|
{
|
||||||
if (_paused)
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (!Links.IsActive(searchId)) return;
|
if (!Links.IsActive(searchId)) return;
|
||||||
|
|
||||||
Log.Information("New listings: {SearchId} ({Count} items)", searchId, itemIds.Count);
|
foreach (var item in items)
|
||||||
Emit("info", $"New listings: {itemIds.Count} items from {searchId}");
|
{
|
||||||
|
var display = DiamondSettings.KnownDiamonds.GetValueOrDefault(item.Name, item.Name);
|
||||||
|
Emit("info", $"Diamond: {display} @ {item.PriceAmount} {item.PriceCurrency}");
|
||||||
|
}
|
||||||
|
|
||||||
TradeQueue.Enqueue(new TradeInfo(
|
if (_diamondExecutors.TryGetValue(searchId, out var exec))
|
||||||
SearchId: searchId,
|
exec.EnqueueItems(items);
|
||||||
ItemIds: itemIds,
|
|
||||||
WhisperText: "",
|
|
||||||
Timestamp: DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
|
||||||
TradeUrl: ""
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ActivateLink(TradeLink link)
|
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
|
else
|
||||||
{
|
{
|
||||||
await TradeMonitor.AddSearch(link.Url);
|
await TradeMonitor.AddSearch(link.Url);
|
||||||
|
|
@ -433,6 +539,14 @@ public class BotOrchestrator : IAsyncDisposable
|
||||||
await scrapExec.Stop();
|
await scrapExec.Stop();
|
||||||
_scrapExecutors.Remove(id);
|
_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);
|
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");
|
Log.Information("Recovering: escaping and going to hideout");
|
||||||
await _game.FocusGame();
|
await _game.FocusGame();
|
||||||
await _game.PressEscape();
|
// await _game.PressEscape();
|
||||||
await Sleep(Delays.PostEscape);
|
// await Sleep(Delays.PostEscape);
|
||||||
await _game.PressEscape();
|
// await _game.PressEscape();
|
||||||
await Sleep(Delays.PostEscape);
|
// await Sleep(Delays.PostEscape);
|
||||||
|
|
||||||
var arrived = await _inventory.WaitForAreaTransition(
|
var arrived = await _inventory.WaitForAreaTransition(
|
||||||
_config.TravelTimeoutMs, () => _game.GoToHideout());
|
_config.TravelTimeoutMs, () => _game.GoToHideout());
|
||||||
|
|
|
||||||
|
|
@ -229,7 +229,7 @@ public class KulemakExecutor : MappingExecutor
|
||||||
SetState(MappingState.WalkingToEntrance);
|
SetState(MappingState.WalkingToEntrance);
|
||||||
Log.Information("Walking to Black Cathedral entrance (W+D)");
|
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)
|
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);
|
Log.Information("Phase {Phase} done, walking to well", phase);
|
||||||
await Sleep(500);
|
await Sleep(100);
|
||||||
await WalkToWorldPosition(wellWorldX, wellWorldY);
|
await WalkToWorldPosition(wellWorldX, wellWorldY);
|
||||||
await Sleep(500);
|
await Sleep(100);
|
||||||
if (!await TryClickWell())
|
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.A);
|
||||||
await _game.KeyDown(InputSender.VK.W);
|
if(attempt == 0) await _game.KeyDown(InputSender.VK.W);
|
||||||
await Sleep(1500);
|
await Sleep(1000);
|
||||||
await _game.KeyUp(InputSender.VK.W);
|
|
||||||
await _game.KeyUp(InputSender.VK.A);
|
await _game.KeyUp(InputSender.VK.A);
|
||||||
await Sleep(500);
|
if(attempt == 0) await _game.KeyUp(InputSender.VK.W);
|
||||||
await TryClickWell();
|
await Sleep(100);
|
||||||
}
|
}
|
||||||
await Sleep(200);
|
await Sleep(1500);
|
||||||
|
|
||||||
await WalkToWorldPosition(fightWorldX + 20, fightWorldY +20, cancelWhen: IsBossAlive);
|
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);
|
Log.Information("Ring phase: using fightArea=({FX:F0},{FY:F0})", fightWorldX, fightWorldY);
|
||||||
|
|
||||||
await WalkToWorldPosition(-440, -330);
|
await WalkToWorldPosition(-450, -340);
|
||||||
await Sleep(1000);
|
await Sleep(1000);
|
||||||
if (_stopped) return;
|
if (_stopped) return;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,13 @@ public class TradeExecutor
|
||||||
private readonly IInventoryManager _inventory;
|
private readonly IInventoryManager _inventory;
|
||||||
private readonly SavedSettings _config;
|
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 event Action<TradeState>? StateChanged;
|
||||||
|
|
||||||
public TradeExecutor(IGameController game, IScreenReader screen, ITradeMonitor tradeMonitor,
|
public TradeExecutor(IGameController game, IScreenReader screen, ITradeMonitor tradeMonitor,
|
||||||
|
|
@ -40,19 +47,52 @@ public class TradeExecutor
|
||||||
{
|
{
|
||||||
try
|
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;
|
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;
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
SetState(TradeState.ScanningStash);
|
_inventory.SetLocation(false);
|
||||||
await ScanAndBuyItems();
|
|
||||||
|
|
||||||
SetState(TradeState.WaitingForMore);
|
// Merchant stash is visible — buy immediately
|
||||||
Log.Information("Waiting {Ms}ms for more items...", _config.WaitForMoreItemsMs);
|
SetState(TradeState.Buying);
|
||||||
await Helpers.Sleep(_config.WaitForMoreItemsMs);
|
foreach (var item in trade.Items)
|
||||||
await ScanAndBuyItems();
|
{
|
||||||
|
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();
|
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);
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||||
Log.Information("Clicking Travel to Hideout for {SearchId}...", trade.SearchId);
|
|
||||||
|
|
||||||
var arrived = await _inventory.WaitForAreaTransition(
|
while (sw.ElapsedMilliseconds < MerchantTimeoutMs)
|
||||||
_config.TravelTimeoutMs,
|
{
|
||||||
async () =>
|
try
|
||||||
{
|
{
|
||||||
if (!await _tradeMonitor.ClickTravelToHideout(trade.SearchId, trade.ItemIds[0]))
|
var match = await _screen.TemplateMatch(MerchantTemplatePath, MerchantRegion);
|
||||||
throw new Exception("Failed to click Travel to Hideout");
|
if (match != null && match.Confidence >= MerchantThreshold)
|
||||||
});
|
{
|
||||||
if (!arrived)
|
Log.Information("Merchant stash detected (confidence={Conf:F3}, elapsed={Ms}ms)",
|
||||||
{
|
match.Confidence, sw.ElapsedMilliseconds);
|
||||||
Log.Error("Timed out waiting for hideout arrival");
|
return true;
|
||||||
SetState(TradeState.Failed);
|
}
|
||||||
return false;
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Debug(ex, "Merchant poll error");
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(MerchantPollMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
SetState(TradeState.InSellersHideout);
|
return false;
|
||||||
_inventory.SetLocation(false);
|
|
||||||
Log.Information("Arrived at seller hideout");
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ReturnHome()
|
private async Task ReturnHome()
|
||||||
|
|
@ -147,11 +171,4 @@ public class TradeExecutor
|
||||||
Log.Debug(ex, "Recovery failed");
|
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 int Length => _queue.Count;
|
||||||
public bool IsProcessing => _processing;
|
public bool IsProcessing => _processing;
|
||||||
|
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
_queue.Clear();
|
||||||
|
Log.Information("Trade queue cleared");
|
||||||
|
}
|
||||||
|
|
||||||
public event Action? TradeCompleted;
|
public event Action? TradeCompleted;
|
||||||
public event Action? TradeFailed;
|
public event Action? TradeFailed;
|
||||||
|
|
||||||
public void Enqueue(TradeInfo trade)
|
public void Enqueue(TradeInfo trade)
|
||||||
{
|
{
|
||||||
var existingIds = _queue.SelectMany(t => t.ItemIds).ToHashSet();
|
var existingIds = _queue.SelectMany(t => t.Items.Select(i => i.Id)).ToHashSet();
|
||||||
var newIds = trade.ItemIds.Where(id => !existingIds.Contains(id)).ToList();
|
var newItems = trade.Items.Where(i => !existingIds.Contains(i.Id)).ToList();
|
||||||
if (newIds.Count == 0)
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var deduped = trade with { ItemIds = newIds };
|
var deduped = trade with { Items = newItems };
|
||||||
_queue.Enqueue(deduped);
|
_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();
|
_ = ProcessNext();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -46,7 +52,7 @@ public class TradeQueue
|
||||||
var trade = _queue.Dequeue();
|
var trade = _queue.Dequeue();
|
||||||
try
|
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);
|
var success = await _executor.ExecuteTrade(trade);
|
||||||
if (success)
|
if (success)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,58 @@ public class SavedSettings
|
||||||
public bool ShowHudDebug { get; set; }
|
public bool ShowHudDebug { get; set; }
|
||||||
public string OcrEngine { get; set; } = "WinOCR";
|
public string OcrEngine { get; set; } = "WinOCR";
|
||||||
public KulemakSettings Kulemak { get; set; } = new();
|
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
|
public class KulemakSettings
|
||||||
|
|
@ -71,7 +123,16 @@ public class ConfigStore
|
||||||
public void AddLink(string url, string name = "", LinkMode mode = LinkMode.Live, PostAction? postAction = null)
|
public void AddLink(string url, string name = "", LinkMode mode = LinkMode.Live, PostAction? postAction = null)
|
||||||
{
|
{
|
||||||
url = StripLive(url);
|
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
|
_data.Links.Add(new SavedLink
|
||||||
{
|
{
|
||||||
Url = url,
|
Url = url,
|
||||||
|
|
@ -177,6 +238,9 @@ public class ConfigStore
|
||||||
link.Url = StripLive(link.Url);
|
link.Url = StripLive(link.Url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Backfill known diamonds
|
||||||
|
parsed.Diamond.BackfillKnown();
|
||||||
|
|
||||||
Log.Information("Loaded config.json from {Path} ({LinkCount} links)", _filePath, parsed.Links.Count);
|
Log.Information("Loaded config.json from {Path} ({LinkCount} links)", _filePath, parsed.Links.Count);
|
||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,24 @@ public class LinkManager
|
||||||
public LinkManager(ConfigStore store)
|
public LinkManager(ConfigStore store)
|
||||||
{
|
{
|
||||||
_store = 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)
|
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(
|
public record TradeInfo(
|
||||||
string SearchId,
|
string SearchId,
|
||||||
List<string> ItemIds,
|
List<TradeItem> Items,
|
||||||
string WhisperText,
|
long Timestamp
|
||||||
long Timestamp,
|
|
||||||
string TradeUrl
|
|
||||||
);
|
);
|
||||||
|
|
||||||
public record TradeItem(
|
public record TradeItem(
|
||||||
|
|
@ -59,9 +57,34 @@ public enum ScrapState
|
||||||
public enum LinkMode
|
public enum LinkMode
|
||||||
{
|
{
|
||||||
Live,
|
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
|
public enum PostAction
|
||||||
{
|
{
|
||||||
Stash,
|
Stash,
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,8 @@ public class InventoryManager : IInventoryManager
|
||||||
Log.Information("Depositing {Count} items to stash", items.Count);
|
Log.Information("Depositing {Count} items to stash", items.Count);
|
||||||
await CtrlClickItems(items, GridLayouts.Inventory);
|
await CtrlClickItems(items, GridLayouts.Inventory);
|
||||||
|
|
||||||
|
await SnapshotInventory();
|
||||||
|
|
||||||
await _game.PressEscape();
|
await _game.PressEscape();
|
||||||
await Helpers.Sleep(Delays.PostEscape);
|
await Helpers.Sleep(Delays.PostEscape);
|
||||||
Log.Information("Items deposited to stash");
|
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
|
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 Start(string? dashboardUrl = null);
|
||||||
Task AddSearch(string tradeUrl);
|
Task AddSearch(string tradeUrl);
|
||||||
|
Task AddDiamondSearch(string tradeUrl);
|
||||||
Task PauseSearch(string searchId);
|
Task PauseSearch(string searchId);
|
||||||
Task<bool> ClickTravelToHideout(string pageId, string? itemId = null);
|
Task<bool> ClickTravelToHideout(string pageId, string? itemId = null);
|
||||||
Task<(string ScrapId, List<TradeItem> Items)> OpenScrapPage(string tradeUrl);
|
Task<(string ScrapId, List<TradeItem> Items)> OpenScrapPage(string tradeUrl);
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,8 @@ public class TradeDaemonBridge : ITradeMonitor
|
||||||
private readonly string _daemonScript;
|
private readonly string _daemonScript;
|
||||||
private readonly string _nodeExe;
|
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)
|
public TradeDaemonBridge(SavedSettings config)
|
||||||
{
|
{
|
||||||
|
|
@ -52,6 +53,12 @@ public class TradeDaemonBridge : ITradeMonitor
|
||||||
await SendCommand("addSearch", new { url = tradeUrl });
|
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)
|
public async Task PauseSearch(string searchId)
|
||||||
{
|
{
|
||||||
EnsureDaemonRunning();
|
EnsureDaemonRunning();
|
||||||
|
|
@ -293,14 +300,21 @@ public class TradeDaemonBridge : ITradeMonitor
|
||||||
{
|
{
|
||||||
case "newListings":
|
case "newListings":
|
||||||
var searchId = root.GetProperty("searchId").GetString()!;
|
var searchId = root.GetProperty("searchId").GetString()!;
|
||||||
var itemIds = root.GetProperty("itemIds").EnumerateArray()
|
var tradeItems = ParseItems(root);
|
||||||
.Select(e => e.GetString()!)
|
if (tradeItems.Count > 0)
|
||||||
.Where(s => s != null)
|
|
||||||
.ToList();
|
|
||||||
if (itemIds.Count > 0)
|
|
||||||
{
|
{
|
||||||
Log.Information("New listings from daemon: {SearchId} ({Count} items)", searchId, itemIds.Count);
|
Log.Information("New listings from daemon: {SearchId} ({Count} items)", searchId, tradeItems.Count);
|
||||||
NewListings?.Invoke(searchId, itemIds);
|
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;
|
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)
|
private static List<TradeItem> ParseItems(JsonElement resp)
|
||||||
{
|
{
|
||||||
var items = new List<TradeItem>();
|
var items = new List<TradeItem>();
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ public partial class App : Application
|
||||||
services.AddSingleton<DebugViewModel>();
|
services.AddSingleton<DebugViewModel>();
|
||||||
services.AddSingleton<SettingsViewModel>();
|
services.AddSingleton<SettingsViewModel>();
|
||||||
services.AddSingleton<MappingViewModel>();
|
services.AddSingleton<MappingViewModel>();
|
||||||
|
services.AddSingleton<AtlasViewModel>();
|
||||||
|
|
||||||
var provider = services.BuildServiceProvider();
|
var provider = services.BuildServiceProvider();
|
||||||
|
|
||||||
|
|
@ -68,6 +69,7 @@ public partial class App : Application
|
||||||
mainVm.DebugVm = provider.GetRequiredService<DebugViewModel>();
|
mainVm.DebugVm = provider.GetRequiredService<DebugViewModel>();
|
||||||
mainVm.SettingsVm = provider.GetRequiredService<SettingsViewModel>();
|
mainVm.SettingsVm = provider.GetRequiredService<SettingsViewModel>();
|
||||||
mainVm.MappingVm = provider.GetRequiredService<MappingViewModel>();
|
mainVm.MappingVm = provider.GetRequiredService<MappingViewModel>();
|
||||||
|
mainVm.AtlasVm = provider.GetRequiredService<AtlasViewModel>();
|
||||||
|
|
||||||
var window = new MainWindow { DataContext = mainVm };
|
var window = new MainWindow { DataContext = mainVm };
|
||||||
window.SetConfigStore(store);
|
window.SetConfigStore(store);
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ public class LinkModeToColorConverter : IValueConverter
|
||||||
{
|
{
|
||||||
LinkMode.Live => new SolidColorBrush(Color.Parse("#1f6feb")),
|
LinkMode.Live => new SolidColorBrush(Color.Parse("#1f6feb")),
|
||||||
LinkMode.Scrap => new SolidColorBrush(Color.Parse("#9e6a03")),
|
LinkMode.Scrap => new SolidColorBrush(Color.Parse("#9e6a03")),
|
||||||
|
LinkMode.Diamond => new SolidColorBrush(Color.Parse("#8957e5")),
|
||||||
_ => new SolidColorBrush(Color.Parse("#30363d")),
|
_ => 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;
|
[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
|
public partial class MainWindowViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
private readonly BotOrchestrator _bot;
|
private readonly BotOrchestrator _bot;
|
||||||
|
|
@ -63,7 +121,7 @@ public partial class MainWindowViewModel : ObservableObject
|
||||||
[ObservableProperty] private int _activeLinksCount;
|
[ObservableProperty] private int _activeLinksCount;
|
||||||
[ObservableProperty] private BotMode _botMode;
|
[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 static BotMode[] BotModes { get; } = [BotMode.Trading, BotMode.Mapping];
|
||||||
|
|
||||||
public MainWindowViewModel(BotOrchestrator bot)
|
public MainWindowViewModel(BotOrchestrator bot)
|
||||||
|
|
@ -75,6 +133,9 @@ public partial class MainWindowViewModel : ObservableObject
|
||||||
for (var i = 0; i < 60; i++)
|
for (var i = 0; i < 60; i++)
|
||||||
InventoryCells.Add(new CellState());
|
InventoryCells.Add(new CellState());
|
||||||
|
|
||||||
|
// Pre-populate links from config
|
||||||
|
SyncLinks();
|
||||||
|
|
||||||
bot.StatusUpdated += () =>
|
bot.StatusUpdated += () =>
|
||||||
{
|
{
|
||||||
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||||
|
|
@ -86,7 +147,7 @@ public partial class MainWindowViewModel : ObservableObject
|
||||||
TradesCompleted = status.TradesCompleted;
|
TradesCompleted = status.TradesCompleted;
|
||||||
TradesFailed = status.TradesFailed;
|
TradesFailed = status.TradesFailed;
|
||||||
ActiveLinksCount = status.Links.Count(l => l.Active);
|
ActiveLinksCount = status.Links.Count(l => l.Active);
|
||||||
OnPropertyChanged(nameof(Links));
|
SyncLinks();
|
||||||
UpdateInventoryGrid();
|
UpdateInventoryGrid();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
@ -110,7 +171,7 @@ public partial class MainWindowViewModel : ObservableObject
|
||||||
}
|
}
|
||||||
|
|
||||||
public string PauseButtonText => IsPaused ? "Resume" : "Pause";
|
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<LogEntry> Logs { get; } = [];
|
||||||
public ObservableCollection<CellState> InventoryCells { get; } = [];
|
public ObservableCollection<CellState> InventoryCells { get; } = [];
|
||||||
public int InventoryFreeCells => _bot.IsReady ? _bot.Inventory.Tracker.FreeCells : 60;
|
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 DebugViewModel? DebugVm { get; set; }
|
||||||
public SettingsViewModel? SettingsVm { get; set; }
|
public SettingsViewModel? SettingsVm { get; set; }
|
||||||
public MappingViewModel? MappingVm { get; set; }
|
public MappingViewModel? MappingVm { get; set; }
|
||||||
|
public AtlasViewModel? AtlasVm { get; set; }
|
||||||
|
|
||||||
partial void OnBotModeChanged(BotMode value)
|
partial void OnBotModeChanged(BotMode value)
|
||||||
{
|
{
|
||||||
|
|
@ -162,6 +224,7 @@ public partial class MainWindowViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(NewUrl)) return;
|
if (string.IsNullOrWhiteSpace(NewUrl)) return;
|
||||||
_bot.AddLink(NewUrl, NewLinkName, NewLinkMode);
|
_bot.AddLink(NewUrl, NewLinkName, NewLinkMode);
|
||||||
|
SyncLinks();
|
||||||
NewUrl = "";
|
NewUrl = "";
|
||||||
NewLinkName = "";
|
NewLinkName = "";
|
||||||
}
|
}
|
||||||
|
|
@ -169,15 +232,48 @@ public partial class MainWindowViewModel : ObservableObject
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void RemoveLink(string? id)
|
private void RemoveLink(string? id)
|
||||||
{
|
{
|
||||||
if (id != null) _bot.RemoveLink(id);
|
if (id == null) return;
|
||||||
|
_bot.RemoveLink(id);
|
||||||
|
SyncLinks();
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
private void SyncLinks()
|
||||||
private void ToggleLink(string? id)
|
|
||||||
{
|
{
|
||||||
if (id == null) return;
|
var current = _bot.Links.GetLinks();
|
||||||
var link = _bot.Links.GetLink(id);
|
var currentIds = new HashSet<string>(current.Select(l => l.Id));
|
||||||
if (link != null) _bot.ToggleLink(id, !link.Active);
|
|
||||||
|
// 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)
|
private async Task RunBackgroundLoop(CancellationToken ct)
|
||||||
|
|
@ -193,9 +289,7 @@ public partial class MainWindowViewModel : ObservableObject
|
||||||
if (endDown && !f12WasDown)
|
if (endDown && !f12WasDown)
|
||||||
{
|
{
|
||||||
Log.Information("END pressed — emergency stop");
|
Log.Information("END pressed — emergency stop");
|
||||||
await _bot.Navigation.Stop();
|
await _bot.EmergencyStop();
|
||||||
_bot.KulemakExecutor.Stop();
|
|
||||||
_bot.Pause();
|
|
||||||
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||||
{
|
{
|
||||||
IsPaused = true;
|
IsPaused = true;
|
||||||
|
|
|
||||||
|
|
@ -25,11 +25,11 @@ public partial class SettingsViewModel : ObservableObject
|
||||||
[ObservableProperty] private string _calibrationStatus = "";
|
[ObservableProperty] private string _calibrationStatus = "";
|
||||||
[ObservableProperty] private string _stashCalibratedAt = "";
|
[ObservableProperty] private string _stashCalibratedAt = "";
|
||||||
[ObservableProperty] private string _shopCalibratedAt = "";
|
[ObservableProperty] private string _shopCalibratedAt = "";
|
||||||
|
|
||||||
public static string[] OcrEngineOptions { get; } = ["WinOCR", "OneOCR", "EasyOCR"];
|
public static string[] OcrEngineOptions { get; } = ["WinOCR", "OneOCR", "EasyOCR"];
|
||||||
|
|
||||||
public ObservableCollection<StashTabViewModel> StashTabs { get; } = [];
|
public ObservableCollection<StashTabViewModel> StashTabs { get; } = [];
|
||||||
public ObservableCollection<StashTabViewModel> ShopTabs { get; } = [];
|
public ObservableCollection<StashTabViewModel> ShopTabs { get; } = [];
|
||||||
|
public ObservableCollection<DiamondPriceViewModel> DiamondPrices { get; } = [];
|
||||||
|
|
||||||
public SettingsViewModel(BotOrchestrator bot)
|
public SettingsViewModel(BotOrchestrator bot)
|
||||||
{
|
{
|
||||||
|
|
@ -50,6 +50,9 @@ public partial class SettingsViewModel : ObservableObject
|
||||||
Headless = s.Headless;
|
Headless = s.Headless;
|
||||||
ShowHudDebug = s.ShowHudDebug;
|
ShowHudDebug = s.ShowHudDebug;
|
||||||
OcrEngine = s.OcrEngine;
|
OcrEngine = s.OcrEngine;
|
||||||
|
DiamondPrices.Clear();
|
||||||
|
foreach (var p in s.Diamond.Prices)
|
||||||
|
DiamondPrices.Add(new DiamondPriceViewModel(p));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void LoadTabs()
|
private void LoadTabs()
|
||||||
|
|
@ -102,6 +105,7 @@ public partial class SettingsViewModel : ObservableObject
|
||||||
s.Headless = Headless;
|
s.Headless = Headless;
|
||||||
s.ShowHudDebug = ShowHudDebug;
|
s.ShowHudDebug = ShowHudDebug;
|
||||||
s.OcrEngine = OcrEngine;
|
s.OcrEngine = OcrEngine;
|
||||||
|
s.Diamond.Prices = DiamondPrices.Select(p => p.ToModel()).ToList();
|
||||||
});
|
});
|
||||||
|
|
||||||
IsSaved = true;
|
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 OnPoe2LogPathChanged(string value) => IsSaved = false;
|
||||||
partial void OnWindowTitleChanged(string value) => IsSaved = false;
|
partial void OnWindowTitleChanged(string value) => IsSaved = false;
|
||||||
partial void OnTravelTimeoutMsChanged(decimal? 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 OnShowHudDebugChanged(bool value) => IsSaved = false;
|
||||||
partial void OnOcrEngineChanged(string 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,33 +177,107 @@
|
||||||
<ScrollViewer>
|
<ScrollViewer>
|
||||||
<ItemsControl x:Name="LinksControl" ItemsSource="{Binding Links}">
|
<ItemsControl x:Name="LinksControl" ItemsSource="{Binding Links}">
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate x:DataType="vm:TradeLinkViewModel">
|
||||||
<Border Margin="0,2" Padding="6" Background="#21262d"
|
<Border Margin="0,2" Background="#21262d" CornerRadius="4"
|
||||||
CornerRadius="4"
|
|
||||||
Opacity="{Binding Active, Converter={StaticResource ActiveOpacity}}">
|
Opacity="{Binding Active, Converter={StaticResource ActiveOpacity}}">
|
||||||
<DockPanel>
|
<StackPanel>
|
||||||
<Button DockPanel.Dock="Right" Content="X" FontSize="10"
|
<!-- Header row -->
|
||||||
VerticalAlignment="Center"
|
<DockPanel Margin="6">
|
||||||
Command="{ReflectionBinding #LinksControl.DataContext.RemoveLinkCommand}"
|
<Button DockPanel.Dock="Right" Content="X" FontSize="10"
|
||||||
CommandParameter="{Binding Id}" />
|
VerticalAlignment="Center"
|
||||||
<CheckBox DockPanel.Dock="Left"
|
Command="{ReflectionBinding #LinksControl.DataContext.RemoveLinkCommand}"
|
||||||
IsChecked="{Binding Active}"
|
CommandParameter="{Binding Id}" />
|
||||||
Margin="0,0,8,0" VerticalAlignment="Center" />
|
<ToggleButton DockPanel.Dock="Right"
|
||||||
<StackPanel>
|
IsChecked="{Binding IsExpanded}"
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
Content="..." FontSize="10"
|
||||||
<Border Background="{Binding Mode, Converter={StaticResource ModeBrush}}"
|
Padding="6,2" Margin="0,0,6,0"
|
||||||
CornerRadius="4" Padding="6,2">
|
VerticalAlignment="Center" />
|
||||||
<TextBlock Text="{Binding Mode}"
|
<CheckBox DockPanel.Dock="Left"
|
||||||
FontSize="10" FontWeight="Bold"
|
IsChecked="{Binding Active}"
|
||||||
Foreground="White" />
|
Margin="0,0,8,0" VerticalAlignment="Center" />
|
||||||
</Border>
|
<StackPanel>
|
||||||
<TextBlock Text="{Binding Name}" FontSize="12"
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
FontWeight="SemiBold" Foreground="#e6edf3" />
|
<Border Background="{Binding Mode, Converter={StaticResource ModeBrush}}"
|
||||||
|
CornerRadius="4" Padding="6,2">
|
||||||
|
<TextBlock Text="{Binding Mode}"
|
||||||
|
FontSize="10" FontWeight="Bold"
|
||||||
|
Foreground="White" />
|
||||||
|
</Border>
|
||||||
|
<TextBlock Text="{Binding Name}" FontSize="12"
|
||||||
|
FontWeight="SemiBold" Foreground="#e6edf3" />
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Text="{Binding Label}" FontSize="10"
|
||||||
|
Foreground="#8b949e" TextTrimming="CharacterEllipsis" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<TextBlock Text="{Binding Label}" FontSize="10"
|
</DockPanel>
|
||||||
Foreground="#8b949e" TextTrimming="CharacterEllipsis" />
|
|
||||||
</StackPanel>
|
<!-- Expanded settings -->
|
||||||
</DockPanel>
|
<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>
|
</Border>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
|
|
@ -304,6 +378,44 @@
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</TabItem>
|
</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 ========== -->
|
<!-- ========== DEBUG TAB ========== -->
|
||||||
<TabItem Header="Debug">
|
<TabItem Header="Debug">
|
||||||
<ScrollViewer DataContext="{Binding DebugVm}" Margin="0,6,0,0">
|
<ScrollViewer DataContext="{Binding DebugVm}" Margin="0,6,0,0">
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,32 @@ function parseTradeItem(r) {
|
||||||
return { id, w, h, stashX, stashY, account };
|
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) {
|
async function waitForVisible(locator, timeoutMs) {
|
||||||
try {
|
try {
|
||||||
await locator.waitFor({ state: "visible", timeout: timeoutMs });
|
await locator.waitFor({ state: "visible", timeout: timeoutMs });
|
||||||
|
|
@ -97,19 +123,8 @@ function handleWebSocket(ws, searchId) {
|
||||||
|
|
||||||
ws.on("framereceived", (frame) => {
|
ws.on("framereceived", (frame) => {
|
||||||
if (pausedSearches.has(searchId)) return;
|
if (pausedSearches.has(searchId)) return;
|
||||||
try {
|
// Note: trade site now sends JWTs, not raw item IDs.
|
||||||
const payload = typeof frame === "string" ? frame : frame.payload?.toString() ?? "";
|
// We rely on the fetch response interceptor instead.
|
||||||
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 */
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on("close", () => {
|
ws.on("close", () => {
|
||||||
|
|
@ -160,14 +175,42 @@ async function cmdAddSearch(reqId, params) {
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
searchPages.set(searchId, page);
|
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 page.goto(url, { waitUntil: "networkidle" });
|
||||||
await new Promise((r) => setTimeout(r, 2000)); // PageLoad delay
|
await new Promise((r) => setTimeout(r, 2000)); // PageLoad delay
|
||||||
|
|
||||||
page.on("websocket", (ws) => handleWebSocket(ws, searchId));
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const liveBtn = page.locator(Selectors.LiveSearchButton).first();
|
const liveBtn = page.locator(Selectors.LiveSearchButton).first();
|
||||||
await liveBtn.click({ timeout: 5000 });
|
await liveBtn.click({ timeout: 5000 });
|
||||||
|
liveActive = true;
|
||||||
log(`Live search activated: ${searchId}`);
|
log(`Live search activated: ${searchId}`);
|
||||||
} catch {
|
} catch {
|
||||||
log(`Could not click Activate Live Search: ${searchId}`);
|
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) {
|
async function cmdOpenScrapPage(reqId, params) {
|
||||||
if (!context) throw new Error("Browser not started");
|
if (!context) throw new Error("Browser not started");
|
||||||
const { url } = params;
|
const { url } = params;
|
||||||
|
|
@ -339,6 +432,7 @@ async function cmdStop(reqId) {
|
||||||
const handlers = {
|
const handlers = {
|
||||||
start: cmdStart,
|
start: cmdStart,
|
||||||
addSearch: cmdAddSearch,
|
addSearch: cmdAddSearch,
|
||||||
|
addDiamondSearch: cmdAddDiamondSearch,
|
||||||
pauseSearch: cmdPauseSearch,
|
pauseSearch: cmdPauseSearch,
|
||||||
clickTravel: cmdClickTravel,
|
clickTravel: cmdClickTravel,
|
||||||
openScrapPage: cmdOpenScrapPage,
|
openScrapPage: cmdOpenScrapPage,
|
||||||
|
|
|
||||||