poe2-bot/src/Nexus.Bot/CraftingExecutor.cs
2026-03-06 14:37:05 -05:00

233 lines
8.3 KiB
C#

using Nexus.Core;
using Nexus.Game;
using Nexus.Items;
using Serilog;
namespace Nexus.Bot;
public class CraftingExecutor
{
private readonly IGameController _game;
private readonly SavedSettings _config;
private CancellationTokenSource? _cts;
private CraftingState _state = CraftingState.Idle;
public event Action<CraftingState>? StateChanged;
public CraftingState State => _state;
public int CurrentStepIndex { get; private set; }
public int CurrentAttempt { get; private set; }
public int TotalAttempts { get; private set; }
public string? LastItemText { get; private set; }
public CraftingExecutor(IGameController game, SavedSettings config)
{
_game = game;
_config = config;
}
private void SetState(CraftingState s)
{
_state = s;
StateChanged?.Invoke(s);
}
public void Stop()
{
_cts?.Cancel();
SetState(CraftingState.Idle);
Log.Information("Crafting executor stopped");
}
private (int x, int y)? ResolvePosition(string name)
{
var pos = _config.CurrencyPositions.FirstOrDefault(
c => c.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
if (pos != null && (pos.X != 0 || pos.Y != 0))
return (pos.X, pos.Y);
return null;
}
public async Task RunCraft(CraftRecipe recipe)
{
_cts = new CancellationTokenSource();
var ct = _cts.Token;
CurrentStepIndex = 0;
TotalAttempts = 0;
LastItemText = null;
try
{
SetState(CraftingState.Running);
Log.Information("Starting craft: {Name} ({StepCount} steps)", recipe.Name, recipe.Steps.Count);
// Resolve item position: "Craft Item" entry, fallback to recipe ItemX/ItemY
var itemPos = ResolvePosition("Craft Item");
var itemX = itemPos?.x ?? recipe.ItemX;
var itemY = itemPos?.y ?? recipe.ItemY;
await _game.FocusGame();
await Helpers.RandomDelay(200, 400);
while (CurrentStepIndex < recipe.Steps.Count)
{
ct.ThrowIfCancellationRequested();
var step = recipe.Steps[CurrentStepIndex];
CurrentAttempt = 0;
// Resolve currency position: by name, fallback to step CurrencyX/CurrencyY
int curX = step.CurrencyX, curY = step.CurrencyY;
if (!string.IsNullOrEmpty(step.CurrencyName))
{
var resolved = ResolvePosition(step.CurrencyName);
if (resolved.HasValue)
{
curX = resolved.Value.x;
curY = resolved.Value.y;
}
else
{
Log.Warning("Step {Index}: currency '{Name}' not found, using fallback {X},{Y}",
CurrentStepIndex, step.CurrencyName, step.CurrencyX, step.CurrencyY);
}
}
Log.Information("Step {Index}: {Label} (max {Max}) currency=({CurX},{CurY}) item=({ItemX},{ItemY})",
CurrentStepIndex, step.Label, step.MaxAttempts, curX, curY, itemX, itemY);
var stepDone = false;
while (!stepDone)
{
ct.ThrowIfCancellationRequested();
// 1. Right-click currency
SetState(CraftingState.Running);
await _game.RightClickAt(curX, curY);
await Helpers.RandomDelay(80, 150);
ct.ThrowIfCancellationRequested();
// 2. Left-click item
await _game.LeftClickAt(itemX, itemY);
await Helpers.RandomDelay(150, 250);
ct.ThrowIfCancellationRequested();
// 3. Read item via Ctrl+C
SetState(CraftingState.ReadingItem);
await _game.MoveMouseTo(itemX, itemY);
await Helpers.RandomDelay(80, 120);
await _game.HoldCtrl();
await Helpers.Sleep(30);
await _game.PressKey(InputSender.VK.C);
await _game.ReleaseCtrl();
await Helpers.RandomDelay(40, 80);
var itemText = ClipboardHelper.Read();
LastItemText = itemText;
StateChanged?.Invoke(_state); // notify UI of LastItemText update
CurrentAttempt++;
TotalAttempts++;
// 4. Check mods
SetState(CraftingState.CheckingMods);
if (step.RequiredMods.Count == 0)
{
// No condition — single application, advance
Log.Information("Step {Index}: no condition, advancing", CurrentStepIndex);
stepDone = true;
}
else
{
var matched = CheckMods(itemText, step.RequiredMods, step.MatchAll);
if (matched)
{
Log.Information("Step {Index}: condition met after {Attempts} attempts",
CurrentStepIndex, CurrentAttempt);
stepDone = true;
}
else if (step.MaxAttempts > 0 && CurrentAttempt >= step.MaxAttempts)
{
Log.Warning("Step {Index}: max attempts ({Max}) reached",
CurrentStepIndex, step.MaxAttempts);
if (step.OnFailGoTo.HasValue)
{
Log.Information("Jumping to step {Target}", step.OnFailGoTo.Value);
CurrentStepIndex = step.OnFailGoTo.Value;
stepDone = true; // break inner loop, outer loop continues at new index
continue;
}
else
{
SetState(CraftingState.Failed);
Log.Error("Craft failed: max attempts on step {Index}", CurrentStepIndex);
return;
}
}
}
if (!stepDone)
await Helpers.RandomDelay(30, 80);
}
CurrentStepIndex++;
}
SetState(CraftingState.Done);
Log.Information("Craft complete: {Name} ({Total} total attempts)", recipe.Name, TotalAttempts);
}
catch (OperationCanceledException)
{
SetState(CraftingState.Idle);
Log.Information("Craft cancelled");
}
catch (Exception ex)
{
SetState(CraftingState.Failed);
Log.Error(ex, "Craft failed unexpectedly");
}
}
private static bool CheckMods(string itemText, List<string> requiredMods, bool matchAll)
{
if (string.IsNullOrEmpty(itemText)) return false;
// Try Sidekick parsed matching first
try
{
var item = ItemReader.ParseItemText(itemText);
if (item != null)
{
var statTexts = item.Stats.Select(s => s.Text).ToList();
if (matchAll)
{
return requiredMods.All(mod =>
statTexts.Any(st => st.Contains(mod, StringComparison.OrdinalIgnoreCase)));
}
else
{
return requiredMods.Any(mod =>
statTexts.Any(st => st.Contains(mod, StringComparison.OrdinalIgnoreCase)));
}
}
}
catch
{
// Fall through to raw matching
}
// Fallback: raw substring matching
if (matchAll)
{
return requiredMods.All(mod =>
itemText.Contains(mod, StringComparison.OrdinalIgnoreCase));
}
else
{
return requiredMods.Any(mod =>
itemText.Contains(mod, StringComparison.OrdinalIgnoreCase));
}
}
}