feat(gear-task): 引入事件驱动的任务执行与历史记录系统

- 新增 IGearTaskEventBus 接口及默认实现,用于解耦执行器与记录器、UI 投影等消费者
- 新增 IGearTaskResumable 接口,支持任务节点内部恢复(如 Pathing 任务可恢复至特定路径点)
- 重构任务执行流程,使用 GearTaskExecutionRunner 替代旧的 GearTaskExecutionManager
- 实现基于磁盘 JSON 的历史记录存储(IGearTaskHistoryStore),支持执行记录的保存、加载与清理
- 为 PathingGearTask 添加恢复能力,通过 PathingGearTaskResumeState 记录断点状态
- 在 PathExecutor 中集成运行时事件通知,支持路径点进入、完成、传送等事件的发布
- 统一执行事件模型(GearTaskExecutionEvent),包含任务定义、节点路径、时间戳等元数据
- 服务注册更新,使用新的执行器、事件总线、历史记录器等组件
This commit is contained in:
辉鸭蛋
2026-05-11 01:57:29 +08:00
parent 6cdc67c9c3
commit 7f7a34e9ba
18 changed files with 1919 additions and 638 deletions

View File

@@ -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();

View File

@@ -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;
/// <summary>
/// 为了和其他Task做区分使用Gear(齿轮)作为前缀命名调度器内定义的任务
/// 为了和其他 Task 做区分,这里使用 Gear(齿轮) 作为调度器内任务定义的统一抽象。
/// </summary>
public abstract class BaseGearTask : ObservableObject
{
@@ -23,14 +24,14 @@ public abstract class BaseGearTask : ObservableObject
/// 任务名称
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 任务类型
/// </summary>
public string Type { get; set; } = string.Empty;
/// <summary>
/// 任务的文件位置,如果有
/// 任务的文件位置,如果有
/// </summary>
public string FilePath { get; set; } = string.Empty;
@@ -50,6 +51,14 @@ public abstract class BaseGearTask : ObservableObject
/// </summary>
public List<BaseGearTask> Children { get; set; } = [];
[JsonIgnore]
public GearTaskNodeExecutionContext? ExecutionContext { get; private set; }
public virtual void SetExecutionContext(GearTaskNodeExecutionContext context)
{
ExecutionContext = context;
}
/// <summary>
/// 执行任务
/// </summary>
@@ -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
/// 执行任务
/// </summary>
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<BaseGearTask>(json) ?? throw new InvalidOperationException("任务数据读取结果为空");
}
}

View File

@@ -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);
}
}
public Task ApplyResumeTokenAsync(string? resumeTokenJson, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(resumeTokenJson))
{
_resumeState = null;
return Task.CompletedTask;
}
_resumeState = JsonConvert.DeserializeObject<PathingGearTaskResumeState>(resumeTokenJson);
return Task.CompletedTask;
}
}

View File

@@ -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;
/// <summary>
/// 默认事件总线实现。
/// 当前采用进程内同步分发,借由消费者内部自行决定是否异步缓冲。
/// </summary>
public sealed class GearTaskEventBus : IGearTaskEventBus
{
private readonly ILogger<GearTaskEventBus> _logger;
private readonly object _gate = new();
private readonly List<IGearTaskEventConsumer> _consumers = [];
public GearTaskEventBus(ILogger<GearTaskEventBus> 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<IGearTaskEventConsumer> 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);
}
}
}

View File

