mirror of
https://github.com/babalae/better-genshin-impact.git
synced 2026-05-21 09:45:48 +08:00
feat(gear-task): 引入事件驱动的任务执行与历史记录系统
- 新增 IGearTaskEventBus 接口及默认实现,用于解耦执行器与记录器、UI 投影等消费者 - 新增 IGearTaskResumable 接口,支持任务节点内部恢复(如 Pathing 任务可恢复至特定路径点) - 重构任务执行流程,使用 GearTaskExecutionRunner 替代旧的 GearTaskExecutionManager - 实现基于磁盘 JSON 的历史记录存储(IGearTaskHistoryStore),支持执行记录的保存、加载与清理 - 为 PathingGearTask 添加恢复能力,通过 PathingGearTaskResumeState 记录断点状态 - 在 PathExecutor 中集成运行时事件通知,支持路径点进入、完成、传送等事件的发布 - 统一执行事件模型(GearTaskExecutionEvent),包含任务定义、节点路径、时间戳等元数据 - 服务注册更新,使用新的执行器、事件总线、历史记录器等组件
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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("任务数据读取结果为空");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user