diff --git a/BetterGenshinImpact/GameTask/AutoPathing/PathExecutor.cs b/BetterGenshinImpact/GameTask/AutoPathing/PathExecutor.cs index 7dfb29f2..c4010a98 100644 --- a/BetterGenshinImpact/GameTask/AutoPathing/PathExecutor.cs +++ b/BetterGenshinImpact/GameTask/AutoPathing/PathExecutor.cs @@ -38,6 +38,7 @@ using BetterGenshinImpact.GameTask.Common.Element.Assets; using BetterGenshinImpact.GameTask.Common.Exceptions; using BetterGenshinImpact.GameTask.Common.Map.Maps; using BetterGenshinImpact.GameTask.AutoFight; +using BetterGenshinImpact.Service.GearTask.Execution; namespace BetterGenshinImpact.GameTask.AutoPathing; @@ -101,6 +102,10 @@ public class PathExecutor // 最近一次获取派遣奖励的时间 private DateTime _lastGetExpeditionRewardsTime = DateTime.MinValue; + private PathingGearTaskResumeState? _resumeState; + + public IPathingRuntimeNotifier RuntimeNotifier { get; set; } = NullPathingRuntimeNotifier.Instance; + //当到达恢复点位 public void TryCloseSkipOtherOperations() @@ -128,6 +133,25 @@ public class PathExecutor RecordWaypoint = CurWaypoint; } + public void ApplyResumeState(PathingGearTaskResumeState state) + { + _resumeState = state; + _skipOtherOperations = state.SkipOtherOperations; + } + + public PathingGearTaskResumeState CaptureResumeState() + { + return new PathingGearTaskResumeState + { + PathFile = string.Empty, + WaypointGroupIndex = CurWaypoints.Item1, + WaypointIndex = CurWaypoint.Item1, + SkipOtherOperations = _skipOtherOperations, + LastAction = CurWaypoint.Item2?.Action, + CaptureTime = DateTime.Now, + }; + } + public async Task Pathing(PathingTask task) { // SuspendableDictionary; @@ -181,7 +205,9 @@ public class PathExecutor foreach (var waypoint in waypoints) // 一条路径 { CurWaypoint = (waypoints.FindIndex(wps => wps == waypoint), waypoint); + await NotifyWaypointEnteredAsync(waypoint); TryCloseSkipOtherOperations(); + await NotifyResumePointUpdatedAsync("路径断点已更新"); await RecoverWhenLowHp(waypoint); // 低血量恢复 if (waypoint.Type == WaypointType.Teleport.Code) @@ -203,6 +229,8 @@ public class PathExecutor } } await HandleTeleportWaypoint(waypoint); + await RuntimeNotifier.NotifyTeleportCompletedAsync(CurWaypoints.Item1, CurWaypoint.Item1, waypoint.X, waypoint.Y, ct); + await NotifyWaypointCompletedAsync(waypoint); } else { @@ -235,7 +263,13 @@ public class PathExecutor // 执行 action await AfterMoveToTarget(waypoint); + if (!string.IsNullOrWhiteSpace(waypoint.Action)) + { + await RuntimeNotifier.NotifyActionCompletedAsync(CurWaypoints.Item1, CurWaypoint.Item1, waypoint.Action, waypoint.X, waypoint.Y, ct); + } } + + await NotifyWaypointCompletedAsync(waypoint); } } @@ -717,6 +751,46 @@ public class PathExecutor await Delay(500, ct); // 多等一会 } + private async Task NotifyWaypointEnteredAsync(WaypointForTrack waypoint) + { + ApplyResumeSkipPolicy(); + await RuntimeNotifier.NotifyWaypointEnteredAsync(CurWaypoints.Item1, CurWaypoint.Item1, waypoint.Action, waypoint.X, waypoint.Y, ct); + } + + private async Task NotifyWaypointCompletedAsync(WaypointForTrack waypoint) + { + await RuntimeNotifier.NotifyWaypointCompletedAsync(CurWaypoints.Item1, CurWaypoint.Item1, waypoint.Action, waypoint.X, waypoint.Y, ct); + } + + private async Task NotifyResumePointUpdatedAsync(string message) + { + var state = CaptureResumeState(); + await RuntimeNotifier.NotifyResumePointUpdatedAsync(state, message, ct); + } + + private void ApplyResumeSkipPolicy() + { + if (_resumeState == null) + { + return; + } + + if (CurWaypoints.Item1 < _resumeState.WaypointGroupIndex) + { + _skipOtherOperations = true; + return; + } + + if (CurWaypoints.Item1 == _resumeState.WaypointGroupIndex && CurWaypoint.Item1 < _resumeState.WaypointIndex) + { + _skipOtherOperations = true; + return; + } + + _skipOtherOperations = false; + _resumeState = null; + } + public async Task FaceTo(WaypointForTrack waypoint) { var screen = CaptureToRectArea(); diff --git a/BetterGenshinImpact/Model/Gear/Tasks/BaseGearTask.cs b/BetterGenshinImpact/Model/Gear/Tasks/BaseGearTask.cs index 86187eb7..27bfda4a 100644 --- a/BetterGenshinImpact/Model/Gear/Tasks/BaseGearTask.cs +++ b/BetterGenshinImpact/Model/Gear/Tasks/BaseGearTask.cs @@ -5,6 +5,7 @@ using System.IO; using System.Threading; using System.Threading.Tasks; using BetterGenshinImpact.GameTask.AutoGeniusInvokation.Exception; +using BetterGenshinImpact.Service.GearTask.Execution; using CommunityToolkit.Mvvm.ComponentModel; using Microsoft.Extensions.Logging; using Newtonsoft.Json; @@ -12,7 +13,7 @@ using Newtonsoft.Json; namespace BetterGenshinImpact.Model.Gear.Tasks; /// -/// 为了和其他Task做区分,使用Gear(齿轮)来作为前缀命名调度器内定义的任务 +/// 为了和其他 Task 做区分,这里使用 Gear(齿轮) 作为调度器内任务定义的统一抽象。 /// public abstract class BaseGearTask : ObservableObject { @@ -23,14 +24,14 @@ public abstract class BaseGearTask : ObservableObject /// 任务名称 /// public string Name { get; set; } = string.Empty; - + /// /// 任务类型 /// public string Type { get; set; } = string.Empty; /// - /// 任务的文件位置,如果有 + /// 任务的文件位置,如果有。 /// public string FilePath { get; set; } = string.Empty; @@ -50,6 +51,14 @@ public abstract class BaseGearTask : ObservableObject /// public List Children { get; set; } = []; + [JsonIgnore] + public GearTaskNodeExecutionContext? ExecutionContext { get; private set; } + + public virtual void SetExecutionContext(GearTaskNodeExecutionContext context) + { + ExecutionContext = context; + } + /// /// 执行任务 /// @@ -62,7 +71,7 @@ public abstract class BaseGearTask : ObservableObject stopwatch.Start(); await Run(ct); } - catch (NormalEndException e) + catch (NormalEndException) { throw; } @@ -75,13 +84,17 @@ public abstract class BaseGearTask : ObservableObject { _logger.LogDebug(e, "执行脚本时发生异常"); _logger.LogError("执行脚本时发生异常: {Msg}", e.Message); + throw; } finally { stopwatch.Stop(); var elapsedTime = TimeSpan.FromMilliseconds(stopwatch.ElapsedMilliseconds); - _logger.LogInformation("→ 脚本执行结束: {Name}, 耗时: {Minutes}分{Seconds:0.000}秒", Name, - elapsedTime.Hours * 60 + elapsedTime.Minutes, elapsedTime.TotalSeconds % 60); + _logger.LogInformation( + "-> 脚本执行结束: {Name}, 耗时: {Minutes}分{Seconds:0.000}秒", + Name, + elapsedTime.Hours * 60 + elapsedTime.Minutes, + elapsedTime.TotalSeconds % 60); _logger.LogInformation("------------------------------"); } } @@ -90,7 +103,6 @@ public abstract class BaseGearTask : ObservableObject /// 执行任务 /// public abstract Task Run(CancellationToken ct); - public static BaseGearTask ReadFileToBaseGearTasks(string? path) { @@ -98,8 +110,8 @@ public abstract class BaseGearTask : ObservableObject { throw new ArgumentException("任务文件路径不能为空", nameof(path)); } + var json = File.ReadAllText(path); return JsonConvert.DeserializeObject(json) ?? throw new InvalidOperationException("任务数据读取结果为空"); } - } diff --git a/BetterGenshinImpact/Model/Gear/Tasks/PathingGearTask.cs b/BetterGenshinImpact/Model/Gear/Tasks/PathingGearTask.cs index 93423dd9..f0235263 100644 --- a/BetterGenshinImpact/Model/Gear/Tasks/PathingGearTask.cs +++ b/BetterGenshinImpact/Model/Gear/Tasks/PathingGearTask.cs @@ -3,38 +3,59 @@ using System.Threading.Tasks; using BetterGenshinImpact.Core.Script; using BetterGenshinImpact.GameTask; using BetterGenshinImpact.GameTask.AutoPathing; -using BetterGenshinImpact.GameTask.AutoPathing.Model; using BetterGenshinImpact.Model.Gear.Parameter; +using BetterGenshinImpact.Service.GearTask.Execution; +using Newtonsoft.Json; namespace BetterGenshinImpact.Model.Gear.Tasks; -public class PathingGearTask : BaseGearTask +public class PathingGearTask : BaseGearTask, IGearTaskResumable { - - private PathingGearTaskParams _params; + private readonly PathingGearTaskParams _params; + private PathingGearTaskResumeState? _resumeState; public PathingGearTask(PathingGearTaskParams param) { FilePath = param.Path; _params = param; } - + public override async Task Run(CancellationToken ct) { - // 加载并执行 - var task = PathingTask.BuildFromFilePath(_params.Path); - var pathingTask = new PathExecutor(ct); + var task = BetterGenshinImpact.GameTask.AutoPathing.Model.PathingTask.BuildFromFilePath(_params.Path); + var pathExecutor = new PathExecutor(ct); if (_params.PathingPartyConfig != null) { - pathingTask.PartyConfig = _params.PathingPartyConfig; + pathExecutor.PartyConfig = _params.PathingPartyConfig; } - if (pathingTask.PartyConfig.AutoPickEnabled) + if (pathExecutor.PartyConfig.AutoPickEnabled) { TaskTriggerDispatcher.Instance().AddTrigger("AutoPick", null); } - await pathingTask.Pathing(task); + pathExecutor.RuntimeNotifier = ExecutionContext != null + ? new GearTaskPathingRuntimeNotifier(ExecutionContext) + : NullPathingRuntimeNotifier.Instance; + + if (_resumeState != null) + { + pathExecutor.ApplyResumeState(_resumeState); + } + + await pathExecutor.Pathing(task); } -} \ No newline at end of file + + public Task ApplyResumeTokenAsync(string? resumeTokenJson, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(resumeTokenJson)) + { + _resumeState = null; + return Task.CompletedTask; + } + + _resumeState = JsonConvert.DeserializeObject(resumeTokenJson); + return Task.CompletedTask; + } +} diff --git a/BetterGenshinImpact/Service/GearTask/Execution/GearTaskEventBus.cs b/BetterGenshinImpact/Service/GearTask/Execution/GearTaskEventBus.cs new file mode 100644 index 00000000..a2caf8e7 --- /dev/null +++ b/BetterGenshinImpact/Service/GearTask/Execution/GearTaskEventBus.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace BetterGenshinImpact.Service.GearTask.Execution; + +/// +/// 默认事件总线实现。 +/// 当前采用进程内同步分发,借由消费者内部自行决定是否异步缓冲。 +/// +public sealed class GearTaskEventBus : IGearTaskEventBus +{ + private readonly ILogger _logger; + private readonly object _gate = new(); + private readonly List _consumers = []; + + public GearTaskEventBus(ILogger logger) + { + _logger = logger; + } + + public ValueTask PublishAsync(GearTaskExecutionEvent evt, CancellationToken ct = default) + { + IGearTaskEventConsumer[] snapshot; + lock (_gate) + { + // 先拷贝快照,避免消费者在回调期间订阅或退订影响当前广播。 + snapshot = _consumers.ToArray(); + } + + return PublishCoreAsync(snapshot, evt, ct); + } + + public IDisposable Subscribe(IGearTaskEventConsumer consumer) + { + lock (_gate) + { + _consumers.Add(consumer); + } + + return new Subscription(this, consumer); + } + + private async ValueTask PublishCoreAsync(IEnumerable consumers, GearTaskExecutionEvent evt, CancellationToken ct) + { + foreach (var consumer in consumers) + { + try + { + await consumer.ConsumeAsync(evt, ct); + } + catch (Exception ex) + { + _logger.LogError(ex, + "GearTask 事件消费者处理失败,已隔离该异常。Consumer: {ConsumerType}, Event: {EventType}, RecordId: {RecordId}", + consumer.GetType().Name, + evt.GetType().Name, + evt.RecordId); + } + } + } + + private void Unsubscribe(IGearTaskEventConsumer consumer) + { + lock (_gate) + { + _consumers.Remove(consumer); + } + } + + private sealed class Subscription : IDisposable + { + private readonly GearTaskEventBus _bus; + private readonly IGearTaskEventConsumer _consumer; + private bool _disposed; + + public Subscription(GearTaskEventBus bus, IGearTaskEventConsumer consumer) + { + _bus = bus; + _consumer = consumer; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _bus.Unsubscribe(_consumer); + } + } +} diff --git a/BetterGenshinImpact/Service/GearTask/Execution/GearTaskExecutionEvent.cs b/BetterGenshinImpact/Service/GearTask/Execution/GearTaskExecutionEvent.cs new file mode 100644 index 00000000..c7c81c1a --- /dev/null +++ b/BetterGenshinImpact/Service/GearTask/Execution/GearTaskExecutionEvent.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; + +namespace BetterGenshinImpact.Service.GearTask.Execution; + +/// +/// GearTask 执行链路中的统一事件基类。 +/// 执行器只负责发布事件,记录器、UI 投影和其他消费者按需订阅并消费。 +/// +public abstract class GearTaskExecutionEvent +{ + /// + /// 当前执行记录的唯一标识。 + /// + public string RecordId { get; set; } = string.Empty; + + /// + /// 任务定义展示名。 + /// + public string TaskDefinitionName { get; set; } = string.Empty; + + /// + /// 任务定义对应的安全文件名,用于历史记录目录划分。 + /// + public string TaskDefinitionFileKey { get; set; } = string.Empty; + + /// + /// 任务树中的节点路径,例如 0/1/2。 + /// + public string NodeId { get; set; } = string.Empty; + + public string? ParentNodeId { get; set; } + + public string TaskName { get; set; } = string.Empty; + + public string TaskType { get; set; } = string.Empty; + + public string TaskPath { get; set; } = string.Empty; + + public int NodeDepth { get; set; } + + public int NodeOrder { get; set; } + + /// + /// 事件发生时间,默认使用本地时间,便于直接展示在历史时间线中。 + /// + public DateTime Timestamp { get; set; } = DateTime.Now; + + /// + /// 预留给具体任务类型的扩展字段,避免事件模型为了单个任务不断膨胀。 + /// + public Dictionary Extra { get; set; } = []; +} diff --git a/BetterGenshinImpact/Service/GearTask/Execution/GearTaskExecutionEvents.cs b/BetterGenshinImpact/Service/GearTask/Execution/GearTaskExecutionEvents.cs new file mode 100644 index 00000000..aee8328c --- /dev/null +++ b/BetterGenshinImpact/Service/GearTask/Execution/GearTaskExecutionEvents.cs @@ -0,0 +1,171 @@ +namespace BetterGenshinImpact.Service.GearTask.Execution; + +/// +/// 一次任务组执行开始。 +/// +public sealed class ExecutionStartedEvent : GearTaskExecutionEvent +{ + /// + /// 如果本次执行来自续跑,则记录源执行记录 Id。 + /// + public string? SourceRecordId { get; set; } + + /// + /// 本次预计可执行的总节点数,用于进度展示。 + /// + public int TotalRunnableNodeCount { get; set; } +} + +/// +/// 一次任务组执行正常结束。 +/// +public sealed class ExecutionCompletedEvent : GearTaskExecutionEvent +{ +} + +/// +/// 一次任务组执行因异常失败。 +/// +public sealed class ExecutionFailedEvent : GearTaskExecutionEvent +{ + public string ErrorMessage { get; set; } = string.Empty; +} + +/// +/// 一次任务组执行被取消。 +/// +public sealed class ExecutionCancelledEvent : GearTaskExecutionEvent +{ + public string? Reason { get; set; } +} + +/// +/// 一次任务组执行被外部中断,但不一定等价于异常失败。 +/// +public sealed class ExecutionInterruptedEvent : GearTaskExecutionEvent +{ + public string? Reason { get; set; } +} + +/// +/// 单个任务节点开始执行。 +/// +public sealed class TaskNodeStartedEvent : GearTaskExecutionEvent +{ +} + +/// +/// 单个任务节点执行完成。 +/// +public sealed class TaskNodeCompletedEvent : GearTaskExecutionEvent +{ + public string? Message { get; set; } +} + +/// +/// 单个任务节点执行失败。 +/// +public sealed class TaskNodeFailedEvent : GearTaskExecutionEvent +{ + public string ErrorMessage { get; set; } = string.Empty; +} + +/// +/// 单个任务节点被跳过。 +/// +public sealed class TaskNodeSkippedEvent : GearTaskExecutionEvent +{ + public string Reason { get; set; } = string.Empty; +} + +/// +/// 任务内部恢复点更新。 +/// +public sealed class ResumePointUpdatedEvent : GearTaskExecutionEvent +{ + /// + /// 是否支持在该任务内部继续执行,而不是仅支持节点级重跑。 + /// + public bool CanResumeInsideTask { get; set; } + + /// + /// 任务私有恢复状态序列化结果。 + /// + public string? ResumeTokenJson { get; set; } + + public string? Message { get; set; } +} + +/// +/// Pathing 进入某个 waypoint。 +/// +public sealed class PathingWaypointEnteredEvent : GearTaskExecutionEvent +{ + public int WaypointGroupIndex { get; set; } + + public int WaypointIndex { get; set; } + + public string? Action { get; set; } + + public double PositionX { get; set; } + + public double PositionY { get; set; } +} + +/// +/// Pathing 完成某个 waypoint。 +/// +public sealed class PathingWaypointCompletedEvent : GearTaskExecutionEvent +{ + public int WaypointGroupIndex { get; set; } + + public int WaypointIndex { get; set; } + + public string? Action { get; set; } + + public double PositionX { get; set; } + + public double PositionY { get; set; } +} + +/// +/// Pathing 完成传送步骤。 +/// +public sealed class PathingTeleportCompletedEvent : GearTaskExecutionEvent +{ + public int WaypointGroupIndex { get; set; } + + public int WaypointIndex { get; set; } + + public double PositionX { get; set; } + + public double PositionY { get; set; } +} + +/// +/// Pathing 完成 waypoint 绑定动作。 +/// +public sealed class PathingActionCompletedEvent : GearTaskExecutionEvent +{ + public int WaypointGroupIndex { get; set; } + + public int WaypointIndex { get; set; } + + public string Action { get; set; } = string.Empty; + + public double PositionX { get; set; } + + public double PositionY { get; set; } +} + +/// +/// JavaScript 脚本主动上报的检查点事件。 +/// +public sealed class ScriptCheckpointReachedEvent : GearTaskExecutionEvent +{ + public string CheckpointName { get; set; } = string.Empty; + + public string? ResumeTokenJson { get; set; } + + public string? Message { get; set; } +} diff --git a/BetterGenshinImpact/Service/GearTask/Execution/GearTaskExecutionModels.cs b/BetterGenshinImpact/Service/GearTask/Execution/GearTaskExecutionModels.cs new file mode 100644 index 00000000..a73562e6 --- /dev/null +++ b/BetterGenshinImpact/Service/GearTask/Execution/GearTaskExecutionModels.cs @@ -0,0 +1,186 @@ +using System; +using System.Collections.Generic; + +namespace BetterGenshinImpact.Service.GearTask.Execution; + +/// +/// 一次任务组执行记录的状态。 +/// +public enum GearTaskExecutionRecordStatus +{ + Pending, + Running, + Succeeded, + Failed, + Cancelled, + Interrupted, +} + +/// +/// 单个任务节点在执行记录中的状态。 +/// +public enum GearTaskExecutionNodeStatus +{ + Pending, + Running, + Succeeded, + Failed, + Skipped, + Cancelled, + Interrupted, +} + +/// +/// 一次大任务组执行的持久化记录。 +/// +public class GearTaskExecutionRecord +{ + public string RecordId { get; set; } = string.Empty; + + public string TaskDefinitionName { get; set; } = string.Empty; + + public string TaskDefinitionFileKey { get; set; } = string.Empty; + + public string? SourceRecordId { get; set; } + + public GearTaskExecutionRecordStatus Status { get; set; } + + public DateTime StartTime { get; set; } + + public DateTime? EndTime { get; set; } + + public int TotalRunnableNodeCount { get; set; } + + public int CompletedNodeCount { get; set; } + + public int FailedNodeCount { get; set; } + + public int SkippedNodeCount { get; set; } + + public string? CurrentNodeId { get; set; } + + /// + /// 当前记录可恢复时,对应的节点 Id。 + /// + public string? ResumeNodeId { get; set; } + + /// + /// 是否存在有效恢复点。 + /// + public bool CanResume { get; set; } + + public string? InterruptReason { get; set; } + + public string? ErrorMessage { get; set; } + + /// + /// 当前记录内所有节点的最终或最近状态快照。 + /// + public List Nodes { get; set; } = []; + + /// + /// 完整时间线,面向执行记录详情和调试定位。 + /// + public List Timeline { get; set; } = []; + + public int SchemaVersion { get; set; } = 1; +} + +/// +/// 单个任务节点的持久化快照。 +/// +public class GearTaskExecutionNodeRecord +{ + public string NodeId { get; set; } = string.Empty; + + public string? ParentNodeId { get; set; } + + public string TaskName { get; set; } = string.Empty; + + public string TaskType { get; set; } = string.Empty; + + public string TaskPath { get; set; } = string.Empty; + + public int Depth { get; set; } + + public int Order { get; set; } + + public GearTaskExecutionNodeStatus Status { get; set; } + + public DateTime? StartTime { get; set; } + + public DateTime? EndTime { get; set; } + + public string? StatusMessage { get; set; } + + public string? ErrorMessage { get; set; } + + public bool CanResumeInsideTask { get; set; } + + /// + /// 节点内部恢复状态,通常由具体任务类型自己定义。 + /// + public string? ResumeTokenJson { get; set; } +} + +/// +/// 面向“执行详情”视图的时间线条目。 +/// +public class GearTaskExecutionTimelineItem +{ + public DateTime Timestamp { get; set; } + + public string NodeId { get; set; } = string.Empty; + + public string EventType { get; set; } = string.Empty; + + public string Message { get; set; } = string.Empty; + + public Dictionary Extra { get; set; } = []; +} + +/// +/// 执行恢复计划。 +/// 由历史记录解析得到,再传递给执行器决定哪些节点跳过、哪些节点恢复。 +/// +public class GearTaskResumePlan +{ + public string SourceRecordId { get; set; } = string.Empty; + + public string ResumeNodeId { get; set; } = string.Empty; + + public Dictionary NodeResumeTokens { get; set; } = []; +} + +/// +/// 一次执行运行时的公共上下文。 +/// +public sealed class GearTaskExecutionRunContext +{ + public string RecordId { get; init; } = string.Empty; + + public string TaskDefinitionName { get; init; } = string.Empty; + + public string TaskDefinitionFileKey { get; init; } = string.Empty; + + public GearTaskResumePlan? ResumePlan { get; init; } +} + +/// +/// PathingGearTask 的内部恢复状态。 +/// 当前按 waypoint 粒度记录,后续需要更细时可继续扩展。 +/// +public class PathingGearTaskResumeState +{ + public string PathFile { get; set; } = string.Empty; + + public int WaypointGroupIndex { get; set; } + + public int WaypointIndex { get; set; } + + public bool SkipOtherOperations { get; set; } + + public string? LastAction { get; set; } + + public DateTime CaptureTime { get; set; } +} diff --git a/BetterGenshinImpact/Service/GearTask/Execution/GearTaskExecutionPathFormatter.cs b/BetterGenshinImpact/Service/GearTask/Execution/GearTaskExecutionPathFormatter.cs new file mode 100644 index 00000000..01644e64 --- /dev/null +++ b/BetterGenshinImpact/Service/GearTask/Execution/GearTaskExecutionPathFormatter.cs @@ -0,0 +1,87 @@ +using System; +using System.IO; +using BetterGenshinImpact.Core.Config; +using BetterGenshinImpact.ViewModel.Pages; + +namespace BetterGenshinImpact.Service.GearTask.Execution; + +/// +/// 执行记录中的任务路径格式化器。 +/// 将运行期绝对路径转换为和任务定义列表一致的占位符路径。 +/// +public static class GearTaskExecutionPathFormatter +{ + public const string PathingRepoFolderPlaceholder = "{pathingRepoFolder}"; + public const string JsUserFolderPlaceholder = "{jsUserFolder}"; + + public static string FormatTaskPath(string? taskPath) + { + if (string.IsNullOrWhiteSpace(taskPath)) + { + return string.Empty; + } + + var normalizedInput = taskPath.Trim(); + if (!Path.IsPathRooted(normalizedInput)) + { + return normalizedInput; + } + + if (TryFormatUnderRoot(normalizedInput, MapPathingViewModel.PathJsonPath, PathingRepoFolderPlaceholder, out var pathingPath)) + { + return pathingPath; + } + + if (TryFormatUnderRoot(normalizedInput, Global.ScriptPath(), JsUserFolderPlaceholder, out var jsPath)) + { + return jsPath; + } + + return normalizedInput; + } + + private static bool TryFormatUnderRoot(string sourcePath, string rootPath, string placeholder, out string formattedPath) + { + formattedPath = string.Empty; + + var normalizedSource = Path.GetFullPath(sourcePath); + var normalizedRoot = Path.GetFullPath(rootPath); + var sourceWithSeparator = EnsureTrailingSeparator(normalizedSource); + var rootWithSeparator = EnsureTrailingSeparator(normalizedRoot); + + if (string.Equals(normalizedSource, normalizedRoot, StringComparison.OrdinalIgnoreCase)) + { + formattedPath = sourcePath.EndsWith(Path.DirectorySeparatorChar) || sourcePath.EndsWith(Path.AltDirectorySeparatorChar) + ? placeholder + Path.DirectorySeparatorChar + : placeholder; + return true; + } + + if (!sourceWithSeparator.StartsWith(rootWithSeparator, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var relativePath = Path.GetRelativePath(normalizedRoot, normalizedSource); + formattedPath = string.IsNullOrEmpty(relativePath) + ? placeholder + : $@"{placeholder}\{relativePath}"; + + if (sourcePath.EndsWith(Path.DirectorySeparatorChar) || sourcePath.EndsWith(Path.AltDirectorySeparatorChar)) + { + formattedPath += Path.DirectorySeparatorChar; + } + + return true; + } + + private static string EnsureTrailingSeparator(string path) + { + if (path.EndsWith(Path.DirectorySeparatorChar) || path.EndsWith(Path.AltDirectorySeparatorChar)) + { + return path; + } + + return path + Path.DirectorySeparatorChar; + } +} diff --git a/BetterGenshinImpact/Service/GearTask/Execution/GearTaskExecutionRunner.cs b/BetterGenshinImpact/Service/GearTask/Execution/GearTaskExecutionRunner.cs new file mode 100644 index 00000000..2bda22ea --- /dev/null +++ b/BetterGenshinImpact/Service/GearTask/Execution/GearTaskExecutionRunner.cs @@ -0,0 +1,206 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using BetterGenshinImpact.Model.Gear.Tasks; +using Microsoft.Extensions.Logging; + +namespace BetterGenshinImpact.Service.GearTask.Execution; + +/// +/// GearTask 任务树执行器。 +/// 只负责遍历节点、执行任务以及向外发布业务事件。 +/// +public interface IGearTaskExecutionRunner +{ + Task RunAsync(BaseGearTask rootTask, GearTaskExecutionRunContext context, CancellationToken ct); +} + +/// +/// 默认 GearTask 执行器实现。 +/// +public sealed class GearTaskExecutionRunner : IGearTaskExecutionRunner +{ + private readonly ILogger _logger; + private readonly IGearTaskEventBus _eventBus; + + public GearTaskExecutionRunner(ILogger logger, IGearTaskEventBus eventBus) + { + _logger = logger; + _eventBus = eventBus; + } + + public async Task RunAsync(BaseGearTask rootTask, GearTaskExecutionRunContext context, CancellationToken ct) + { + var rootContext = CreateNodeContext(rootTask, context, "0", null, 0, 0); + var startedEvent = rootContext.CreateEvent(); + startedEvent.SourceRecordId = context.ResumePlan?.SourceRecordId; + startedEvent.TotalRunnableNodeCount = CountTotalNodes(rootTask); + await _eventBus.PublishAsync(startedEvent, ct); + + try + { + await ExecuteNodeAsync(rootTask, context, "0", null, 0, 0, ct); + + var completedEvent = rootContext.CreateEvent(); + await _eventBus.PublishAsync(completedEvent, ct); + } + catch (OperationCanceledException) + { + var cancelledEvent = rootContext.CreateEvent(); + cancelledEvent.Reason = "用户取消执行"; + await _eventBus.PublishAsync(cancelledEvent, CancellationToken.None); + throw; + } + catch (Exception ex) + { + var failedEvent = rootContext.CreateEvent(); + failedEvent.ErrorMessage = ex.Message; + await _eventBus.PublishAsync(failedEvent, CancellationToken.None); + throw; + } + } + + private async Task ExecuteNodeAsync( + BaseGearTask task, + GearTaskExecutionRunContext runContext, + string nodeId, + string? parentNodeId, + int depth, + int order, + CancellationToken ct) + { + var nodeContext = CreateNodeContext(task, runContext, nodeId, parentNodeId, depth, order); + + task.SetExecutionContext(nodeContext); + + if (!task.Enabled) + { + var skippedEvent = nodeContext.CreateEvent(); + skippedEvent.Reason = "任务未启用"; + await nodeContext.PublishAsync(skippedEvent, ct); + return; + } + + if (ShouldSkipByResume(runContext.ResumePlan, nodeId)) + { + var skippedEvent = nodeContext.CreateEvent(); + skippedEvent.Reason = "恢复执行时跳过已完成节点"; + await nodeContext.PublishAsync(skippedEvent, ct); + return; + } + + var startedEvent = nodeContext.CreateEvent(); + await nodeContext.PublishAsync(startedEvent, ct); + + try + { + if (task is IGearTaskResumable resumable) + { + await resumable.ApplyResumeTokenAsync(nodeContext.ResumeTokenJson, ct); + } + + await task.Execute(ct); + + var childOrder = 0; + foreach (var child in task.Children) + { + await ExecuteNodeAsync(child, runContext, $"{nodeId}/{childOrder}", nodeId, depth + 1, childOrder, ct); + childOrder++; + } + + var completedEvent = nodeContext.CreateEvent(); + completedEvent.Message = "节点执行完成"; + await nodeContext.PublishAsync(completedEvent, ct); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "执行节点失败: {TaskName} ({NodeId})", task.Name, nodeId); + var failedEvent = nodeContext.CreateEvent(); + failedEvent.ErrorMessage = ex.Message; + await nodeContext.PublishAsync(failedEvent, CancellationToken.None); + throw; + } + } + + private static string? GetResumeToken(GearTaskResumePlan? plan, string nodeId) + { + if (plan == null) + { + return null; + } + + return plan.NodeResumeTokens.TryGetValue(nodeId, out var token) ? token : null; + } + + private static bool ShouldSkipByResume(GearTaskResumePlan? plan, string nodeId) + { + if (plan == null || string.IsNullOrWhiteSpace(plan.ResumeNodeId)) + { + return false; + } + + if (nodeId == plan.ResumeNodeId) + { + return false; + } + + if (plan.ResumeNodeId.StartsWith(nodeId + "/", StringComparison.Ordinal)) + { + // 恢复节点的祖先链仍然需要执行,否则无法走到真正的恢复节点。 + return false; + } + + return IsNodeBefore(nodeId, plan.ResumeNodeId); + } + + /// + /// 比较两个节点路径的顺序,供恢复逻辑判断当前节点是否位于恢复点之前。 + /// + private static bool IsNodeBefore(string leftNodeId, string rightNodeId) + { + var left = leftNodeId.Split('/').Select(int.Parse).ToArray(); + var right = rightNodeId.Split('/').Select(int.Parse).ToArray(); + var minLength = Math.Min(left.Length, right.Length); + for (var i = 0; i < minLength; i++) + { + if (left[i] != right[i]) + { + return left[i] < right[i]; + } + } + + return left.Length < right.Length; + } + + /// + /// 统计任务树节点总数,用于生成执行进度的总量基线。 + /// + private static int CountTotalNodes(BaseGearTask task) + { + return 1 + task.Children.Sum(CountTotalNodes); + } + + private GearTaskNodeExecutionContext CreateNodeContext( + BaseGearTask task, + GearTaskExecutionRunContext runContext, + string nodeId, + string? parentNodeId, + int depth, + int order) + { + return GearTaskNodeExecutionContext.Create( + _eventBus, + task, + runContext, + nodeId, + parentNodeId, + depth, + order, + GetResumeToken(runContext.ResumePlan, nodeId)); + } +} diff --git a/BetterGenshinImpact/Service/GearTask/Execution/GearTaskHistoryRecorder.cs b/BetterGenshinImpact/Service/GearTask/Execution/GearTaskHistoryRecorder.cs new file mode 100644 index 00000000..604fb5d3 --- /dev/null +++ b/BetterGenshinImpact/Service/GearTask/Execution/GearTaskHistoryRecorder.cs @@ -0,0 +1,404 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace BetterGenshinImpact.Service.GearTask.Execution; + +/// +/// GearTask 历史记录器。 +/// 通过订阅执行事件把事件写入 Channel,再由单消费者后台循环聚合并异步落盘。 +/// +public sealed class GearTaskHistoryRecorder : BackgroundService, IGearTaskEventConsumer +{ + private static readonly TimeSpan FlushDebounce = TimeSpan.FromMilliseconds(300); + + private readonly ILogger _logger; + private readonly IGearTaskHistoryStore _historyStore; + private readonly IDisposable _subscription; + private readonly Channel _eventChannel; + private readonly Dictionary _records = []; + private readonly Dictionary _dirtyRecords = []; + private readonly HashSet _priorityFlushRecords = []; + private bool _disposed; + + public GearTaskHistoryRecorder( + ILogger logger, + IGearTaskHistoryStore historyStore, + IGearTaskEventBus eventBus) + { + _logger = logger; + _historyStore = historyStore; + _subscription = eventBus.Subscribe(this); + _eventChannel = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = false, + AllowSynchronousContinuations = false, + }); + } + + public ValueTask ConsumeAsync(GearTaskExecutionEvent evt, CancellationToken ct = default) + { + if (_disposed) + { + return ValueTask.CompletedTask; + } + + if (!_eventChannel.Writer.TryWrite(evt)) + { + _logger.LogDebug("GearTask 历史记录事件被忽略,记录器可能正在关闭: {RecordId}", evt.RecordId); + } + + return ValueTask.CompletedTask; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try + { + while (true) + { + var waitForEventTask = _eventChannel.Reader.WaitToReadAsync(stoppingToken).AsTask(); + var waitForFlushDelayTask = Task.Delay(FlushDebounce, stoppingToken); + var completedTask = await Task.WhenAny(waitForEventTask, waitForFlushDelayTask); + + if (completedTask == waitForEventTask) + { + if (!await waitForEventTask) + { + break; + } + + while (_eventChannel.Reader.TryRead(out var evt)) + { + ProcessEvent(evt); + } + } + + await FlushDirtyRecordsAsync(force: false); + } + } + catch (OperationCanceledException) + { + // shutdown + } + catch (Exception ex) + { + _logger.LogError(ex, "GearTask 历史记录后台消费者异常退出"); + } + finally + { + try + { + while (_eventChannel.Reader.TryRead(out var evt)) + { + ProcessEvent(evt); + } + + await FlushDirtyRecordsAsync(force: true); + } + catch (Exception ex) + { + _logger.LogError(ex, "GearTask 历史记录器在退出时落盘失败"); + } + } + } + + private void ProcessEvent(GearTaskExecutionEvent evt) + { + var record = GetOrAddRecord(evt); + ApplyEvent(record, evt); + + _dirtyRecords[record.RecordId] = DateTime.UtcNow; + if (IsTerminalEvent(evt)) + { + // 终态记录在当前批次处理完后优先刷盘,尽量避免任务刚结束但历史还没落地。 + _priorityFlushRecords.Add(record.RecordId); + } + } + + private GearTaskExecutionRecord GetOrAddRecord(GearTaskExecutionEvent evt) + { + if (_records.TryGetValue(evt.RecordId, out var record)) + { + return record; + } + + record = new GearTaskExecutionRecord + { + RecordId = evt.RecordId, + TaskDefinitionName = evt.TaskDefinitionName, + TaskDefinitionFileKey = evt.TaskDefinitionFileKey, + }; + _records[evt.RecordId] = record; + return record; + } + + private async Task FlushDirtyRecordsAsync(bool force) + { + if (_dirtyRecords.Count == 0) + { + return; + } + + var now = DateTime.UtcNow; + var recordIds = _dirtyRecords.Keys.ToList(); + foreach (var recordId in recordIds) + { + if (!_records.TryGetValue(recordId, out var record)) + { + _dirtyRecords.Remove(recordId); + _priorityFlushRecords.Remove(recordId); + continue; + } + + var shouldFlush = force + || _priorityFlushRecords.Contains(recordId) + || now - _dirtyRecords[recordId] >= FlushDebounce; + + if (!shouldFlush) + { + continue; + } + + try + { + await _historyStore.SaveAsync(record); + await _historyStore.TrimAsync(record.TaskDefinitionFileKey, 30); + + _dirtyRecords.Remove(recordId); + _priorityFlushRecords.Remove(recordId); + + if (IsTerminalStatus(record.Status)) + { + // 历史记录已经落盘,终态记录不再保留在内存里,避免长期运行时内存持续增长。 + _records.Remove(recordId); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "GearTask 历史记录落盘失败,将在后续批次重试。RecordId: {RecordId}", recordId); + } + } + } + + private static bool IsTerminalEvent(GearTaskExecutionEvent evt) + { + return evt is ExecutionCompletedEvent or ExecutionFailedEvent or ExecutionCancelledEvent or ExecutionInterruptedEvent; + } + + private static bool IsTerminalStatus(GearTaskExecutionRecordStatus status) + { + return status is GearTaskExecutionRecordStatus.Succeeded + or GearTaskExecutionRecordStatus.Failed + or GearTaskExecutionRecordStatus.Cancelled + or GearTaskExecutionRecordStatus.Interrupted; + } + + private static void ApplyEvent(GearTaskExecutionRecord record, GearTaskExecutionEvent evt) + { + record.TaskDefinitionName = evt.TaskDefinitionName; + record.TaskDefinitionFileKey = evt.TaskDefinitionFileKey; + record.CurrentNodeId = evt.NodeId; + + switch (evt) + { + case ExecutionStartedEvent started: + record.SourceRecordId = started.SourceRecordId; + record.StartTime = started.Timestamp; + record.Status = GearTaskExecutionRecordStatus.Running; + record.TotalRunnableNodeCount = started.TotalRunnableNodeCount; + break; + case ExecutionCompletedEvent completed: + record.Status = GearTaskExecutionRecordStatus.Succeeded; + record.EndTime = completed.Timestamp; + break; + case ExecutionFailedEvent failed: + record.Status = GearTaskExecutionRecordStatus.Failed; + record.ErrorMessage = failed.ErrorMessage; + record.EndTime = failed.Timestamp; + break; + case ExecutionCancelledEvent cancelled: + record.Status = GearTaskExecutionRecordStatus.Cancelled; + record.InterruptReason = cancelled.Reason; + record.EndTime = cancelled.Timestamp; + break; + case ExecutionInterruptedEvent interrupted: + record.Status = GearTaskExecutionRecordStatus.Interrupted; + record.InterruptReason = interrupted.Reason; + record.EndTime = interrupted.Timestamp; + break; + case TaskNodeStartedEvent: + { + var node = GetOrAddNode(record, evt); + node.Status = GearTaskExecutionNodeStatus.Running; + node.StartTime ??= evt.Timestamp; + break; + } + case TaskNodeCompletedEvent completed: + { + var node = GetOrAddNode(record, evt); + node.Status = GearTaskExecutionNodeStatus.Succeeded; + node.EndTime = evt.Timestamp; + node.StatusMessage = completed.Message; + break; + } + case TaskNodeFailedEvent failed: + { + var node = GetOrAddNode(record, evt); + node.Status = GearTaskExecutionNodeStatus.Failed; + node.EndTime = evt.Timestamp; + node.ErrorMessage = failed.ErrorMessage; + break; + } + case TaskNodeSkippedEvent skipped: + { + var node = GetOrAddNode(record, evt); + node.Status = GearTaskExecutionNodeStatus.Skipped; + node.EndTime = evt.Timestamp; + node.StatusMessage = skipped.Reason; + break; + } + case ResumePointUpdatedEvent resume: + { + var node = GetOrAddNode(record, evt); + node.CanResumeInsideTask = resume.CanResumeInsideTask; + node.ResumeTokenJson = resume.ResumeTokenJson; + node.StatusMessage = resume.Message ?? node.StatusMessage; + record.ResumeNodeId = evt.NodeId; + record.CanResume = resume.CanResumeInsideTask || !string.IsNullOrWhiteSpace(resume.ResumeTokenJson); + break; + } + } + + record.CompletedNodeCount = record.Nodes.Count(n => n.Status == GearTaskExecutionNodeStatus.Succeeded); + record.FailedNodeCount = record.Nodes.Count(n => n.Status == GearTaskExecutionNodeStatus.Failed); + record.SkippedNodeCount = record.Nodes.Count(n => n.Status == GearTaskExecutionNodeStatus.Skipped); + + record.Timeline.Add(new GearTaskExecutionTimelineItem + { + Timestamp = evt.Timestamp, + NodeId = evt.NodeId, + EventType = evt.GetType().Name, + Message = BuildTimelineMessage(evt), + Extra = BuildTimelineExtra(evt), + }); + } + + private static string BuildTimelineMessage(GearTaskExecutionEvent evt) + { + return evt switch + { + ExecutionStartedEvent => "执行开始", + ExecutionCompletedEvent => "执行完成", + ExecutionFailedEvent failed => $"执行失败: {failed.ErrorMessage}", + ExecutionCancelledEvent cancelled => $"执行取消: {cancelled.Reason}", + ExecutionInterruptedEvent interrupted => $"执行中断: {interrupted.Reason}", + TaskNodeStartedEvent => "节点开始执行", + TaskNodeCompletedEvent completed => completed.Message ?? "节点执行完成", + TaskNodeFailedEvent failed => $"节点执行失败: {failed.ErrorMessage}", + TaskNodeSkippedEvent skipped => $"节点跳过: {skipped.Reason}", + ResumePointUpdatedEvent resume => resume.Message ?? "更新恢复点", + PathingWaypointEnteredEvent entered => $"进入路径点 {entered.WaypointGroupIndex}/{entered.WaypointIndex} ({entered.PositionX:F2}, {entered.PositionY:F2})", + PathingWaypointCompletedEvent completed => $"完成路径点 {completed.WaypointGroupIndex}/{completed.WaypointIndex} ({completed.PositionX:F2}, {completed.PositionY:F2})", + PathingTeleportCompletedEvent teleport => $"完成传送 {teleport.WaypointGroupIndex}/{teleport.WaypointIndex} ({teleport.PositionX:F2}, {teleport.PositionY:F2})", + PathingActionCompletedEvent action => $"完成动作 {action.Action} ({action.PositionX:F2}, {action.PositionY:F2})", + ScriptCheckpointReachedEvent script => $"脚本检查点 {script.CheckpointName}", + _ => evt.GetType().Name, + }; + } + + private static Dictionary BuildTimelineExtra(GearTaskExecutionEvent evt) + { + var extra = new Dictionary(evt.Extra); + + switch (evt) + { + case PathingWaypointEnteredEvent entered: + FillPathingExtra(extra, entered.WaypointGroupIndex, entered.WaypointIndex, entered.PositionX, entered.PositionY, entered.Action); + break; + case PathingWaypointCompletedEvent completed: + FillPathingExtra(extra, completed.WaypointGroupIndex, completed.WaypointIndex, completed.PositionX, completed.PositionY, completed.Action); + break; + case PathingTeleportCompletedEvent teleport: + FillPathingExtra(extra, teleport.WaypointGroupIndex, teleport.WaypointIndex, teleport.PositionX, teleport.PositionY); + break; + case PathingActionCompletedEvent action: + FillPathingExtra(extra, action.WaypointGroupIndex, action.WaypointIndex, action.PositionX, action.PositionY, action.Action); + break; + } + + return extra; + } + + private static void FillPathingExtra( + Dictionary extra, + int waypointGroupIndex, + int waypointIndex, + double positionX, + double positionY, + string? action = null) + { + extra["waypointGroupIndex"] = waypointGroupIndex; + extra["waypointIndex"] = waypointIndex; + extra["positionX"] = positionX; + extra["positionY"] = positionY; + + if (!string.IsNullOrWhiteSpace(action)) + { + extra["action"] = action; + } + } + + private static GearTaskExecutionNodeRecord GetOrAddNode(GearTaskExecutionRecord record, GearTaskExecutionEvent evt) + { + var node = record.Nodes.FirstOrDefault(n => n.NodeId == evt.NodeId); + if (node != null) + { + return node; + } + + node = new GearTaskExecutionNodeRecord + { + NodeId = evt.NodeId, + ParentNodeId = evt.ParentNodeId, + TaskName = evt.TaskName, + TaskType = evt.TaskType, + TaskPath = evt.TaskPath, + Depth = evt.NodeDepth, + Order = evt.NodeOrder, + Status = GearTaskExecutionNodeStatus.Pending, + }; + record.Nodes.Add(node); + return node; + } + + public override async Task StopAsync(CancellationToken cancellationToken) + { + if (!_disposed) + { + _disposed = true; + _subscription.Dispose(); + _eventChannel.Writer.TryComplete(); + } + + await base.StopAsync(cancellationToken); + } + + public override void Dispose() + { + if (!_disposed) + { + _disposed = true; + _subscription.Dispose(); + _eventChannel.Writer.TryComplete(); + } + + base.Dispose(); + } +} diff --git a/BetterGenshinImpact/Service/GearTask/Execution/GearTaskHistoryStore.cs b/BetterGenshinImpact/Service/GearTask/Execution/GearTaskHistoryStore.cs new file mode 100644 index 00000000..1e9d7a93 --- /dev/null +++ b/BetterGenshinImpact/Service/GearTask/Execution/GearTaskHistoryStore.cs @@ -0,0 +1,132 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Newtonsoft.Json; + +namespace BetterGenshinImpact.Service.GearTask.Execution; + +/// +/// GearTask 历史记录存储接口。 +/// +public interface IGearTaskHistoryStore +{ + Task SaveAsync(GearTaskExecutionRecord record); + + Task LoadAsync(string taskDefinitionFileKey, string recordId); + + Task> LoadLatestAsync(string taskDefinitionFileKey, int count); + + Task TrimAsync(string taskDefinitionFileKey, int keepCount); +} + +/// +/// 基于磁盘 JSON 文件的历史记录存储。 +/// 一个执行记录对应一个文件,按任务定义分目录存放。 +/// +public sealed class GearTaskHistoryStore : IGearTaskHistoryStore +{ + private readonly JsonSerializerSettings _jsonSettings = new() + { + Formatting = Formatting.Indented, + NullValueHandling = NullValueHandling.Ignore, + DateFormatString = "yyyy-MM-dd HH:mm:ss.fff", + }; + + private readonly string _historyRootPath = Path.Combine(AppContext.BaseDirectory, "User", "task_v2", "history"); + + public GearTaskHistoryStore() + { + Directory.CreateDirectory(_historyRootPath); + } + + public async Task SaveAsync(GearTaskExecutionRecord record) + { + var filePath = GetRecordFilePath(record.TaskDefinitionFileKey, record.RecordId, record.StartTime); + Directory.CreateDirectory(Path.GetDirectoryName(filePath)!); + + // 先写临时文件再覆盖正式文件,避免进程中断时把历史记录写坏。 + var json = JsonConvert.SerializeObject(record, _jsonSettings); + var tempPath = filePath + ".tmp"; + await File.WriteAllTextAsync(tempPath, json); + File.Move(tempPath, filePath, true); + } + + public async Task LoadAsync(string taskDefinitionFileKey, string recordId) + { + var directory = GetDefinitionDirectory(taskDefinitionFileKey); + if (!Directory.Exists(directory)) + { + return null; + } + + var filePath = Directory.GetFiles(directory, $"*_{recordId}.json").OrderByDescending(f => f).FirstOrDefault(); + if (filePath == null) + { + return null; + } + + var json = await File.ReadAllTextAsync(filePath); + return JsonConvert.DeserializeObject(json, _jsonSettings); + } + + public async Task> LoadLatestAsync(string taskDefinitionFileKey, int count) + { + var directory = GetDefinitionDirectory(taskDefinitionFileKey); + if (!Directory.Exists(directory)) + { + return []; + } + + var files = Directory.GetFiles(directory, "*.json") + .OrderByDescending(Path.GetFileNameWithoutExtension) + .Take(count) + .ToList(); + + var list = new List(files.Count); + foreach (var file in files) + { + var json = await File.ReadAllTextAsync(file); + var record = JsonConvert.DeserializeObject(json, _jsonSettings); + if (record != null) + { + list.Add(record); + } + } + + return list; + } + + public Task TrimAsync(string taskDefinitionFileKey, int keepCount) + { + var directory = GetDefinitionDirectory(taskDefinitionFileKey); + if (!Directory.Exists(directory)) + { + return Task.CompletedTask; + } + + // 文件名以开始时间开头,按文件名倒序即可近似得到最近执行记录。 + var files = Directory.GetFiles(directory, "*.json") + .OrderByDescending(Path.GetFileNameWithoutExtension) + .Skip(keepCount) + .ToList(); + + foreach (var file in files) + { + File.Delete(file); + } + + return Task.CompletedTask; + } + + public string GetDefinitionDirectory(string taskDefinitionFileKey) + { + return Path.Combine(_historyRootPath, taskDefinitionFileKey); + } + + private string GetRecordFilePath(string taskDefinitionFileKey, string recordId, DateTime startTime) + { + return Path.Combine(GetDefinitionDirectory(taskDefinitionFileKey), $"{startTime:yyyyMMddHHmmss}_{recordId}.json"); + } +} diff --git a/BetterGenshinImpact/Service/GearTask/Execution/GearTaskNodeExecutionContext.cs b/BetterGenshinImpact/Service/GearTask/Execution/GearTaskNodeExecutionContext.cs new file mode 100644 index 00000000..ec082e3e --- /dev/null +++ b/BetterGenshinImpact/Service/GearTask/Execution/GearTaskNodeExecutionContext.cs @@ -0,0 +1,124 @@ +using System.Threading; +using System.Threading.Tasks; +using BetterGenshinImpact.Model.Gear.Tasks; + +namespace BetterGenshinImpact.Service.GearTask.Execution; + +/// +/// 单个任务节点在一次执行中的上下文。 +/// 它负责把节点元数据与事件发布能力打包传给具体任务实例。 +/// +public sealed class GearTaskNodeExecutionContext +{ + private readonly IGearTaskEventBus _eventBus; + + public GearTaskNodeExecutionContext( + IGearTaskEventBus eventBus, + string recordId, + string taskDefinitionName, + string taskDefinitionFileKey, + string nodeId, + string? parentNodeId, + string taskName, + string taskType, + string taskPath, + int nodeDepth, + int nodeOrder, + string? resumeTokenJson) + { + _eventBus = eventBus; + RecordId = recordId; + TaskDefinitionName = taskDefinitionName; + TaskDefinitionFileKey = taskDefinitionFileKey; + NodeId = nodeId; + ParentNodeId = parentNodeId; + TaskName = taskName; + TaskType = taskType; + TaskPath = taskPath; + NodeDepth = nodeDepth; + NodeOrder = nodeOrder; + ResumeTokenJson = resumeTokenJson; + } + + /// + /// 使用任务节点和运行上下文创建节点执行上下文。 + /// + public static GearTaskNodeExecutionContext Create( + IGearTaskEventBus eventBus, + BaseGearTask task, + GearTaskExecutionRunContext runContext, + string nodeId, + string? parentNodeId, + int nodeDepth, + int nodeOrder, + string? resumeTokenJson) + { + return new GearTaskNodeExecutionContext( + eventBus, + runContext.RecordId, + runContext.TaskDefinitionName, + runContext.TaskDefinitionFileKey, + nodeId, + parentNodeId, + task.Name, + task.Type, + GearTaskExecutionPathFormatter.FormatTaskPath(task.FilePath), + nodeDepth, + nodeOrder, + resumeTokenJson); + } + + public string RecordId { get; } + + public string TaskDefinitionName { get; } + + public string TaskDefinitionFileKey { get; } + + public string NodeId { get; } + + public string? ParentNodeId { get; } + + public string TaskName { get; } + + public string TaskType { get; } + + public string TaskPath { get; } + + public int NodeDepth { get; } + + public int NodeOrder { get; } + + /// + /// 从历史记录恢复时传入的节点级恢复令牌。 + /// 由实现了 IGearTaskResumable 的任务自行解释。 + /// + public string? ResumeTokenJson { get; } + + /// + /// 基于当前节点信息构造一条事件,避免各任务重复填公共字段。 + /// + public T CreateEvent() where T : GearTaskExecutionEvent, new() + { + return new T + { + RecordId = RecordId, + TaskDefinitionName = TaskDefinitionName, + TaskDefinitionFileKey = TaskDefinitionFileKey, + NodeId = NodeId, + ParentNodeId = ParentNodeId, + TaskName = TaskName, + TaskType = TaskType, + TaskPath = TaskPath, + NodeDepth = NodeDepth, + NodeOrder = NodeOrder, + }; + } + + /// + /// 发布当前节点相关事件。 + /// + public ValueTask PublishAsync(GearTaskExecutionEvent evt, CancellationToken ct = default) + { + return _eventBus.PublishAsync(evt, ct); + } +} diff --git a/BetterGenshinImpact/Service/GearTask/Execution/IGearTaskEventBus.cs b/BetterGenshinImpact/Service/GearTask/Execution/IGearTaskEventBus.cs new file mode 100644 index 00000000..0e46ca9e --- /dev/null +++ b/BetterGenshinImpact/Service/GearTask/Execution/IGearTaskEventBus.cs @@ -0,0 +1,30 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace BetterGenshinImpact.Service.GearTask.Execution; + +/// +/// GearTask 执行事件总线。 +/// 负责在执行器与记录器、UI 投影等消费者之间解耦。 +/// +public interface IGearTaskEventBus +{ + /// + /// 发布一条执行事件。 + /// + ValueTask PublishAsync(GearTaskExecutionEvent evt, CancellationToken ct = default); + + /// + /// 注册一个事件消费者。 + /// + IDisposable Subscribe(IGearTaskEventConsumer consumer); +} + +/// +/// GearTask 执行事件消费者。 +/// +public interface IGearTaskEventConsumer +{ + ValueTask ConsumeAsync(GearTaskExecutionEvent evt, CancellationToken ct = default); +} diff --git a/BetterGenshinImpact/Service/GearTask/Execution/IGearTaskResumable.cs b/BetterGenshinImpact/Service/GearTask/Execution/IGearTaskResumable.cs new file mode 100644 index 00000000..77146768 --- /dev/null +++ b/BetterGenshinImpact/Service/GearTask/Execution/IGearTaskResumable.cs @@ -0,0 +1,13 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace BetterGenshinImpact.Service.GearTask.Execution; + +/// +/// 支持节点内部恢复的 GearTask 需要实现的接口。 +/// 执行器会在节点执行前注入上次记录下来的恢复令牌。 +/// +public interface IGearTaskResumable +{ + Task ApplyResumeTokenAsync(string? resumeTokenJson, CancellationToken ct); +} diff --git a/BetterGenshinImpact/Service/GearTask/Execution/PathingRuntimeNotifier.cs b/BetterGenshinImpact/Service/GearTask/Execution/PathingRuntimeNotifier.cs new file mode 100644 index 00000000..ff187694 --- /dev/null +++ b/BetterGenshinImpact/Service/GearTask/Execution/PathingRuntimeNotifier.cs @@ -0,0 +1,118 @@ +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; + +namespace BetterGenshinImpact.Service.GearTask.Execution; + +/// +/// Pathing 运行期通知接口。 +/// PathExecutor 从业务步骤出发发出通知,外部可以选择记录、展示或转发。 +/// +public interface IPathingRuntimeNotifier +{ + ValueTask NotifyWaypointEnteredAsync(int waypointGroupIndex, int waypointIndex, string? action, double positionX, double positionY, CancellationToken ct = default); + + ValueTask NotifyWaypointCompletedAsync(int waypointGroupIndex, int waypointIndex, string? action, double positionX, double positionY, CancellationToken ct = default); + + ValueTask NotifyTeleportCompletedAsync(int waypointGroupIndex, int waypointIndex, double positionX, double positionY, CancellationToken ct = default); + + ValueTask NotifyActionCompletedAsync(int waypointGroupIndex, int waypointIndex, string action, double positionX, double positionY, CancellationToken ct = default); + + ValueTask NotifyResumePointUpdatedAsync(PathingGearTaskResumeState state, string? message = null, CancellationToken ct = default); +} + +/// +/// 空通知器,供不关心 Pathing 运行态的场景使用。 +/// +public sealed class NullPathingRuntimeNotifier : IPathingRuntimeNotifier +{ + public static readonly NullPathingRuntimeNotifier Instance = new(); + + public ValueTask NotifyActionCompletedAsync(int waypointGroupIndex, int waypointIndex, string action, double positionX, double positionY, CancellationToken ct = default) => ValueTask.CompletedTask; + + public ValueTask NotifyResumePointUpdatedAsync(PathingGearTaskResumeState state, string? message = null, CancellationToken ct = default) => ValueTask.CompletedTask; + + public ValueTask NotifyTeleportCompletedAsync(int waypointGroupIndex, int waypointIndex, double positionX, double positionY, CancellationToken ct = default) => ValueTask.CompletedTask; + + public ValueTask NotifyWaypointCompletedAsync(int waypointGroupIndex, int waypointIndex, string? action, double positionX, double positionY, CancellationToken ct = default) => ValueTask.CompletedTask; + + public ValueTask NotifyWaypointEnteredAsync(int waypointGroupIndex, int waypointIndex, string? action, double positionX, double positionY, CancellationToken ct = default) => ValueTask.CompletedTask; +} + +/// +/// 把 Pathing 运行步骤转换成 GearTask 执行事件。 +/// +public sealed class GearTaskPathingRuntimeNotifier : IPathingRuntimeNotifier +{ + private readonly GearTaskNodeExecutionContext _context; + + public GearTaskPathingRuntimeNotifier(GearTaskNodeExecutionContext context) + { + _context = context; + } + + public ValueTask NotifyWaypointEnteredAsync(int waypointGroupIndex, int waypointIndex, string? action, double positionX, double positionY, CancellationToken ct = default) + { + var evt = _context.CreateEvent(); + evt.WaypointGroupIndex = waypointGroupIndex; + evt.WaypointIndex = waypointIndex; + evt.Action = action; + evt.PositionX = positionX; + evt.PositionY = positionY; + evt.Extra["positionX"] = positionX; + evt.Extra["positionY"] = positionY; + return _context.PublishAsync(evt, ct); + } + + public ValueTask NotifyWaypointCompletedAsync(int waypointGroupIndex, int waypointIndex, string? action, double positionX, double positionY, CancellationToken ct = default) + { + var evt = _context.CreateEvent(); + evt.WaypointGroupIndex = waypointGroupIndex; + evt.WaypointIndex = waypointIndex; + evt.Action = action; + evt.PositionX = positionX; + evt.PositionY = positionY; + evt.Extra["positionX"] = positionX; + evt.Extra["positionY"] = positionY; + return _context.PublishAsync(evt, ct); + } + + public ValueTask NotifyTeleportCompletedAsync(int waypointGroupIndex, int waypointIndex, double positionX, double positionY, CancellationToken ct = default) + { + var evt = _context.CreateEvent(); + evt.WaypointGroupIndex = waypointGroupIndex; + evt.WaypointIndex = waypointIndex; + evt.PositionX = positionX; + evt.PositionY = positionY; + evt.Extra["positionX"] = positionX; + evt.Extra["positionY"] = positionY; + return _context.PublishAsync(evt, ct); + } + + public ValueTask NotifyActionCompletedAsync(int waypointGroupIndex, int waypointIndex, string action, double positionX, double positionY, CancellationToken ct = default) + { + var evt = _context.CreateEvent(); + evt.WaypointGroupIndex = waypointGroupIndex; + evt.WaypointIndex = waypointIndex; + evt.Action = action; + evt.PositionX = positionX; + evt.PositionY = positionY; + evt.Extra["positionX"] = positionX; + evt.Extra["positionY"] = positionY; + return _context.PublishAsync(evt, ct); + } + + public ValueTask NotifyResumePointUpdatedAsync(PathingGearTaskResumeState state, string? message = null, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(state.PathFile)) + { + state.PathFile = _context.TaskPath; + } + + var evt = _context.CreateEvent(); + evt.CanResumeInsideTask = true; + evt.ResumeTokenJson = JsonConvert.SerializeObject(state); + evt.Message = message; + return _context.PublishAsync(evt, ct); + } +} diff --git a/BetterGenshinImpact/Service/GearTask/GearTaskExecutionManager.cs b/BetterGenshinImpact/Service/GearTask/GearTaskExecutionManager.cs deleted file mode 100644 index 5825a153..00000000 --- a/BetterGenshinImpact/Service/GearTask/GearTaskExecutionManager.cs +++ /dev/null @@ -1,489 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using BetterGenshinImpact.Model.Gear.Tasks; -using CommunityToolkit.Mvvm.ComponentModel; -using Microsoft.Extensions.Logging; - -namespace BetterGenshinImpact.Service; - -/// -/// 任务执行状态 -/// -public enum TaskExecutionStatus -{ - /// - /// 等待执行 - /// - Pending, - - /// - /// 正在执行 - /// - Running, - - /// - /// 执行完成 - /// - Completed, - - /// - /// 执行失败 - /// - Failed, - - /// - /// 已取消 - /// - Cancelled, - - /// - /// 已跳过 - /// - Skipped -} - -/// -/// 任务执行信息 -/// -public partial class TaskExecutionInfo : ObservableObject -{ - [ObservableProperty] - private string _taskName = string.Empty; - - [ObservableProperty] - private string _taskType = string.Empty; - - [ObservableProperty] - private TaskExecutionStatus _status = TaskExecutionStatus.Pending; - - [ObservableProperty] - private DateTime _startTime; - - [ObservableProperty] - private DateTime _endTime; - - [ObservableProperty] - private TimeSpan _duration; - - [ObservableProperty] - private string _errorMessage = string.Empty; - - [ObservableProperty] - private double _progress; - - [ObservableProperty] - private string _statusMessage = string.Empty; - - public BaseGearTask? Task { get; set; } - public List Children { get; set; } = new(); - public TaskExecutionInfo? Parent { get; set; } -} - -/// -/// 齿轮任务执行管理器,负责管理任务执行状态和进度跟踪 -/// -public partial class GearTaskExecutionManager : ObservableObject -{ - private readonly ILogger _logger; - private readonly Dictionary _taskInfoMap = new(); - private CancellationTokenSource? _cancellationTokenSource; - - [ObservableProperty] - private TaskExecutionInfo? _rootTaskInfo; - - [ObservableProperty] - private TaskExecutionInfo? _currentTaskInfo; - - [ObservableProperty] - private bool _isExecuting; - - [ObservableProperty] - private double _overallProgress; - - [ObservableProperty] - private string _overallStatusMessage = string.Empty; - - [ObservableProperty] - private int _totalTasks; - - [ObservableProperty] - private int _completedTasks; - - [ObservableProperty] - private int _failedTasks; - - [ObservableProperty] - private int _skippedTasks; - - public event EventHandler? TaskStarted; - public event EventHandler? TaskCompleted; - public event EventHandler? TaskFailed; - public event EventHandler? TaskSkipped; - public event EventHandler? ProgressChanged; - - public GearTaskExecutionManager(ILogger logger) - { - _logger = logger; - } - - /// - /// 开始执行任务并跟踪状态 - /// - /// 根任务 - /// 取消令牌 - /// - public async Task ExecuteWithTrackingAsync(BaseGearTask rootTask, CancellationToken ct = default) - { - if (IsExecuting) - { - throw new InvalidOperationException("任务执行管理器正在运行中"); - } - - try - { - IsExecuting = true; - _cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(ct); - - // 初始化执行信息 - InitializeExecutionInfo(rootTask); - - await ScriptService.StartGameTask(); - - // 开始执行 - await ExecuteTaskWithTrackingAsync(rootTask, _cancellationTokenSource.Token); - - // 更新最终状态 - UpdateOverallStatus(); - } - catch (OperationCanceledException) - { - _logger.LogInformation("任务执行已取消"); - OverallStatusMessage = "任务执行已取消"; - throw; - } - catch (Exception ex) - { - _logger.LogError(ex, "任务执行过程中发生错误"); - OverallStatusMessage = $"任务执行失败: {ex.Message}"; - throw; - } - finally - { - IsExecuting = false; - _cancellationTokenSource?.Dispose(); - _cancellationTokenSource = null; - } - } - - /// - /// 取消任务执行 - /// - public void CancelExecution() - { - if (IsExecuting && _cancellationTokenSource != null) - { - _logger.LogInformation("用户请求取消任务执行"); - _cancellationTokenSource.Cancel(); - OverallStatusMessage = "正在取消任务执行..."; - } - } - - /// - /// 初始化执行信息 - /// - /// 根任务 - private void InitializeExecutionInfo(BaseGearTask rootTask) - { - _taskInfoMap.Clear(); - CompletedTasks = 0; - FailedTasks = 0; - SkippedTasks = 0; - OverallProgress = 0; - - RootTaskInfo = CreateTaskExecutionInfo(rootTask); - TotalTasks = CountTotalTasks(rootTask); - - OverallStatusMessage = "准备执行任务..."; - - _logger.LogInformation("初始化任务执行跟踪,总任务数: {TotalTasks}", TotalTasks); - } - - /// - /// 创建任务执行信息 - /// - /// 任务 - /// 父任务信息 - /// - private TaskExecutionInfo CreateTaskExecutionInfo(BaseGearTask task, TaskExecutionInfo? parent = null) - { - var info = new TaskExecutionInfo - { - TaskName = task.Name, - TaskType = task.Type, - Status = TaskExecutionStatus.Pending, - Task = task, - Parent = parent - }; - - _taskInfoMap[task] = info; - - // 递归创建子任务信息 - if (task.Children?.Count > 0) - { - foreach (var child in task.Children) - { - var childInfo = CreateTaskExecutionInfo(child, info); - info.Children.Add(childInfo); - } - } - - return info; - } - - /// - /// 执行任务并跟踪状态 - /// - /// 要执行的任务 - /// 取消令牌 - /// - private async Task ExecuteTaskWithTrackingAsync(BaseGearTask task, CancellationToken ct) - { - if (!_taskInfoMap.TryGetValue(task, out var taskInfo)) - { - _logger.LogWarning("未找到任务执行信息: {TaskName}", task.Name); - return; - } - - CurrentTaskInfo = taskInfo; - - // 检查任务是否启用 - if (!task.Enabled) - { - await MarkTaskSkipped(taskInfo, "任务已禁用"); - return; - } - - try - { - // 开始执行任务 - await StartTask(taskInfo); - - // 执行当前任务 - await task.Execute(ct); - - // 执行子任务 - if (task.Children?.Count > 0) - { - for (int i = 0; i < task.Children.Count; i++) - { - var child = task.Children[i]; - await ExecuteTaskWithTrackingAsync(child, ct); - - // 更新进度 - var childProgress = (double)(i + 1) / task.Children.Count * 100; - taskInfo.Progress = childProgress; - UpdateOverallProgress(); - } - } - - // 完成任务 - await CompleteTask(taskInfo); - } - catch (OperationCanceledException) - { - await MarkTaskCancelled(taskInfo); - throw; - } - catch (Exception ex) - { - await FailTask(taskInfo, ex); - throw; - } - } - - /// - /// 开始执行任务 - /// - /// 任务信息 - private async Task StartTask(TaskExecutionInfo taskInfo) - { - taskInfo.Status = TaskExecutionStatus.Running; - taskInfo.StartTime = DateTime.Now; - taskInfo.StatusMessage = "正在执行..."; - - _logger.LogInformation("开始执行任务: {TaskName}", taskInfo.TaskName); - - TaskStarted?.Invoke(this, taskInfo); - await Task.CompletedTask; - } - - /// - /// 完成任务 - /// - /// 任务信息 - private async Task CompleteTask(TaskExecutionInfo taskInfo) - { - taskInfo.Status = TaskExecutionStatus.Completed; - taskInfo.EndTime = DateTime.Now; - taskInfo.Duration = taskInfo.EndTime - taskInfo.StartTime; - taskInfo.Progress = 100; - taskInfo.StatusMessage = "执行完成"; - - CompletedTasks++; - - _logger.LogInformation("任务执行完成: {TaskName}, 耗时: {Duration}ms", - taskInfo.TaskName, taskInfo.Duration.TotalMilliseconds); - - TaskCompleted?.Invoke(this, taskInfo); - UpdateOverallProgress(); - - await Task.CompletedTask; - } - - /// - /// 任务执行失败 - /// - /// 任务信息 - /// 异常 - private async Task FailTask(TaskExecutionInfo taskInfo, Exception exception) - { - taskInfo.Status = TaskExecutionStatus.Failed; - taskInfo.EndTime = DateTime.Now; - taskInfo.Duration = taskInfo.EndTime - taskInfo.StartTime; - taskInfo.ErrorMessage = exception.Message; - taskInfo.StatusMessage = $"执行失败: {exception.Message}"; - - FailedTasks++; - - _logger.LogError(exception, "任务执行失败: {TaskName}", taskInfo.TaskName); - - TaskFailed?.Invoke(this, taskInfo); - UpdateOverallProgress(); - - await Task.CompletedTask; - } - - /// - /// 标记任务为已跳过 - /// - /// 任务信息 - /// 跳过原因 - private async Task MarkTaskSkipped(TaskExecutionInfo taskInfo, string reason) - { - taskInfo.Status = TaskExecutionStatus.Skipped; - taskInfo.StatusMessage = $"已跳过: {reason}"; - - SkippedTasks++; - - _logger.LogInformation("任务已跳过: {TaskName}, 原因: {Reason}", taskInfo.TaskName, reason); - - TaskSkipped?.Invoke(this, taskInfo); - UpdateOverallProgress(); - - await Task.CompletedTask; - } - - /// - /// 标记任务为已取消 - /// - /// 任务信息 - private async Task MarkTaskCancelled(TaskExecutionInfo taskInfo) - { - taskInfo.Status = TaskExecutionStatus.Cancelled; - taskInfo.EndTime = DateTime.Now; - taskInfo.Duration = taskInfo.EndTime - taskInfo.StartTime; - taskInfo.StatusMessage = "已取消"; - - _logger.LogInformation("任务已取消: {TaskName}", taskInfo.TaskName); - - await Task.CompletedTask; - } - - /// - /// 更新整体进度 - /// - private void UpdateOverallProgress() - { - if (TotalTasks > 0) - { - var processedTasks = CompletedTasks + FailedTasks + SkippedTasks; - OverallProgress = (double)processedTasks / TotalTasks * 100; - - OverallStatusMessage = $"进度: {processedTasks}/{TotalTasks} (完成: {CompletedTasks}, 失败: {FailedTasks}, 跳过: {SkippedTasks})"; - - ProgressChanged?.Invoke(this, OverallProgress); - } - } - - /// - /// 更新最终状态 - /// - private void UpdateOverallStatus() - { - if (FailedTasks > 0) - { - OverallStatusMessage = $"执行完成,但有 {FailedTasks} 个任务失败"; - } - else if (SkippedTasks > 0) - { - OverallStatusMessage = $"执行完成,跳过了 {SkippedTasks} 个任务"; - } - else - { - OverallStatusMessage = "所有任务执行完成"; - } - } - - /// - /// 统计任务总数 - /// - /// 根任务 - /// 任务总数 - private int CountTotalTasks(BaseGearTask task) - { - int count = 1; - if (task.Children?.Count > 0) - { - foreach (var child in task.Children) - { - count += CountTotalTasks(child); - } - } - return count; - } - - /// - /// 获取任务执行统计信息 - /// - /// 统计信息 - public TaskExecutionStatistics GetStatistics() - { - return new TaskExecutionStatistics - { - TotalTasks = TotalTasks, - CompletedTasks = CompletedTasks, - FailedTasks = FailedTasks, - SkippedTasks = SkippedTasks, - OverallProgress = OverallProgress, - IsExecuting = IsExecuting - }; - } -} - -/// -/// 任务执行统计信息 -/// -public class TaskExecutionStatistics -{ - public int TotalTasks { get; set; } - public int CompletedTasks { get; set; } - public int FailedTasks { get; set; } - public int SkippedTasks { get; set; } - public double OverallProgress { get; set; } - public bool IsExecuting { get; set; } - - public int ProcessedTasks => CompletedTasks + FailedTasks + SkippedTasks; - public double SuccessRate => TotalTasks > 0 ? (double)CompletedTasks / TotalTasks * 100 : 0; -} \ No newline at end of file diff --git a/BetterGenshinImpact/Service/GearTask/GearTaskExecutor.cs b/BetterGenshinImpact/Service/GearTask/GearTaskExecutor.cs index 9fb84fbe..81a86290 100644 --- a/BetterGenshinImpact/Service/GearTask/GearTaskExecutor.cs +++ b/BetterGenshinImpact/Service/GearTask/GearTaskExecutor.cs @@ -1,59 +1,61 @@ using System; using System.Collections.Generic; -using System.ComponentModel; +using System.Linq; using System.Threading; using System.Threading.Tasks; using BetterGenshinImpact.Core.Script; using BetterGenshinImpact.GameTask; using BetterGenshinImpact.Model.Gear; using BetterGenshinImpact.Model.Gear.Tasks; -using BetterGenshinImpact.Service.GearTask; +using BetterGenshinImpact.Service.GearTask.Execution; using BetterGenshinImpact.ViewModel.Pages.Component; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; using CommunityToolkit.Mvvm.ComponentModel; +using Microsoft.Extensions.Logging; namespace BetterGenshinImpact.Service; /// -/// 齿轮任务执行器,负责从 JSON 数据解析任务并执行 +/// 齿轮任务执行入口,负责加载任务定义、构造执行上下文并启动执行。 /// -public partial class GearTaskExecutor : ObservableObject +public partial class GearTaskExecutor : ObservableObject, IGearTaskEventConsumer, IDisposable { private readonly ILogger _logger; - private readonly GearTaskStorageService _storageService; + private readonly BetterGenshinImpact.Service.GearTask.GearTaskStorageService _storageService; private readonly GearTaskConverter _taskConverter; - private readonly GearTaskExecutionManager _executionManager; - private readonly IServiceProvider _serviceProvider; - + private readonly IGearTaskExecutionRunner _executionRunner; + private readonly IGearTaskHistoryStore _historyStore; + private readonly IDisposable _subscription; + private CancellationTokenSource? _runningCancellationTokenSource; + private string? _activeRecordId; + private int _totalNodeCount; + private int _processedNodeCount; + [ObservableProperty] private bool _isExecuting; - + [ObservableProperty] private string _currentTaskName = string.Empty; - + [ObservableProperty] private double _progress; - + [ObservableProperty] private string _statusMessage = string.Empty; public GearTaskExecutor( ILogger logger, - GearTaskStorageService storageService, + BetterGenshinImpact.Service.GearTask.GearTaskStorageService storageService, GearTaskConverter taskConverter, - GearTaskExecutionManager executionManager, - IServiceProvider serviceProvider) + IGearTaskExecutionRunner executionRunner, + IGearTaskHistoryStore historyStore, + IGearTaskEventBus eventBus) { _logger = logger; _storageService = storageService; _taskConverter = taskConverter; - _executionManager = executionManager; - _serviceProvider = serviceProvider; - - // 订阅执行管理器事件 - _executionManager.ProgressChanged += OnProgressChanged; - _executionManager.PropertyChanged += OnExecutionManagerPropertyChanged; + _executionRunner = executionRunner; + _historyStore = historyStore; + _subscription = eventBus.Subscribe(this); } /// @@ -74,12 +76,12 @@ public partial class GearTaskExecutor : ObservableObject IsExecuting = true; StatusMessage = "正在加载任务定义..."; Progress = 0; + CurrentTaskName = string.Empty; - // 从存储服务加载任务定义 var taskDefinitionViewModel = await _storageService.LoadTaskDefinitionAsync(taskDefinitionName); if (taskDefinitionViewModel == null) { - throw new ArgumentException($"未找到任务定义: {taskDefinitionName}"); + throw new ArgumentException($"未找到任务定义 {taskDefinitionName}"); } if (taskDefinitionViewModel.RootTask == null) @@ -87,120 +89,169 @@ public partial class GearTaskExecutor : ObservableObject throw new InvalidOperationException($"任务定义 '{taskDefinitionName}' 没有根任务"); } - _logger.LogInformation("开始执行任务定义: {TaskDefinitionName}", taskDefinitionName); - - // 转换为可执行的任务 - var rootTaskData = ConvertViewModelToData(taskDefinitionViewModel.RootTask); - var rootTask = await _taskConverter.ConvertTaskDataAsync(rootTaskData); - - // 使用执行管理器执行任务 - CancellationContext.Instance.Set(); - using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, CancellationContext.Instance.GetActiveToken()); - await _executionManager.ExecuteWithTrackingAsync(rootTask, linkedCts.Token); - - StatusMessage = "任务执行完成"; - Progress = 100; - _logger.LogInformation("任务定义执行完成: {TaskDefinitionName}", taskDefinitionName); - } - catch (OperationCanceledException) - { - StatusMessage = "任务执行已取消"; - _logger.LogInformation("任务定义执行已取消: {TaskDefinitionName}", taskDefinitionName); - throw; - } - catch (Exception ex) - { - StatusMessage = $"任务执行失败: {ex.Message}"; - _logger.LogError(ex, "执行任务定义时发生错误: {TaskDefinitionName}", taskDefinitionName); - throw; + await ExecuteInternalAsync(taskDefinitionViewModel, resumePlan: null, ct); } finally { - CancellationContext.Instance.Clear(); - RunnerContext.Instance.Clear(); - IsExecuting = false; + CleanupExecutionState(); } } - /// - /// 停止当前执行的任务 - /// - public void StopExecution() + public async Task ResumeTaskDefinitionAsync(string taskDefinitionName, string sourceRecordId, CancellationToken ct = default) { if (IsExecuting) { - StatusMessage = "正在停止任务执行..."; - _logger.LogInformation("用户请求停止任务执行"); - _executionManager.CancelExecution(); + throw new InvalidOperationException("任务执行器正在运行中,请等待当前任务完成"); } - } - /// - /// 处理进度变化事件 - /// - /// 事件发送者 - /// 进度值 - private void OnProgressChanged(object? sender, double progress) - { - Progress = progress; - } - - /// - /// 处理执行管理器属性变化事件 - /// - /// 事件发送者 - /// 属性变化事件参数 - private void OnExecutionManagerPropertyChanged(object? sender, PropertyChangedEventArgs e) - { - switch (e.PropertyName) + try { - case nameof(GearTaskExecutionManager.CurrentTaskInfo): - if (_executionManager.CurrentTaskInfo != null) - { - CurrentTaskName = _executionManager.CurrentTaskInfo.TaskName; - } + IsExecuting = true; + StatusMessage = "正在加载恢复记录..."; + Progress = 0; + CurrentTaskName = string.Empty; + + var taskDefinitionViewModel = await _storageService.LoadTaskDefinitionAsync(taskDefinitionName); + if (taskDefinitionViewModel == null) + { + throw new ArgumentException($"未找到任务定义 {taskDefinitionName}"); + } + + if (taskDefinitionViewModel.RootTask == null) + { + throw new InvalidOperationException($"任务定义 '{taskDefinitionName}' 没有根任务"); + } + + var taskDefinitionFileKey = GetTaskDefinitionFileKey(taskDefinitionViewModel.Name); + var sourceRecord = await _historyStore.LoadAsync(taskDefinitionFileKey, sourceRecordId); + if (sourceRecord == null) + { + throw new InvalidOperationException($"未找到恢复记录 {sourceRecordId}"); + } + + var resumePlan = BuildResumePlan(sourceRecord); + await ExecuteInternalAsync(taskDefinitionViewModel, resumePlan, ct); + } + finally + { + CleanupExecutionState(); + } + } + + public void StopExecution() + { + if (!IsExecuting || _runningCancellationTokenSource == null) + { + return; + } + + StatusMessage = "正在停止任务执行..."; + _logger.LogInformation("用户请求停止 GearTask 执行"); + _runningCancellationTokenSource.Cancel(); + } + + public async ValueTask ConsumeAsync(GearTaskExecutionEvent evt, CancellationToken ct = default) + { + if (_activeRecordId == null || evt.RecordId != _activeRecordId) + { + return; + } + + switch (evt) + { + case ExecutionStartedEvent started: + _totalNodeCount = Math.Max(started.TotalRunnableNodeCount, 1); + _processedNodeCount = 0; + StatusMessage = "任务执行中..."; + Progress = 0; break; - case nameof(GearTaskExecutionManager.OverallStatusMessage): - StatusMessage = _executionManager.OverallStatusMessage; + case TaskNodeStartedEvent: + CurrentTaskName = evt.TaskName; + StatusMessage = $"正在执行: {evt.TaskName}"; break; - case nameof(GearTaskExecutionManager.IsExecuting): - IsExecuting = _executionManager.IsExecuting; + case TaskNodeCompletedEvent: + case TaskNodeFailedEvent: + case TaskNodeSkippedEvent: + _processedNodeCount++; + Progress = Math.Min(100, (double)_processedNodeCount / _totalNodeCount * 100); + break; + case ExecutionCompletedEvent: + Progress = 100; + StatusMessage = "任务执行完成"; + break; + case ExecutionCancelledEvent: + StatusMessage = "任务执行已取消"; + break; + case ExecutionFailedEvent failed: + StatusMessage = $"任务执行失败: {failed.ErrorMessage}"; + break; + case ExecutionInterruptedEvent interrupted: + StatusMessage = $"任务执行中断: {interrupted.Reason}"; break; } } - /// - /// 获取执行统计信息 - /// - /// 统计信息 - public TaskExecutionStatistics GetExecutionStatistics() + public void Dispose() { - return _executionManager.GetStatistics(); + _subscription.Dispose(); + _runningCancellationTokenSource?.Dispose(); } - /// - /// 获取当前任务执行信息 - /// - /// 当前任务信息 - public TaskExecutionInfo? GetCurrentTaskInfo() + private async Task ExecuteInternalAsync(GearTaskDefinitionViewModel taskDefinitionViewModel, GearTaskResumePlan? resumePlan, CancellationToken ct) { - return _executionManager.CurrentTaskInfo; + _logger.LogInformation("开始执行任务定义 {TaskDefinitionName}", taskDefinitionViewModel.Name); + + var rootTaskData = ConvertViewModelToData(taskDefinitionViewModel.RootTask!); + var rootTask = await _taskConverter.ConvertTaskDataAsync(rootTaskData); + var recordId = Guid.NewGuid().ToString("N"); + var taskDefinitionFileKey = GetTaskDefinitionFileKey(taskDefinitionViewModel.Name); + var runContext = new GearTaskExecutionRunContext + { + RecordId = recordId, + TaskDefinitionName = taskDefinitionViewModel.Name, + TaskDefinitionFileKey = taskDefinitionFileKey, + ResumePlan = resumePlan, + }; + + _activeRecordId = recordId; + CancellationContext.Instance.Set(); + _runningCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(ct, CancellationContext.Instance.GetActiveToken()); + + await ScriptService.StartGameTask(); + await _executionRunner.RunAsync(rootTask, runContext, _runningCancellationTokenSource.Token); } - /// - /// 获取根任务执行信息 - /// - /// - public TaskExecutionInfo? GetRootTaskInfo() + private void CleanupExecutionState() { - return _executionManager.RootTaskInfo; + CancellationContext.Instance.Clear(); + RunnerContext.Instance.Clear(); + _runningCancellationTokenSource?.Dispose(); + _runningCancellationTokenSource = null; + _activeRecordId = null; + _totalNodeCount = 0; + _processedNodeCount = 0; + IsExecuting = false; + } + + private static GearTaskResumePlan BuildResumePlan(GearTaskExecutionRecord sourceRecord) + { + return new GearTaskResumePlan + { + SourceRecordId = sourceRecord.RecordId, + ResumeNodeId = sourceRecord.ResumeNodeId ?? "0", + NodeResumeTokens = sourceRecord.Nodes + .Where(n => !string.IsNullOrWhiteSpace(n.ResumeTokenJson)) + .ToDictionary(n => n.NodeId, n => n.ResumeTokenJson!), + }; + } + + private static string GetTaskDefinitionFileKey(string name) + { + var invalidChars = System.IO.Path.GetInvalidFileNameChars(); + var safeName = string.Join("_", name.Split(invalidChars, StringSplitOptions.RemoveEmptyEntries)); + return string.IsNullOrWhiteSpace(safeName) ? "unnamed_task" : safeName; } - /// - /// 将 GearTaskViewModel 转换为 GearTaskData - /// - /// 视图模型 - /// 任务数据 private GearTaskData ConvertViewModelToData(GearTaskViewModel viewModel) { var taskData = new GearTaskData @@ -216,7 +267,6 @@ public partial class GearTaskExecutor : ObservableObject Priority = viewModel.Priority, }; - // 递归转换子任务 if (viewModel.Children?.Count > 0) { taskData.Children = new List(); @@ -229,14 +279,3 @@ public partial class GearTaskExecutor : ObservableObject return taskData; } } - -/// -/// 空任务实现,用于已禁用的任务 -/// -internal class EmptyGearTask : BaseGearTask -{ - public override Task Run(CancellationToken ct) - { - return Task.CompletedTask; - } -} diff --git a/BetterGenshinImpact/Service/GearTask/GearTaskServiceExtensions.cs b/BetterGenshinImpact/Service/GearTask/GearTaskServiceExtensions.cs index c9340377..cfe7db1b 100644 --- a/BetterGenshinImpact/Service/GearTask/GearTaskServiceExtensions.cs +++ b/BetterGenshinImpact/Service/GearTask/GearTaskServiceExtensions.cs @@ -1,3 +1,4 @@ +using BetterGenshinImpact.Service.GearTask.Execution; using Microsoft.Extensions.DependencyInjection; namespace BetterGenshinImpact.Service.GearTask; @@ -10,17 +11,19 @@ public static class GearTaskServiceExtensions /// /// 注册齿轮任务相关服务 /// - /// 服务集合 - /// 服务集合 public static IServiceCollection AddGearTaskServices(this IServiceCollection services) { - // 注册核心服务 services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddTransient(); - services.AddTransient(); - + + services.AddSingleton(); + services.AddSingleton(); + services.AddHostedService(); + + services.AddSingleton(); + services.AddSingleton(); + return services; } }