@@ -0,0 +1,53 @@
using System;
using System.Collections.Generic;
namespace BetterGenshinImpact.Service.GearTask.Execution;
/// <summary>
/// GearTask 执行链路中的统一事件基类。
/// 执行器只负责发布事件记录器、UI 投影和其他消费者按需订阅并消费。
/// </summary>
public abstract class GearTaskExecutionEvent
{
/// <summary>
/// 当前执行记录的唯一标识。
/// </summary>
public string RecordId { get; set; } = string.Empty;
/// <summary>
/// 任务定义展示名。
/// </summary>
public string TaskDefinitionName { get; set; } = string.Empty;
/// <summary>
/// 任务定义对应的安全文件名,用于历史记录目录划分。
/// </summary>
public string TaskDefinitionFileKey { get; set; } = string.Empty;
/// <summary>
/// 任务树中的节点路径,例如 0/1/2。
/// </summary>
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; }
/// <summary>
/// 事件发生时间,默认使用本地时间,便于直接展示在历史时间线中。
/// </summary>
public DateTime Timestamp { get; set; } = DateTime.Now;
/// <summary>
/// 预留给具体任务类型的扩展字段,避免事件模型为了单个任务不断膨胀。
/// </summary>
public Dictionary<string, object?> Extra { get; set; } = [];
}

View File

@@ -0,0 +1,171 @@
namespace BetterGenshinImpact.Service.GearTask.Execution;
/// <summary>
/// 一次任务组执行开始。
/// </summary>
public sealed class ExecutionStartedEvent : GearTaskExecutionEvent
{
/// <summary>
/// 如果本次执行来自续跑,则记录源执行记录 Id。
/// </summary>
public string? SourceRecordId { get; set; }
/// <summary>
/// 本次预计可执行的总节点数,用于进度展示。
/// </summary>
public int TotalRunnableNodeCount { get; set; }
}
/// <summary>
/// 一次任务组执行正常结束。
/// </summary>
public sealed class ExecutionCompletedEvent : GearTaskExecutionEvent
{
}
/// <summary>
/// 一次任务组执行因异常失败。
/// </summary>
public sealed class ExecutionFailedEvent : GearTaskExecutionEvent
{
public string ErrorMessage { get; set; } = string.Empty;
}
/// <summary>
/// 一次任务组执行被取消。
/// </summary>
public sealed class ExecutionCancelledEvent : GearTaskExecutionEvent
{
public string? Reason { get; set; }
}
/// <summary>
/// 一次任务组执行被外部中断,但不一定等价于异常失败。
/// </summary>
public sealed class ExecutionInterruptedEvent : GearTaskExecutionEvent
{
public string? Reason { get; set; }
}
/// <summary>
/// 单个任务节点开始执行。
/// </summary>
public sealed class TaskNodeStartedEvent : GearTaskExecutionEvent
{
}
/// <summary>
/// 单个任务节点执行完成。
/// </summary>
public sealed class TaskNodeCompletedEvent : GearTaskExecutionEvent
{
public string? Message { get; set; }
}
/// <summary>
/// 单个任务节点执行失败。
/// </summary>
public sealed class TaskNodeFailedEvent : GearTaskExecutionEvent
{
public string ErrorMessage { get; set; } = string.Empty;
}
/// <summary>
/// 单个任务节点被跳过。
/// </summary>
public sealed class TaskNodeSkippedEvent : GearTaskExecutionEvent
{
public string Reason { get; set; } = string.Empty;
}
/// <summary>
/// 任务内部恢复点更新。
/// </summary>
public sealed class ResumePointUpdatedEvent : GearTaskExecutionEvent
{
/// <summary>
/// 是否支持在该任务内部继续执行,而不是仅支持节点级重跑。
/// </summary>
public bool CanResumeInsideTask { get; set; }
/// <summary>
/// 任务私有恢复状态序列化结果。
/// </summary>
public string? ResumeTokenJson { get; set; }
public string? Message { get; set; }
}
/// <summary>
/// Pathing 进入某个 waypoint。
/// </summary>
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; }
}
/// <summary>
/// Pathing 完成某个 waypoint。
/// </summary>
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; }
}
/// <summary>
/// Pathing 完成传送步骤。
/// </summary>
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; }
}
/// <summary>
/// Pathing 完成 waypoint 绑定动作。
/// </summary>
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; }
}
/// <summary>
/// JavaScript 脚本主动上报的检查点事件。
/// </summary>
public sealed class ScriptCheckpointReachedEvent : GearTaskExecutionEvent
{
public string CheckpointName { get; set; } = string.Empty;
public string? ResumeTokenJson { get; set; }
public string? Message { get; set; }
}

