233 lines
8.3 KiB
C#
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));
|
|
}
|
|
}
|
|
}
|