using BetterGenshinImpact.Core.Recognition; using BetterGenshinImpact.Core.Recognition.OpenCv; using BetterGenshinImpact.Core.Recognition.OCR; using BetterGenshinImpact.Core.Config; using BetterGenshinImpact.Core.Script; using BetterGenshinImpact.Core.Simulator; using BetterGenshinImpact.Core.Simulator.Extensions; using BetterGenshinImpact.GameTask.AutoPathing; using BetterGenshinImpact.GameTask.AutoPathing.Model; using BetterGenshinImpact.GameTask.AutoTrackPath; using BetterGenshinImpact.GameTask.AutoFight; using BetterGenshinImpact.GameTask.Common; using BetterGenshinImpact.GameTask.Common.BgiVision; using BetterGenshinImpact.GameTask.Common.Job; using BetterGenshinImpact.GameTask.Common.Map.Maps.Base; using BetterGenshinImpact.GameTask; using BetterGenshinImpact.GameTask.Model; using BetterGenshinImpact.GameTask.Model.Area; using BetterGenshinImpact.Service.Notification; using Microsoft.Extensions.Logging; using OpenCvSharp; using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using BetterGenshinImpact.GameTask.AutoGeniusInvokation.Exception; using Vanara.PInvoke; using static BetterGenshinImpact.GameTask.Common.TaskControl; namespace BetterGenshinImpact.GameTask.AutoLeyLineOutcrop; public class AutoLeyLineOutcropTask : ISoloTask { private readonly ILogger _logger = App.GetLogger(); private readonly AutoLeyLineOutcropConfig _config; private readonly bool _oneDragonMode; private TpTask _tpTask = null!; private readonly ReturnMainUiTask _returnMainUiTask = new(); private SwitchPartyTask? _switchPartyTask; private ISystemInfo _systemInfo = null!; private CancellationToken _ct; private AutoLeyLineConfigData? _configData; private NodeData? _nodeData; private double _leyLineX; private double _leyLineY; private int _currentRunTimes; private bool _marksStatus = true; private int _recheckCount; private int _consecutiveFailureCount; private DateTime _lastRewardNavLog = DateTime.MinValue; private RecognitionObject? _openRo; private RecognitionObject? _closeRo; private RecognitionObject? _paimonMenuRo; private RecognitionObject? _boxIconRo; private RecognitionObject? _mapSettingButtonRo; private RecognitionObject? _ocrRo1; private RecognitionObject? _ocrRo2; private RecognitionObject? _ocrRo3; private readonly RecognitionObject _ocrRoThis = RecognitionObject.OcrThis; private readonly Dictionary _templateCache = new(); private const int MaxRecheckCount = 3; private const int MaxConsecutiveFailures = 5; public string Name => "自动地脉花"; public AutoLeyLineOutcropTask(AutoLeyLineOutcropConfig config, bool oneDragonMode = false) { _config = config; _oneDragonMode = oneDragonMode; } public async Task Start(CancellationToken ct) { _ct = ct; try { Initialize(); var runTimesValue = await HandleResinExhaustionMode(); if (runTimesValue <= 0) { throw new Exception("树脂耗尽,任务结束"); } await PrepareForLeyLineRun(); await RunLeyLineChallenges(); if (_config.IsResinExhaustionMode) { await RecheckResinAndContinue(); } } catch (NormalEndException e) { Logger.LogInformation("任务结束:{Msg}", e.Message); } catch (Exception e) { _logger.LogError(e, "自动地脉花执行失败"); if (_config.IsNotification) { Notify.Event("AutoLeyLineOutcrop").Error($"任务失败: {e.Message}"); } throw new Exception($"自动地脉花执行失败: {e.Message}", e); } finally { try { await EnsureExitRewardPage(); } catch (Exception ex) { _logger.LogWarning(ex, "退出奖励界面失败"); } if (!_marksStatus) { await OpenCustomMarks(); } } } private void Initialize() { _systemInfo = TaskContext.Instance().SystemInfo; _tpTask = new TpTask(_ct); ValidateSettings(); LoadConfigData(); LoadRecognitionObjects(); } private void ValidateSettings() { if (string.IsNullOrWhiteSpace(_config.LeyLineOutcropType)) { throw new Exception("地脉花类型未选择"); } if (_config.LeyLineOutcropType != "启示之花" && _config.LeyLineOutcropType != "藏金之花") { throw new Exception("地脉花类型无效,请重新选择"); } if (string.IsNullOrWhiteSpace(_config.Country)) { throw new Exception("国家未配置"); } if (!string.IsNullOrWhiteSpace(_config.FriendshipTeam) && string.IsNullOrWhiteSpace(_config.Team)) { throw new Exception("配置好感队时必须配置战斗队伍"); } if (_config.Count < 1) { _config.Count = 1; } } private void LoadConfigData() { // Load and validate the static ley line route config from disk. var workDir = Global.Absolute(@"GameTask\AutoLeyLineOutcrop"); var configPath = Path.Combine(workDir, "Assets", "config.json"); if (!File.Exists(configPath)) { throw new FileNotFoundException("config.json 未找到", configPath); } var json = File.ReadAllText(configPath); _configData = JsonSerializer.Deserialize(json) ?? throw new Exception("config.json 解析失败"); } private void LoadRecognitionObjects() { // Template ROIs are tuned for the 1080p capture region. _openRo = BuildTemplate("Assets/icon/open.png"); _closeRo = BuildTemplate("Assets/icon/close.png"); _paimonMenuRo = BuildTemplate("Assets/icon/paimon_menu.png", new Rect(0, 0, ScaleTo1080(640), ScaleTo1080(216))); _boxIconRo = BuildTemplate("Assets/icon/box.png"); _mapSettingButtonRo = BuildTemplate("Assets/icon/map_setting_button.bmp"); _ocrRo1 = RecognitionObject.Ocr(ScaleTo1080(800), ScaleTo1080(200), ScaleTo1080(300), ScaleTo1080(100)); _ocrRo2 = RecognitionObject.Ocr(ScaleTo1080(0), ScaleTo1080(200), ScaleTo1080(300), ScaleTo1080(300)); _ocrRo3 = RecognitionObject.Ocr(ScaleTo1080(1200), ScaleTo1080(520), ScaleTo1080(300), ScaleTo1080(300)); } private static int ScaleTo1080(int value) { // CaptureToRectArea returns a 1080p region already. return value; } private RecognitionObject BuildTemplate(string relativePath, Rect? roi = null, double threshold = 0.8) { // Cache + scale templates to the current asset scale to keep matching stable. var mat = LoadTemplate(relativePath); var ro = RecognitionObject.TemplateMatch(mat); ro.Threshold = threshold; if (roi.HasValue) { ro.RegionOfInterest = roi.Value; } return ro; } private Mat LoadTemplate(string relativePath) { if (_templateCache.TryGetValue(relativePath, out var cached)) { return cached; } var workDir = Global.Absolute(@"GameTask\AutoLeyLineOutcrop"); var fullPath = Path.Combine(workDir, relativePath.Replace("/", Path.DirectorySeparatorChar.ToString())); if (!File.Exists(fullPath)) { throw new FileNotFoundException("模板素材未找到", fullPath); } var mat = Mat.FromStream(File.OpenRead(fullPath), ImreadModes.Color); // Resize once and reuse to avoid repeated scaling during recognition. var scaled = ResizeHelper.Resize(mat, _systemInfo.AssetScale); _templateCache[relativePath] = scaled; return scaled; } private async Task HandleResinExhaustionMode() { if (!_config.IsResinExhaustionMode) { return _config.Count; } var result = await CalCountByResin(); if (result.Count <= 0) { return 0; } if (_config.OpenModeCountMin) { _config.Count = Math.Min(result.Count, _config.Count); } else { _config.Count = result.Count; } if (_config.IsNotification) { var text = "树脂耗尽模式统计结果:\n" + $"原粹树脂次数: {result.OriginalResinTimes}\n" + $"浓缩树脂次数: {result.CondensedResinTimes}\n" + $"须臾树脂次数: {result.TransientResinTimes}\n" + $"脆弱树脂次数: {result.FragileResinTimes}\n" + $"总次数: {result.Count}"; Notify.Event("AutoLeyLineOutcrop").Send(text); } return _config.Count; } private async Task PrepareForLeyLineRun() { await EnsureExitRewardPage(); await _returnMainUiTask.Start(_ct); if (!_oneDragonMode) { await _tpTask.TpToStatueOfTheSeven(); } if (!string.IsNullOrWhiteSpace(_config.Team)) { _switchPartyTask ??= new SwitchPartyTask(); await _switchPartyTask.Start(_config.Team, _ct); } if (_config.UseAdventurerHandbook) { // The config flag means "do NOT use handbook"; close custom marks for manual navigation. await CloseCustomMarks(); } TaskTriggerDispatcher.Instance().AddTrigger("AutoPick", null); } private async Task RunLeyLineChallenges() { while (_currentRunTimes < _config.Count) { if (!_config.UseAdventurerHandbook) { // Handbook flow: open the book and track a ley line target. await FindLeyLineOutcropByBook(_config.Country, _config.LeyLineOutcropType); } else { // Manual flow: detect the ley line on the big map. await FindLeyLineOutcrop(_config.Country, _config.LeyLineOutcropType); } var foundStrategy = await ExecuteMatchingStrategy(); if (!foundStrategy) { HandleNoStrategyFound(); return; } } } private async Task ExecuteMatchingStrategy() { if (_configData?.LeyLinePositions == null) { throw new Exception("地脉花策略配置缺失"); } if (!_configData.LeyLinePositions.TryGetValue(_config.Country, out var positions)) { return false; } foreach (var position in positions) { if (IsNearPosition(_leyLineX, _leyLineY, position.X, position.Y, _configData.ErrorThreshold)) { _logger.LogInformation("匹配策略: {Strategy} order={Order}", position.Strategy, position.Order); await ExecutePathsUsingNodeData(position); return true; } } return false; } private static bool IsNearPosition(double x1, double y1, double x2, double y2, double threshold) { return Math.Abs(x1 - x2) <= threshold && Math.Abs(y1 - y2) <= threshold; } private async Task ExecutePathsUsingNodeData(LeyLinePosition position) { try { // Map node graph provides the walking routes for each ley line position. var nodeData = await LoadNodeData(); var targetNode = FindTargetNodeByPosition(nodeData, position.X, position.Y); if (targetNode == null) { await EnsureExitRewardPage(); return; } var paths = FindPathsToTarget(nodeData, targetNode); if (paths.Count == 0) { await EnsureExitRewardPage(); return; } var optimal = SelectOptimalPath(paths); await ExecutePath(optimal); _currentRunTimes++; if (_currentRunTimes >= _config.Count) { return; } var currentNode = targetNode; while (currentNode.Next.Count > 0 && _currentRunTimes < _config.Count) { if (currentNode.Next.Count == 1) { var next = currentNode.Next[0]; var nextNode = nodeData.Nodes.FirstOrDefault(n => n.Id == next.Target); if (nextNode == null) { await EnsureExitRewardPage(); return; } var path = new PathInfo { StartNode = currentNode, TargetNode = nextNode, Routes = [next.Route] }; await ExecutePath(path); _currentRunTimes++; currentNode = nextNode; } else { // Multiple branches: re-locate the ley line position before deciding the route. var originalX = _leyLineX; var originalY = _leyLineY; await _returnMainUiTask.Start(_ct); await _tpTask.OpenBigMapUi(); var found = await LocateLeyLineOutcrop(_config.LeyLineOutcropType); await _returnMainUiTask.Start(_ct); if (!found) { _leyLineX = originalX; _leyLineY = originalY; await EnsureExitRewardPage(); return; } var selected = SelectBranchRoute(nodeData, currentNode); if (selected == null) { _leyLineX = originalX; _leyLineY = originalY; await EnsureExitRewardPage(); return; } var path = new PathInfo { StartNode = currentNode, TargetNode = selected.Value.Node, Routes = [selected.Value.Route] }; await ExecutePath(path); _currentRunTimes++; currentNode = selected.Value.Node; } } } catch (Exception ex) { if (ex.Message.Contains("战斗失败", StringComparison.OrdinalIgnoreCase)) { _consecutiveFailureCount++; if (_consecutiveFailureCount >= MaxConsecutiveFailures) { await EnsureExitRewardPage(); throw new Exception($"连续战斗失败{MaxConsecutiveFailures}次,任务终止"); } await EnsureExitRewardPage(); _logger.LogInformation("战斗失败,重新寻找地脉花"); return; } await EnsureExitRewardPage(); throw; } } private (string Route, Node Node)? SelectBranchRoute(NodeData nodeData, Node currentNode) { string? selectedRoute = null; Node? selectedNode = null; var closest = double.MaxValue; foreach (var next in currentNode.Next) { var branchNode = nodeData.Nodes.FirstOrDefault(n => n.Id == next.Target); if (branchNode == null) { continue; } var distance = Calculate2DDistance(_leyLineX, _leyLineY, branchNode.Position.X, branchNode.Position.Y); if (distance < closest) { closest = distance; selectedRoute = next.Route; selectedNode = branchNode; } } if (selectedRoute == null || selectedNode == null) { return null; } return (selectedRoute, selectedNode); } private static double Calculate2DDistance(double x1, double y1, double x2, double y2) { var dx = x1 - x2; var dy = y1 - y2; return Math.Sqrt(dx * dx + dy * dy); } private Node? FindTargetNodeByPosition(NodeData nodeData, double x, double y) { const double errorThreshold = 50; return nodeData.Nodes.FirstOrDefault(node => node.Type == "blossom" && Math.Abs(node.Position.X - x) <= errorThreshold && Math.Abs(node.Position.Y - y) <= errorThreshold); } private List FindPathsToTarget(NodeData nodeData, Node targetNode) { return BreadthFirstPathSearch(nodeData, targetNode); } private List BreadthFirstPathSearch(NodeData nodeData, Node targetNode) { var validPaths = new List(); var teleportNodes = nodeData.Nodes.Where(n => n.Type == "teleport").ToList(); var nodeMap = nodeData.Nodes.ToDictionary(n => n.Id, n => n); foreach (var startNode in teleportNodes) { var queue = new Queue<(Node Node, PathInfo Path, HashSet Visited)>(); // BFS ensures we prefer shorter paths from each teleport node. queue.Enqueue((startNode, new PathInfo { StartNode = startNode, TargetNode = targetNode, Routes = new List() }, new HashSet { startNode.Id })); while (queue.Count > 0) { var (current, path, visited) = queue.Dequeue(); if (current.Id == targetNode.Id) { validPaths.Add(path); continue; } foreach (var next in current.Next) { if (visited.Contains(next.Target)) { continue; } if (!nodeMap.TryGetValue(next.Target, out var nextNode)) { continue; } var newRoutes = new List(path.Routes) { next.Route }; var newVisited = new HashSet(visited) { next.Target }; queue.Enqueue((nextNode, new PathInfo { StartNode = path.StartNode, TargetNode = targetNode, Routes = newRoutes }, newVisited)); } } } validPaths.AddRange(FindReversePathsIfNeeded(nodeData, targetNode, validPaths)); return validPaths; } private static List FindReversePathsIfNeeded(NodeData nodeData, Node targetNode, List existingPaths) { if (existingPaths.Count > 0 || targetNode.Prev.Count == 0) { return []; } // Fallback: allow a single hop into the target when no forward path exists. var reversePaths = new List(); var nodeMap = nodeData.Nodes.ToDictionary(n => n.Id, n => n); foreach (var prevNodeId in targetNode.Prev) { if (!nodeMap.TryGetValue(prevNodeId, out var prevNode)) { continue; } var teleportNodes = nodeData.Nodes.Where(node => node.Type == "teleport" && node.Next.Any(route => route.Target == prevNode.Id)).ToList(); foreach (var teleportNode in teleportNodes) { var route = teleportNode.Next.FirstOrDefault(r => r.Target == prevNode.Id); var nextRoute = prevNode.Next.FirstOrDefault(r => r.Target == targetNode.Id); if (route == null || nextRoute == null) { continue; } reversePaths.Add(new PathInfo { StartNode = teleportNode, TargetNode = targetNode, Routes = [route.Route, nextRoute.Route] }); } } return reversePaths; } private static PathInfo SelectOptimalPath(List paths) { if (paths.Count == 0) { throw new Exception("没有可用路径"); } return paths.OrderBy(p => p.Routes.Count).First(); } private async Task ExecutePath(PathInfo path) { foreach (var routePath in path.Routes) { await RunPathingFile(routePath); } var lastRoute = path.Routes.Last(); var targetRoute = lastRoute.Replace("Assets/pathing/", "Assets/pathing/target/").Replace("-rerun", ""); await ProcessLeyLineOutcrop(_config.Timeout, targetRoute); var rewardSuccess = await AttemptReward(); if (!rewardSuccess) { throw new Exception("无法领取奖励"); } _consecutiveFailureCount = 0; } private async Task RunPathingFile(string routePath) { await _returnMainUiTask.Start(_ct); var workDir = Global.Absolute(@"GameTask\AutoLeyLineOutcrop"); var localPath = routePath.Replace("/", Path.DirectorySeparatorChar.ToString()); var fullPath = Path.Combine(workDir, localPath); var task = PathingTask.BuildFromFilePath(fullPath) ?? throw new Exception("路径文件解析失败"); var executor = new PathExecutor(_ct); await executor.Pathing(task); } private async Task LoadNodeData() { if (_nodeData != null) { return _nodeData; } var workDir = Global.Absolute(@"GameTask\AutoLeyLineOutcrop"); var nodePath = Path.Combine(workDir, "Assets", "LeyLineOutcropData.json"); if (!File.Exists(nodePath)) { throw new FileNotFoundException("LeyLineOutcropData.json 未找到", nodePath); } var raw = JsonSerializer.Deserialize(File.ReadAllText(nodePath)) ?? throw new Exception("节点数据解析失败"); _nodeData = AdaptNodeData(raw); return _nodeData; } private static NodeData AdaptNodeData(RawNodeData raw) { var nodes = new List(); foreach (var teleport in raw.Teleports) { nodes.Add(new Node { Id = teleport.Id, Region = teleport.Region, Position = teleport.Position, Type = "teleport", Next = new List(), Prev = new List() }); } foreach (var blossom in raw.Blossoms) { nodes.Add(new Node { Id = blossom.Id, Region = blossom.Region, Position = blossom.Position, Type = "blossom", Next = new List(), Prev = new List() }); } foreach (var edge in raw.Edges) { var sourceNode = nodes.FirstOrDefault(n => n.Id == edge.Source); var targetNode = nodes.FirstOrDefault(n => n.Id == edge.Target); if (sourceNode == null || targetNode == null) { continue; } sourceNode.Next.Add(new NodeRoute { Target = edge.Target, Route = edge.Route }); targetNode.Prev.Add(edge.Source); } return new NodeData { Nodes = nodes, Indexes = raw.Indexes }; } private async Task FindLeyLineOutcrop(string country, string type) { if (_configData?.MapPositions == null) { throw new Exception("地图位置配置缺失"); } if (!_configData.MapPositions.TryGetValue(country, out var positions) || positions.Count == 0) { throw new Exception($"未找到国家 {country} 的位置信息"); } await _returnMainUiTask.Start(_ct); await _tpTask.OpenBigMapUi(); await _tpTask.MoveMapTo(positions[0].X, positions[0].Y, MapTypes.Teyvat.ToString()); var found = await LocateLeyLineOutcrop(type); if (found) { return; } for (var i = 1; i < positions.Count; i++) { var pos = positions[i]; _logger.LogInformation("尝试定位地脉花: {Name}", pos.Name ?? $"{pos.X},{pos.Y}"); await _tpTask.MoveMapTo(pos.X, pos.Y, MapTypes.Teyvat.ToString()); if (await LocateLeyLineOutcrop(type)) { return; } } await EnsureExitRewardPage(); if (_config.UseAdventurerHandbook) { _logger.LogWarning("寻找地脉花失败:当前已勾选“不使用冒险之证寻路”,可尝试关闭该选项后重试!"); throw new Exception("寻找地脉花失败:未在地图上识别到地脉花图标。当前已勾选“不使用冒险之证寻路”,可尝试关闭该选项后重试!"); } throw new Exception("寻找地脉花失败:未在地图上识别到地脉花图标"); } private async Task LocateLeyLineOutcrop(string type) { await Delay(500, _ct); var currentZoom = _tpTask.GetBigMapZoomLevel(CaptureToRectArea()); await _tpTask.AdjustMapZoomLevel(currentZoom, 3.0); var iconPath = type == "启示之花" ? "Assets/icon/Blossom_of_Revelation.png" : "Assets/icon/Blossom_of_Wealth.png"; using var ra = CaptureToRectArea(); var iconRo = BuildTemplate(iconPath); var list = ra.FindMulti(iconRo); if (list.Count == 0) { return false; } var flower = list[0]; var center = _tpTask.GetBigMapCenterPoint(MapTypes.Teyvat.ToString()); var mapZoomLevel = _tpTask.GetBigMapZoomLevel(CaptureToRectArea()); var mapScaleFactor = TaskContext.Instance().Config.TpConfig.MapScaleFactor; _leyLineX = (960 - flower.X - 25) * mapZoomLevel / mapScaleFactor + center.X; _leyLineY = (540 - flower.Y - 25) * mapZoomLevel / mapScaleFactor + center.Y; return true; } private void HandleNoStrategyFound() { _logger.LogError("未找到对应的地脉花策略"); if (_config.IsNotification) { Notify.Event("AutoLeyLineOutcrop").Error("未找到对应的地脉花策略"); } } private async Task ProcessLeyLineOutcrop(int timeoutSeconds, string targetPath, int retries = 0) { const int maxRetries = 3; if (retries >= maxRetries) { await EnsureExitRewardPage(); throw new Exception("开启地脉花失败,已达最大重试次数"); } _logger.LogDebug("检测地脉花交互状态,重试次数: {Retries}/{MaxRetries}", retries + 1, maxRetries); using var capture = CaptureToRectArea(); var result1 = FindSafe(capture, _ocrRo2!); var result2 = FindSafe(capture, _ocrRo3!); _logger.LogDebug("OCR结果: result1='{Text1}', result2='{Text2}'", result1.Text, result2.Text); if (result2.Text.Contains("之花", StringComparison.Ordinal)) { _logger.LogDebug("识别到地脉之花入口"); await SwitchToFriendshipTeamIfNeeded(); return true; } if (result2.Text.Contains("溢口", StringComparison.Ordinal)) { _logger.LogDebug("识别到溢口提示,尝试交互"); Simulation.SendInput.SimulateAction(GIActions.PickUpOrInteract); await Delay(300, _ct); Simulation.SendInput.SimulateAction(GIActions.PickUpOrInteract); await Delay(500, _ct); } else if (!ContainsFightText(result1.Text)) { _logger.LogDebug("未识别到战斗提示,执行路径: {Path}", targetPath); await RunPathingFile(targetPath); return await ProcessLeyLineOutcrop(timeoutSeconds, targetPath, retries + 1); } var fightResult = await AutoFight(timeoutSeconds); if (!fightResult) { await EnsureExitRewardPage(); if (await ProcessResurrect()) { return await ProcessLeyLineOutcrop(timeoutSeconds, targetPath, retries + 1); } throw new Exception("战斗失败"); } await SwitchToFriendshipTeamIfNeeded(); await AutoNavigateToReward(); return true; } private Region FindSafe(ImageRegion capture, RecognitionObject ro) { var roi = ro.RegionOfInterest; if (roi == default) { return capture.Find(ro); } var clamped = ClampRect(roi, capture.Width, capture.Height); if (clamped.Width <= 0 || clamped.Height <= 0) { return new Region(); } if (clamped == roi) { return capture.Find(ro); } var cloned = ro.Clone(); cloned.RegionOfInterest = clamped; return capture.Find(cloned); } private static Rect ClampRect(Rect roi, int maxWidth, int maxHeight) { // Clamp ROI to avoid OpenCV exceptions when the rectangle is out of bounds. var x = Math.Clamp(roi.X, 0, Math.Max(0, maxWidth - 1)); var y = Math.Clamp(roi.Y, 0, Math.Max(0, maxHeight - 1)); var w = Math.Clamp(roi.Width, 0, Math.Max(0, maxWidth - x)); var h = Math.Clamp(roi.Height, 0, Math.Max(0, maxHeight - y)); return new Rect(x, y, w, h); } private async Task AutoFight(int timeoutSeconds) { var fightCts = CancellationTokenSource.CreateLinkedTokenSource(_ct); // Ley line uses OCR-based finish detection; disable auto-fight finish detect. var fightTask = StartAutoFightWithoutFinishDetect(fightCts.Token); var fightResult = await RecognizeTextInRegion(timeoutSeconds * 1000); fightCts.Cancel(); try { await fightTask; } catch (Exception ex) { _logger.LogDebug(ex, "自动战斗任务结束"); } finally { Simulation.ReleaseAllKey(); } return fightResult; } private Task StartAutoFightWithoutFinishDetect(CancellationToken ct) { var autoFightConfig = TaskContext.Instance().Config.AutoFightConfig; var strategyPath = BuildAutoFightStrategyPath(autoFightConfig); var taskParam = new AutoFightParam(strategyPath, autoFightConfig) { FightFinishDetectEnabled = false, CheckBeforeBurst = false }; // Avoid false finish signals for ley line fights. taskParam.FinishDetectConfig.FastCheckEnabled = false; taskParam.FinishDetectConfig.RotateFindEnemyEnabled = false; return new AutoFightTask(taskParam).Start(ct); } private static string BuildAutoFightStrategyPath(AutoFightConfig config) { var path = Global.Absolute(@"User\AutoFight\" + config.StrategyName + ".txt"); if ("根据队伍自动选择".Equals(config.StrategyName)) { path = Global.Absolute(@"User\AutoFight\"); } if (!File.Exists(path) && !Directory.Exists(path)) { throw new Exception("战斗策略文件不存在"); } return path; } private async Task RecognizeTextInRegion(int timeoutMs) { var start = DateTime.UtcNow; var noTextCount = 0; var successKeywords = new[] { "挑战达成", "战斗胜利", "挑战成功" }; var failureKeywords = new[] { "挑战失败" }; while ((DateTime.UtcNow - start).TotalMilliseconds < timeoutMs) { using var capture = CaptureToRectArea(); var result = capture.Find(_ocrRo1!); var text = result.Text; if (successKeywords.Any(text.Contains)) { // OCR recognizes victory text; treat as success. return true; } if (failureKeywords.Any(text.Contains)) { // OCR recognizes failure text; stop early. return false; } var foundText = RecognizeFightText(capture); if (!foundText) { noTextCount++; if (noTextCount >= 10) { return false; } } else { noTextCount = 0; } await Delay(1000, _ct); } return false; } private bool RecognizeFightText(ImageRegion captureRegion) { var result = captureRegion.Find(_ocrRo2!); var text = result.Text; return ContainsFightText(text); } private static bool ContainsFightText(string text) { var keywords = new[] { "打倒", "所有", "敌人" }; return keywords.Any(text.Contains); } private async Task AutoNavigateToReward() { const int maxRetry = 3; for (var retry = 0; retry < maxRetry; retry++) { // Reset camera and move in short bursts to re-acquire the chest icon. _logger.LogInformation("开始导航到地脉花奖励,尝试 {Retry}/{Max}", retry + 1, maxRetry); Simulation.SendInput.Mouse.MiddleButtonClick(); await Delay(300, _ct); if (await NavigateTowardReward(60000)) { _logger.LogInformation("已到达领取奖励页面"); return; } Simulation.SendInput.SimulateAction(GIActions.MoveForward, KeyType.KeyUp); Simulation.SendInput.Keyboard.KeyPress(User32.VK.VK_X); await Delay(500, _ct); Simulation.SendInput.SimulateAction(GIActions.MoveBackward, KeyType.KeyDown); await Delay(1000, _ct); Simulation.SendInput.SimulateAction(GIActions.MoveBackward, KeyType.KeyUp); await Delay(500, _ct); } throw new Exception("导航到地脉花失败:超时未检测到奖励或交互文字"); } private async Task NavigateTowardReward(int timeoutMs) { var start = DateTime.UtcNow; try { while ((DateTime.UtcNow - start).TotalMilliseconds < timeoutMs) { // If reward UI is detected, stop moving. if (await DetectRewardPage()) { _logger.LogInformation("检测到奖励/交互文字,停止导航"); return true; } using var capture = CaptureToRectArea(); if (_paimonMenuRo != null && capture.Find(_paimonMenuRo).IsEmpty()) { LogRewardNav("误入其他界面,尝试返回主界面"); await _returnMainUiTask.Start(_ct); } if (!AdjustViewForReward(capture)) { // Wait for the icon to re-enter view before moving forward. LogRewardNav("未对正地脉花图标,等待重新定位"); Simulation.SendInput.SimulateAction(GIActions.MoveForward, KeyType.KeyUp); await Delay(1000, _ct); continue; } LogRewardNav("地脉花图标已对正,开始前进"); Simulation.SendInput.SimulateAction(GIActions.MoveForward, KeyType.KeyDown); await Delay(200, _ct); } } finally { Simulation.SendInput.SimulateAction(GIActions.MoveForward, KeyType.KeyUp); } return false; } private bool AdjustViewForReward(ImageRegion capture) { if (_boxIconRo == null) { return false; } // Use the chest icon position to align the camera before moving forward. var iconRes = capture.Find(_boxIconRo); if (iconRes.IsEmpty()) { LogRewardNav("未找到地脉花图标"); return false; } const int screenCenterX = 960; const int screenCenterY = 540; const double maxAngle = 10; var xOffset = iconRes.X - screenCenterX; var yOffset = screenCenterY - iconRes.Y; var angleInRadians = Math.Atan2(Math.Abs(xOffset), yOffset); var angleInDegrees = angleInRadians * (180 / Math.PI); var isAboveCenter = iconRes.Y < screenCenterY; var isWithinAngle = angleInDegrees <= maxAngle; if (isAboveCenter && isWithinAngle) { LogRewardNav("地脉花图标已对正,角度: {Angle}", angleInDegrees); return true; } Simulation.SendInput.SimulateAction(GIActions.MoveForward, KeyType.KeyUp); var moveX = Math.Clamp(xOffset, -300, 300); LogRewardNav("调整视角,xOffset={XOffset}, yOffset={YOffset}, angle={Angle}", xOffset, yOffset, angleInDegrees); Simulation.SendInput.Mouse.MoveMouseBy(moveX, 0); if (!isAboveCenter) { Simulation.SendInput.Mouse.MoveMouseBy(0, 500); } return false; } private async Task DetectRewardPage() { using var capture = CaptureToRectArea(); // Bv.FindF is faster for common keywords and avoids OCR misses. if (Bv.FindF(capture, "接触") || Bv.FindF(capture, "地脉") || Bv.FindF(capture, "之花")) { return true; } var list = capture.FindMulti(_ocrRoThis); foreach (var res in list) { if (res.Text.Contains("原粹树脂", StringComparison.Ordinal)) { return true; } if (res.Text.Contains("接触", StringComparison.Ordinal) || res.Text.Contains("地脉", StringComparison.Ordinal) || res.Text.Contains("之花", StringComparison.Ordinal)) { return true; } } return false; } private void LogRewardNav(string message, params object[] args) { var now = DateTime.UtcNow; // Throttle log spam during navigation loops. if ((now - _lastRewardNavLog).TotalSeconds < 3) { return; } _lastRewardNavLog = now; if (args.Length == 0) { _logger.LogInformation(message); } else { _logger.LogInformation(message, args); } } private async Task ProcessResurrect() { using var capture = CaptureToRectArea(); var list = capture.FindMulti(_ocrRoThis); foreach (var res in list) { if (res.Text.Contains("复苏", StringComparison.Ordinal)) { res.Click(); await Delay(2000, _ct); return true; } } return false; } private async Task SwitchToFriendshipTeamIfNeeded() { if (string.IsNullOrWhiteSpace(_config.FriendshipTeam)) { return; } Simulation.SendInput.SimulateAction(GIActions.MoveForward, KeyType.KeyUp); try { _switchPartyTask ??= new SwitchPartyTask(); await _switchPartyTask.Start(_config.FriendshipTeam, _ct); } catch (Exception ex) { _logger.LogWarning(ex, "切换好感队失败!"); } } private async Task SwitchBackToCombatTeam() { if (string.IsNullOrWhiteSpace(_config.Team)) { return; } Simulation.SendInput.SimulateAction(GIActions.MoveForward, KeyType.KeyUp); _switchPartyTask ??= new SwitchPartyTask(); await _switchPartyTask.Start(_config.Team, _ct); } private async Task AttemptReward(int retryCount = 0) { const int maxRetry = 3; if (retryCount >= maxRetry) { throw new Exception("领取奖励失败"); } Simulation.SendInput.SimulateAction(GIActions.PickUpOrInteract); await Delay(800, _ct); if (!await VerifyRewardPage()) { await _returnMainUiTask.Start(_ct); await AutoNavigateToReward(); return await AttemptReward(retryCount + 1); } var isOriginalResinEmpty = await CheckOriginalResinEmpty(); var sortedButtons = FindAndSortUseButtons(); if (sortedButtons.Count == 0) { await EnsureExitRewardPage(); return false; } var resinChoice = await AnalyzeResinOptions(sortedButtons, isOriginalResinEmpty); if (resinChoice == null) { await EnsureExitRewardPage(); return false; } resinChoice.Value.Click(); await Delay(1000, _ct); if (!string.IsNullOrWhiteSpace(_config.FriendshipTeam)) { await SwitchBackToCombatTeam(); } await Delay(1200, _ct); await EnsureExitRewardPage(); return true; } private async Task VerifyRewardPage() { using var capture = CaptureToRectArea(); var roi = new Rect(0, 0, capture.Width, capture.Height / 2); var list = capture.FindMulti(RecognitionObject.Ocr(roi)); foreach (var res in list) { var text = res.Text; if (text.Contains("激活地脉之花", StringComparison.Ordinal) || text.Contains("选择激活方式", StringComparison.Ordinal)) { return true; } } return false; } private async Task CheckOriginalResinEmpty() { using var capture = CaptureToRectArea(); var list = capture.FindMulti(_ocrRoThis); foreach (var res in list) { if (res.Text.Contains("补充", StringComparison.Ordinal)) { return true; } } return false; } private List FindAndSortUseButtons() { using var capture = CaptureToRectArea(); var list = capture.FindMulti(_ocrRoThis); var buttons = new List(); foreach (var res in list) { var text = res.Text.Trim(); if (text == "使用") { var centerX = res.X + res.Width / 2; var centerY = res.Y + res.Height / 2; buttons.Add(new UseButton(centerX, centerY, res.Y)); } } return buttons.OrderBy(b => b.SortKey).ToList(); } private async Task AnalyzeResinOptions(List sortedButtons, bool isOriginalResinEmpty) { using var capture = CaptureToRectArea(); var list = capture.FindMulti(_ocrRoThis); var texts = list.Select(r => new { r.Text, r.Y }).ToList(); var hasDoubleReward = texts.Any(t => t.Text.Contains("双倍", StringComparison.Ordinal) || t.Text.Contains("2倍产出", StringComparison.Ordinal) || t.Text.Contains("2倍", StringComparison.Ordinal)); var hasOriginal20 = !isOriginalResinEmpty && texts.Any(t => t.Text.Contains("20", StringComparison.Ordinal) && t.Text.Contains("原粹", StringComparison.Ordinal)); var hasOriginal40 = !isOriginalResinEmpty && texts.Any(t => t.Text.Contains("40", StringComparison.Ordinal) && t.Text.Contains("原粹", StringComparison.Ordinal)); var hasCondensed = texts.Any(t => t.Text.Contains("浓缩", StringComparison.Ordinal)); var hasTransient = texts.Any(t => t.Text.Contains("须臾", StringComparison.Ordinal)); var hasFragile = texts.Any(t => t.Text.Contains("脆弱", StringComparison.Ordinal)); if (isOriginalResinEmpty) { if (hasCondensed && sortedButtons.Count >= 1) { return sortedButtons[0]; } if (hasTransient && _config.UseTransientResin && sortedButtons.Count >= 1) { return sortedButtons[0]; } if (hasFragile && _config.UseFragileResin && sortedButtons.Count >= 1) { return sortedButtons[0]; } return null; } if (hasDoubleReward && (hasOriginal20 || hasOriginal40)) { if (hasOriginal20 && !hasOriginal40) { await TrySwitch20To40Resin(); } return sortedButtons.FirstOrDefault(); } if (hasCondensed && sortedButtons.Count >= 2) { return sortedButtons[1]; } if (hasTransient && _config.UseTransientResin && sortedButtons.Count >= 2) { return sortedButtons[1]; } if (hasOriginal20 || hasOriginal40) { if (hasOriginal20 && !hasOriginal40) { await TrySwitch20To40Resin(); } return sortedButtons.FirstOrDefault(); } if (hasFragile && _config.UseFragileResin && sortedButtons.Count >= 2) { return sortedButtons[1]; } return sortedButtons.FirstOrDefault(); } private async Task TrySwitch20To40Resin() { var switchRo = BuildTemplate("Assets/icon/switch_button.png", null, 0.7); using var capture = CaptureToRectArea(); var res = capture.Find(switchRo); if (res.IsEmpty()) { return false; } res.Click(); await Delay(800, _ct); using var check = CaptureToRectArea(); var list = check.FindMulti(_ocrRoThis); return list.Any(r => r.Text.Contains("40", StringComparison.Ordinal) && r.Text.Contains("原粹", StringComparison.Ordinal)); } private async Task EnsureExitRewardPage() { const int maxAttempts = 5; for (var i = 0; i < maxAttempts; i++) { if (!await VerifyRewardPage()) { return; } Simulation.SendInput.Keyboard.KeyPress(User32.VK.VK_ESCAPE); await Delay(800, _ct); } } private async Task CloseCustomMarks() { await _returnMainUiTask.Start(_ct); Simulation.SendInput.SimulateAction(GIActions.OpenMap); await Delay(1000, _ct); GameCaptureRegion.GameRegion1080PPosClick(60, 1020); await Delay(600, _ct); using var capture = CaptureToRectArea(); if (_openRo == null) { return; } var button = capture.Find(_openRo); if (button.IsExist()) { _marksStatus = false; button.Click(); await Delay(600, _ct); } Simulation.SendInput.Keyboard.KeyPress(User32.VK.VK_ESCAPE); } private async Task OpenCustomMarks() { await _returnMainUiTask.Start(_ct); Simulation.SendInput.SimulateAction(GIActions.OpenMap); await Delay(1000, _ct); GameCaptureRegion.GameRegion1080PPosClick(60, 1020); await Delay(600, _ct); if (_closeRo == null) { return; } using var capture = CaptureToRectArea(); var buttons = capture.FindMulti(_closeRo); foreach (var button in buttons) { if (button.Y > ScaleTo1080(280) && button.Y < ScaleTo1080(350)) { button.Click(); _marksStatus = true; break; } } } private async Task FindLeyLineOutcropByBook(string country, string type) { await _returnMainUiTask.Start(_ct); await Delay(1000, _ct); Simulation.SendInput.SimulateAction(GIActions.OpenAdventurerHandbook); await Delay(2500, _ct); GameCaptureRegion.GameRegion1080PPosClick(300, 550); await Delay(1000, _ct); GameCaptureRegion.GameRegion1080PPosClick(500, 200); await Delay(1000, _ct); GameCaptureRegion.GameRegion1080PPosClick(500, 500); await Delay(1000, _ct); if (type == "启示之花") { GameCaptureRegion.GameRegion1080PPosClick(700, 350); } else { GameCaptureRegion.GameRegion1080PPosClick(500, 350); } await Delay(1000, _ct); GameCaptureRegion.GameRegion1080PPosClick(1300, 800); await Delay(1000, _ct); await FindAndClickCountry(country); await FindAndCancelTrackingInBook(); for (var retry = 0; retry < 3; retry++) { await Delay(1000, _ct); GameCaptureRegion.GameRegion1080PPosClick(1500, 850); await Delay(2500, _ct); if (await CheckBigMapOpened()) { break; } if (retry < 2) { await _returnMainUiTask.Start(_ct); await FindAndClickCountry(country); await FindAndCancelTrackingInBook(); } else { throw new Exception("大地图打开失败"); } } var center = _tpTask.GetBigMapCenterPoint(MapTypes.Teyvat.ToString()); _leyLineX = center.X; _leyLineY = center.Y; await CancelTrackingInMap(); } private async Task CheckBigMapOpened() { if (_mapSettingButtonRo == null) { return false; } using var capture = CaptureToRectArea(); return capture.Find(_mapSettingButtonRo).IsExist(); } private async Task FindAndClickCountry(string country) { var match = country == "挪德卡莱" ? "挪德卡" : country; using var capture = CaptureToRectArea(); var list = capture.FindMulti(_ocrRoThis); var target = list.FirstOrDefault(r => r.Text.Contains(match, StringComparison.Ordinal)); if (target == null) { throw new Exception($"冒险之证未找到国家: {country}"); } target.Click(); } private async Task FindAndCancelTrackingInBook() { using var capture = CaptureToRectArea(); var list = capture.FindMulti(_ocrRoThis); var stop = list.FirstOrDefault(r => r.Text.Contains("停止", StringComparison.Ordinal)); stop?.Click(); await Delay(1000, _ct); } private async Task CancelTrackingInMap() { GameCaptureRegion.GameRegion1080PPosClick(960, 540); await Delay(1000, _ct); using var capture = CaptureToRectArea(); var list = capture.FindMulti(_ocrRoThis); var stop = list.FirstOrDefault(r => r.Text.Contains("停止", StringComparison.Ordinal)); if (stop != null) { stop.Click(); return; } var leyLine = list.FirstOrDefault(r => r.Text.Contains("地脉", StringComparison.Ordinal) || r.Text.Contains("衍出", StringComparison.Ordinal)); if (leyLine != null) { leyLine.Click(); await Delay(1000, _ct); GameCaptureRegion.GameRegion1080PPosClick(1700, 1010); await Delay(1000, _ct); } } private async Task RecheckResinAndContinue() { _recheckCount++; if (_config.OpenModeCountMin) { if (_currentRunTimes >= _config.Count) { return; } } if (_recheckCount > MaxRecheckCount) { return; } var result = await CalCountByResin(); if (result.Count <= 0) { return; } if (result.Count > 50) { return; } _currentRunTimes = 0; _config.Count = result.Count; await RunLeyLineChallenges(); await RecheckResinAndContinue(); } private async Task CalCountByResin() { var counts = await CountAllResin(); var originalTimes = counts.OriginalResinCount / 40; var remaining = counts.OriginalResinCount % 40; if (remaining >= 20) { originalTimes += remaining / 20; } var condensedTimes = counts.CondensedResinCount; var transientTimes = _config.UseTransientResin ? counts.TransientResinCount : 0; var fragileTimes = _config.UseFragileResin ? counts.FragileResinCount : 0; return new ResinCountResult { Count = originalTimes + condensedTimes + transientTimes + fragileTimes, OriginalResinTimes = originalTimes, CondensedResinTimes = condensedTimes, TransientResinTimes = transientTimes, FragileResinTimes = fragileTimes }; } private async Task CountAllResin() { await _returnMainUiTask.Start(_ct); await Delay(1500, _ct); Simulation.SendInput.SimulateAction(GIActions.OpenMap); await Delay(1500, _ct); var result = new ResinCounts { OriginalResinCount = await CountOriginalResin(), CondensedResinCount = await CountCondensedResin() }; if (_config.UseTransientResin || _config.UseFragileResin) { await OpenReplenishResinUi(); await Delay(1500, _ct); result.TransientResinCount = await CountTransientResin(); result.FragileResinCount = await CountFragileResin(); } await _returnMainUiTask.Start(_ct); return result; } private async Task CountOriginalResin() { var icon = BuildTemplate("Assets/1920x1080/original_resin.png"); using var capture = CaptureToRectArea(); var res = capture.Find(icon); if (res.IsEmpty()) { return 0; } var roi = new Rect(res.X, res.Y, ScaleTo1080(200), ScaleTo1080(40)); using var region = capture.DeriveCrop(roi); var text = OcrFactory.Paddle.OcrWithoutDetector(region.CacheGreyMat); var match = Regex.Match(text, @"(\d{1,3})\s*/\s*\d+"); if (match.Success) { return int.TryParse(match.Groups[1].Value, out var value) ? value : 0; } return 0; } private async Task CountCondensedResin() { var icon = BuildTemplate("Assets/1920x1080/condensed_resin.png"); using var capture = CaptureToRectArea(); var res = capture.Find(icon); if (res.IsEmpty()) { return 0; } var roi = new Rect(res.Right, res.Y, ScaleTo1080(90), ScaleTo1080(40)); using var region = capture.DeriveCrop(roi); var text = OcrFactory.Paddle.OcrWithoutDetector(region.CacheGreyMat); if (int.TryParse(Regex.Match(text, @"\d+").Value, out var value)) { return value; } return await RecognizeWhiteNumber(region, capture); } private async Task CountTransientResin() { var icon = BuildTemplate("Assets/1920x1080/transient_resin.png"); using var capture = CaptureToRectArea(); var res = capture.Find(icon); if (res.IsEmpty()) { return 0; } var roi = new Rect(res.X, res.Bottom, res.Width, ScaleTo1080(60)); using var region = capture.DeriveCrop(roi); return await RecognizeNumberWithFallback(region); } private async Task CountFragileResin() { var icon = BuildTemplate("Assets/1920x1080/fragile_resin.png"); using var capture = CaptureToRectArea(); var res = capture.Find(icon); if (res.IsEmpty()) { return 0; } var roi = new Rect(res.X, res.Bottom, res.Width, ScaleTo1080(60)); using var region = capture.DeriveCrop(roi); return await RecognizeNumberWithFallback(region); } private async Task RecognizeNumberWithFallback(ImageRegion region) { var text = OcrFactory.Paddle.OcrWithoutDetector(region.CacheGreyMat); if (int.TryParse(Regex.Match(text, @"\d+").Value, out var value)) { return value; } return await RecognizeNumberByTemplate(region, false); } private async Task RecognizeWhiteNumber(ImageRegion region, ImageRegion capture) { return await RecognizeNumberByTemplate(region, true); } private async Task RecognizeNumberByTemplate(ImageRegion region, bool white) { var icons = white ? new Dictionary { { 0, "Assets/1920x1080/num0_white.png" }, { 1, "Assets/1920x1080/num1_white.png" }, { 2, "Assets/1920x1080/num2_white.png" }, { 3, "Assets/1920x1080/num3_white.png" }, { 4, "Assets/1920x1080/num4_white.png" }, { 5, "Assets/1920x1080/num5_white.png" } } : new Dictionary { { 1, "Assets/1920x1080/num1.png" }, { 2, "Assets/1920x1080/num2.png" }, { 3, "Assets/1920x1080/num3.png" }, { 4, "Assets/1920x1080/num4.png" } }; foreach (var kvp in icons) { var ro = BuildTemplate(kvp.Value); var result = region.Find(ro); if (result.IsExist()) { return kvp.Key; } } return 0; } private async Task OpenReplenishResinUi() { var ro = BuildTemplate("Assets/icon/replenish_resin_button.png"); using var capture = CaptureToRectArea(); var res = capture.Find(ro); if (res.IsExist()) { res.Click(); } } private class AutoLeyLineConfigData { [JsonPropertyName("errorThreshold")] public double ErrorThreshold { get; set; } [JsonPropertyName("mapPositions")] public Dictionary> MapPositions { get; set; } = []; [JsonPropertyName("leyLinePositions")] public Dictionary> LeyLinePositions { get; set; } = []; } private class MapPosition { [JsonPropertyName("x")] public double X { get; set; } [JsonPropertyName("y")] public double Y { get; set; } [JsonPropertyName("name")] public string? Name { get; set; } } private class LeyLinePosition { [JsonPropertyName("x")] public double X { get; set; } [JsonPropertyName("y")] public double Y { get; set; } [JsonPropertyName("strategy")] public string Strategy { get; set; } = string.Empty; [JsonPropertyName("steps")] public int Steps { get; set; } [JsonPropertyName("order")] public int Order { get; set; } } private class RawNodeData { [JsonPropertyName("teleports")] public List Teleports { get; set; } = []; [JsonPropertyName("blossoms")] public List Blossoms { get; set; } = []; [JsonPropertyName("edges")] public List Edges { get; set; } = []; [JsonPropertyName("indexes")] public Dictionary>> Indexes { get; set; } = []; } private class RawNode { [JsonPropertyName("id")] public int Id { get; set; } [JsonPropertyName("region")] public string Region { get; set; } = string.Empty; [JsonPropertyName("position")] public NodePosition Position { get; set; } = new(); } private class RawEdge { [JsonPropertyName("source")] public int Source { get; set; } [JsonPropertyName("target")] public int Target { get; set; } [JsonPropertyName("route")] public string Route { get; set; } = string.Empty; } private class NodeData { public List Nodes { get; set; } = []; public Dictionary>> Indexes { get; set; } = []; } private class Node { public int Id { get; set; } public string Region { get; set; } = string.Empty; public NodePosition Position { get; set; } = new(); public string Type { get; set; } = string.Empty; public List Next { get; set; } = []; public List Prev { get; set; } = []; } private class NodeRoute { public int Target { get; set; } public string Route { get; set; } = string.Empty; } private class NodePosition { [JsonPropertyName("x")] public double X { get; set; } [JsonPropertyName("y")] public double Y { get; set; } } private class PathInfo { public Node StartNode { get; set; } = new(); public Node TargetNode { get; set; } = new(); public List Routes { get; set; } = []; } private class ResinCounts { public int OriginalResinCount { get; set; } public int CondensedResinCount { get; set; } public int TransientResinCount { get; set; } public int FragileResinCount { get; set; } } private class ResinCountResult { public int Count { get; set; } public int OriginalResinTimes { get; set; } public int CondensedResinTimes { get; set; } public int TransientResinTimes { get; set; } public int FragileResinTimes { get; set; } } private readonly struct UseButton { public int X { get; } public int Y { get; } public int SortKey { get; } public UseButton(int x, int y, int sortKey) { X = x; Y = y; SortKey = sortKey; } public void Click() { GameCaptureRegion.GameRegion1080PPosClick(X, Y); } } }