View File

@@ -0,0 +1,186 @@
using System;
using System.Collections.Generic;
namespace BetterGenshinImpact.Service.GearTask.Execution;
/// <summary>
/// 一次任务组执行记录的状态。
/// </summary>
public enum GearTaskExecutionRecordStatus
{
Pending,
Running,
Succeeded,
Failed,
Cancelled,
Interrupted,
}
/// <summary>
/// 单个任务节点在执行记录中的状态。
/// </summary>
public enum GearTaskExecutionNodeStatus
{
Pending,
Running,
Succeeded,
Failed,
Skipped,
Cancelled,
Interrupted,
}
/// <summary>
/// 一次大任务组执行的持久化记录。
/// </summary>
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; }
/// <summary>
/// 当前记录可恢复时,对应的节点 Id。
/// </summary>
public string? ResumeNodeId { get; set; }
/// <summary>
/// 是否存在有效恢复点。
/// </summary>
public bool CanResume { get; set; }
public string? InterruptReason { get; set; }
public string? ErrorMessage { get; set; }
/// <summary>
/// 当前记录内所有节点的最终或最近状态快照。
/// </summary>
public List<GearTaskExecutionNodeRecord> Nodes { get; set; } = [];
/// <summary>
/// 完整时间线,面向执行记录详情和调试定位。
/// </summary>
public List<GearTaskExecutionTimelineItem> Timeline { get; set; } = [];
public int SchemaVersion { get; set; } = 1;
}
/// <summary>
/// 单个任务节点的持久化快照。
/// </summary>
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; }
/// <summary>
/// 节点内部恢复状态,通常由具体任务类型自己定义。
/// </summary>
public string? ResumeTokenJson { get; set; }
}
/// <summary>
/// 面向“执行详情”视图的时间线条目。
/// </summary>
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<string, object?> Extra { get; set; } = [];
}
/// <summary>
/// 执行恢复计划。
/// 由历史记录解析得到,再传递给执行器决定哪些节点跳过、哪些节点恢复。
/// </summary>
public class GearTaskResumePlan
{
public string SourceRecordId { get; set; } = string.Empty;
public string ResumeNodeId { get; set; } = string.Empty;
public Dictionary<string, string> NodeResumeTokens { get; set; } = [];
}
/// <summary>
/// 一次执行运行时的公共上下文。
/// </summary>
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; }
}
/// <summary>
/// PathingGearTask 的内部恢复状态。
/// 当前按 waypoint 粒度记录,后续需要更细时可继续扩展。
/// </summary>
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; }
}

View File

@@ -0,0 +1,87 @@
using System;
using System.IO;
using BetterGenshinImpact.Core.Config;
using BetterGenshinImpact.ViewModel.Pages;
namespace BetterGenshinImpact.Service.GearTask.Execution;
/// <summary>
/// 执行记录中的任务路径格式化器。
/// 将运行期绝对路径转换为和任务定义列表一致的占位符路径。
/// </summary>
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;
}
}

View File

@@ -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;
/// <summary>
/// GearTask 任务树执行器。
/// 只负责遍历节点、执行任务以及向外发布业务事件。
/// </summary>
public interface IGearTaskExecutionRunner
{
Task RunAsync(BaseGearTask rootTask, GearTaskExecutionRunContext context, CancellationToken ct);
}
/// <summary>
/// 默认 GearTask 执行器实现。
/// </summary>
public sealed class GearTaskExecutionRunner : IGearTaskExecutionRunner
{
private readonly ILogger<GearTaskExecutionRunner> _logger;
private readonly IGearTaskEventBus _eventBus;
public GearTaskExecutionRunner(ILogger<GearTaskExecutionRunner> 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<ExecutionStartedEvent>();
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<ExecutionCompletedEvent>();
await _eventBus.PublishAsync(completedEvent, ct);
}
catch (OperationCanceledException)
{
var cancelledEvent = rootContext.CreateEvent<ExecutionCancelledEvent>();
cancelledEvent.Reason = "用户取消执行";
await _eventBus.PublishAsync(cancelledEvent, CancellationToken.None);
throw;
}
catch (Exception ex)
{
var failedEvent = rootContext.CreateEvent<ExecutionFailedEvent>();
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<TaskNodeSkippedEvent>();
skippedEvent.Reason = "任务未启用";
await nodeContext.PublishAsync(skippedEvent, ct);
return;
}
if (ShouldSkipByResume(runContext.ResumePlan, nodeId))
{
var skippedEvent = nodeContext.CreateEvent<TaskNodeSkippedEvent>();
skippedEvent.Reason = "恢复执行时跳过已完成节点";
await nodeContext.PublishAsync(skippedEvent, ct);
return;
}
var startedEvent = nodeContext.CreateEvent<TaskNodeStartedEvent>();
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<TaskNodeCompletedEvent>();
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<TaskNodeFailedEvent>();
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);
}
/// <summary>
/// 比较两个节点路径的顺序,供恢复逻辑判断当前节点是否位于恢复点之前。
/// </summary>
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;
}
/// <summary>
/// 统计任务树节点总数,用于生成执行进度的总量基线。
/// </summary>
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));
}
}

View File

@@ -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;
/// <summary>
/// GearTask 历史记录器。
/// 通过订阅执行事件把事件写入 Channel再由单消费者后台循环聚合并异步落盘。
/// </summary>
public sealed class GearTaskHistoryRecorder : BackgroundService, IGearTaskEventConsumer
{
private static readonly TimeSpan FlushDebounce = TimeSpan.FromMilliseconds(300);
private readonly ILogger<GearTaskHistoryRecorder> _logger;
private readonly IGearTaskHistoryStore _historyStore;
private readonly IDisposable _subscription;
private readonly Channel<GearTaskExecutionEvent> _eventChannel;
private readonly Dictionary<string, GearTaskExecutionRecord> _records = [];
private readonly Dictionary<string, DateTime> _dirtyRecords = [];
private readonly HashSet<string> _priorityFlushRecords = [];
private bool _disposed;
public GearTaskHistoryRecorder(
ILogger<GearTaskHistoryRecorder> logger,
IGearTaskHistoryStore historyStore,
IGearTaskEventBus eventBus)
{
_logger = logger;
_historyStore = historyStore;
_subscription = eventBus.Subscribe(this);
_eventChannel = Channel.CreateUnbounded<GearTaskExecutionEvent>(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<string, object?> BuildTimelineExtra(GearTaskExecutionEvent evt)
{
var extra = new Dictionary<string, object?>(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<string, object?> 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();
}
}

View File

@@ -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;
/// <summary>
/// GearTask 历史记录存储接口。
/// </summary>
public interface IGearTaskHistoryStore
{
Task SaveAsync(GearTaskExecutionRecord record);
Task<GearTaskExecutionRecord?> LoadAsync(string taskDefinitionFileKey, string recordId);
Task<IReadOnlyList<GearTaskExecutionRecord>> LoadLatestAsync(string taskDefinitionFileKey, int count);
Task TrimAsync(string taskDefinitionFileKey, int keepCount);
}
/// <summary>
/// 基于磁盘 JSON 文件的历史记录存储。
/// 一个执行记录对应一个文件,按任务定义分目录存放。
/// </summary>
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<GearTaskExecutionRecord?> 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<GearTaskExecutionRecord>(json, _jsonSettings);
}
public async Task<IReadOnlyList<GearTaskExecutionRecord>> 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<GearTaskExecutionRecord>(files.Count);
foreach (var file in files)
{
var json = await File.ReadAllTextAsync(file);
var record = JsonConvert.DeserializeObject<GearTaskExecutionRecord>(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");
}
}

View File

@@ -0,0 +1,124 @@
using System.Threading;
using System.Threading.Tasks;
using BetterGenshinImpact.Model.Gear.Tasks;
namespace BetterGenshinImpact.Service.GearTask.Execution;
/// <summary>
/// 单个任务节点在一次执行中的上下文。
/// 它负责把节点元数据与事件发布能力打包传给具体任务实例。
/// </summary>
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;
}
/// <summary>
/// 使用任务节点和运行上下文创建节点执行上下文。
/// </summary>
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; }
/// <summary>
/// 从历史记录恢复时传入的节点级恢复令牌。
/// 由实现了 IGearTaskResumable 的任务自行解释。
/// </summary>
public string? ResumeTokenJson { get; }
/// <summary>
/// 基于当前节点信息构造一条事件,避免各任务重复填公共字段。
/// </summary>
public T CreateEvent<T>() 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,
};
}
/// <summary>
/// 发布当前节点相关事件。
/// </summary>
public ValueTask PublishAsync(GearTaskExecutionEvent evt, CancellationToken ct = default)
{
return _eventBus.PublishAsync(evt, ct);
}
}

View File

@@ -0,0 +1,30 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace BetterGenshinImpact.Service.GearTask.Execution;
/// <summary>
/// GearTask 执行事件总线。
/// 负责在执行器与记录器、UI 投影等消费者之间解耦。
/// </summary>
public interface IGearTaskEventBus
{
/// <summary>
/// 发布一条执行事件。
/// </summary>
ValueTask PublishAsync(GearTaskExecutionEvent evt, CancellationToken ct = default);
/// <summary>
/// 注册一个事件消费者。
/// </summary>
IDisposable Subscribe(IGearTaskEventConsumer consumer);
}
/// <summary>
/// GearTask 执行事件消费者。
/// </summary>
public interface IGearTaskEventConsumer
{
ValueTask ConsumeAsync(GearTaskExecutionEvent evt, CancellationToken ct = default);
}

View File

@@ -0,0 +1,13 @@
using System.Threading;
using System.Threading.Tasks;
namespace BetterGenshinImpact.Service.GearTask.Execution;
/// <summary>
/// 支持节点内部恢复的 GearTask 需要实现的接口。
/// 执行器会在节点执行前注入上次记录下来的恢复令牌。
/// </summary>
public interface IGearTaskResumable
{
Task ApplyResumeTokenAsync(string? resumeTokenJson, CancellationToken ct);
}

View File

@@ -0,0 +1,118 @@
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json;
namespace BetterGenshinImpact.Service.GearTask.Execution;
/// <summary>
/// Pathing 运行期通知接口。
/// PathExecutor 从业务步骤出发发出通知,外部可以选择记录、展示或转发。
/// </summary>
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);
}
/// <summary>
/// 空通知器,供不关心 Pathing 运行态的场景使用。
/// </summary>
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;
}
/// <summary>
/// 把 Pathing 运行步骤转换成 GearTask 执行事件。
/// </summary>
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<PathingWaypointEnteredEvent>();
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<PathingWaypointCompletedEvent>();
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<PathingTeleportCompletedEvent>();
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<PathingActionCompletedEvent>();
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<ResumePointUpdatedEvent>();
evt.CanResumeInsideTask = true;
evt.ResumeTokenJson = JsonConvert.SerializeObject(state);
evt.Message = message;
return _context.PublishAsync(evt, ct);
}
}

View File

@@ -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;
/// <summary>
/// 任务执行状态
/// </summary>
public enum TaskExecutionStatus
{
/// <summary>
/// 等待执行
/// </summary>
Pending,
/// <summary>
/// 正在执行
/// </summary>
Running,
/// <summary>
/// 执行完成
/// </summary>
Completed,
/// <summary>
/// 执行失败
/// </summary>
Failed,
/// <summary>
/// 已取消
/// </summary>
Cancelled,
/// <summary>
/// 已跳过
/// </summary>
Skipped
}
/// <summary>
/// 任务执行信息
/// </summary>
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<TaskExecutionInfo> Children { get; set; } = new();
public TaskExecutionInfo? Parent { get; set; }
}
/// <summary>
/// 齿轮任务执行管理器,负责管理任务执行状态和进度跟踪
/// </summary>
public partial class GearTaskExecutionManager : ObservableObject
{
private readonly ILogger<GearTaskExecutionManager> _logger;
private readonly Dictionary<BaseGearTask, TaskExecutionInfo> _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<TaskExecutionInfo>? TaskStarted;
public event EventHandler<TaskExecutionInfo>? TaskCompleted;
public event EventHandler<TaskExecutionInfo>? TaskFailed;
public event EventHandler<TaskExecutionInfo>? TaskSkipped;
public event EventHandler<double>? ProgressChanged;
public GearTaskExecutionManager(ILogger<GearTaskExecutionManager> logger)
{
_logger = logger;
}
/// <summary>
/// 开始执行任务并跟踪状态
/// </summary>
/// <param name="rootTask">根任务</param>
/// <param name="ct">取消令牌</param>
/// <returns></returns>
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;
}
}
/// <summary>
/// 取消任务执行
/// </summary>
public void CancelExecution()
{
if (IsExecuting && _cancellationTokenSource != null)
{
_logger.LogInformation("用户请求取消任务执行");
_cancellationTokenSource.Cancel();
OverallStatusMessage = "正在取消任务执行...";
}
}
/// <summary>
/// 初始化执行信息
/// </summary>
/// <param name="rootTask">根任务</param>
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);
}
/// <summary>
/// 创建任务执行信息
/// </summary>
/// <param name="task">任务</param>
/// <param name="parent">父任务信息</param>
/// <returns></returns>
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;
}
/// <summary>
/// 执行任务并跟踪状态
/// </summary>
/// <param name="task">要执行的任务</param>
/// <param name="ct">取消令牌</param>
/// <returns></returns>
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;
}
}
/// <summary>
/// 开始执行任务
/// </summary>
/// <param name="taskInfo">任务信息</param>
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;
}
/// <summary>
/// 完成任务
/// </summary>
/// <param name="taskInfo">任务信息</param>
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;
}
/// <summary>
/// 任务执行失败
/// </summary>
/// <param name="taskInfo">任务信息</param>
/// <param name="exception">异常</param>
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;
}
/// <summary>
/// 标记任务为已跳过
/// </summary>
/// <param name="taskInfo">任务信息</param>
/// <param name="reason">跳过原因</param>
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;
}
/// <summary>
/// 标记任务为已取消
/// </summary>
/// <param name="taskInfo">任务信息</param>
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;
}
/// <summary>
/// 更新整体进度
/// </summary>
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);
}
}
/// <summary>
/// 更新最终状态
/// </summary>
private void UpdateOverallStatus()
{
if (FailedTasks > 0)
{
OverallStatusMessage = $"执行完成,但有 {FailedTasks} 个任务失败";
}
else if (SkippedTasks > 0)
{
OverallStatusMessage = $"执行完成,跳过了 {SkippedTasks} 个任务";
}
else
{
OverallStatusMessage = "所有任务执行完成";
}
}
/// <summary>
/// 统计任务总数
/// </summary>
/// <param name="task">根任务</param>
/// <returns>任务总数</returns>
private int CountTotalTasks(BaseGearTask task)
{
int count = 1;
if (task.Children?.Count > 0)
{
foreach (var child in task.Children)
{
count += CountTotalTasks(child);
}
}
return count;
}
/// <summary>
/// 获取任务执行统计信息
/// </summary>
/// <returns>统计信息</returns>
public TaskExecutionStatistics GetStatistics()
{
return new TaskExecutionStatistics
{
TotalTasks = TotalTasks,
CompletedTasks = CompletedTasks,
FailedTasks = FailedTasks,
SkippedTasks = SkippedTasks,
OverallProgress = OverallProgress,
IsExecuting = IsExecuting
};
}
}
/// <summary>
/// 任务执行统计信息
/// </summary>
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;
}

View File

@@ -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;
/// <summary>
/// 齿轮任务执行,负责从 JSON 数据解析任务并执行
/// 齿轮任务执行入口,负责加载任务定义、构造执行上下文并启动执行
/// </summary>
public partial class GearTaskExecutor : ObservableObject
public partial class GearTaskExecutor : ObservableObject, IGearTaskEventConsumer, IDisposable
{
private readonly ILogger<GearTaskExecutor> _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<GearTaskExecutor> 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);
}
/// <summary>
@@ -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();
}
}
/// <summary>
/// 停止当前执行的任务
/// </summary>
public void StopExecution()
public async Task ResumeTaskDefinitionAsync(string taskDefinitionName, string sourceRecordId, CancellationToken ct = default)
{
if (IsExecuting)
{
StatusMessage = "正在停止任务执行...";
_logger.LogInformation("用户请求停止任务执行");
_executionManager.CancelExecution();
throw new InvalidOperationException("任务执行器正在运行中,请等待当前任务完成");
}
}
/// <summary>
/// 处理进度变化事件
/// </summary>
/// <param name="sender">事件发送者</param>
/// <param name="progress">进度值</param>
private void OnProgressChanged(object? sender, double progress)
{
Progress = progress;
}
/// <summary>
/// 处理执行管理器属性变化事件
/// </summary>
/// <param name="sender">事件发送者</param>
/// <param name="e">属性变化事件参数</param>
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;
}
}
/// <summary>
/// 获取执行统计信息
/// </summary>
/// <returns>统计信息</returns>
public TaskExecutionStatistics GetExecutionStatistics()
public void Dispose()
{
return _executionManager.GetStatistics();
_subscription.Dispose();
_runningCancellationTokenSource?.Dispose();
}
/// <summary>
/// 获取当前任务执行信息
/// </summary>
/// <returns>当前任务信息</returns>
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);
}
/// <summary>
/// 获取根任务执行信息
/// </summary>
/// <returns></returns>
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;
}
/// <summary>
/// 将 GearTaskViewModel 转换为 GearTaskData
/// </summary>
/// <param name="viewModel">视图模型</param>
/// <returns>任务数据</returns>
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<GearTaskData>();
@@ -229,14 +279,3 @@ public partial class GearTaskExecutor : ObservableObject
return taskData;
}
}
/// <summary>
/// 空任务实现,用于已禁用的任务
/// </summary>
internal class EmptyGearTask : BaseGearTask
{
public override Task Run(CancellationToken ct)
{
return Task.CompletedTask;
}
}

View File

@@ -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
/// <summary>
/// 注册齿轮任务相关服务
/// </summary>
/// <param name="services">服务集合</param>
/// <returns>服务集合</returns>
public static IServiceCollection AddGearTaskServices(this IServiceCollection services)
{
// 注册核心服务
services.AddSingleton<GearTaskStorageService>();
services.AddSingleton<GearTaskFactory>();
services.AddSingleton<GearTaskConverter>();
services.AddTransient<GearTaskExecutionManager>();
services.AddTransient<GearTaskExecutor>();
services.AddSingleton<IGearTaskEventBus, GearTaskEventBus>();
services.AddSingleton<IGearTaskHistoryStore, GearTaskHistoryStore>();
services.AddHostedService<GearTaskHistoryRecorder>();
services.AddSingleton<IGearTaskExecutionRunner, GearTaskExecutionRunner>();
services.AddSingleton<GearTaskExecutor>();
return services;
}
}