XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEEDToolBox

【WinUI】XFE精英危险工具箱

公开
关注 0 Fork 0 Star 0
UTF-8
using XFEEDToolBox.Core.Models;

namespace XFEEDToolBox.Core.Navigation;

public enum FlightAutomationEntry { Galaxy,CurrentSystem,Trade }
public enum FlightAutomationStage { Preparing,Departing,Interstellar,Local,Completed,Stopped,Failed }
public sealed record FlightAutomationProgress(FlightAutomationEntry Entry,FlightAutomationStage Stage,string Destination,string Message);
public sealed record FlightAutomationResult(NavigationRunState State,Exception? Failure=null,bool HandoffFailed=false,string? Message=null)
{
    public void RequireCompleted(CancellationToken token)
    {
        if(State==NavigationRunState.Completed)return;
        token.ThrowIfCancellationRequested();
        if(Failure is {} failure)System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(failure).Throw();
        throw new InvalidOperationException(Message??"自动驾驶已停止,未提交后续交易。");
    }
}
public interface IFlightDepartureDriver
{
    Task DepartAsync(CancellationToken token);
    /// <summary>A host restart can inherit an original launch after Journal Undocked, before the ship is clear.</summary>
    Task EnsureDepartureClearanceAsync(long? localDestinationMarket,CancellationToken token);
}
public interface IFlightJourneyDriver:IFlightDepartureDriver
{
    Task EnsureReadyAsync(CancellationToken token);
    Task<IFlightControlDriver> PlanInterstellarAsync(string destination,CancellationToken token);
    Task<(SystemNavigationTarget Target,ISystemFlightControlDriver Driver)> ResolveArrivalAsync(Station destination,CancellationToken token);
}

/// <summary>One owner and cancellation scope for departure, jumps, local flight and docking, regardless of UI entry.</summary>
public sealed class FlightAutomationCoordinator(Func<PlayerState> player,Func<DateTimeOffset>? clock=null)
{
    private readonly object gate=new();
    private readonly Func<DateTimeOffset> time=clock??(()=>DateTimeOffset.UtcNow);
    private CancellationTokenSource? active;
    private Exception? monitoringPause;
    private bool manualStop;
    private CancellationToken activeParent;
    private string runId="";
    private FlightAutomationStopRecord? lastStop;
    public FlightAutomationStopRecord? LastStop{get{lock(gate)return lastStop;}}
    public NavigationRunner Interstellar{get;}=new(player,clock);
    public SystemNavigationRunner Local{get;}=new(player);
    public FlightAutomationEntry? ActiveEntry{get;private set;}
    public FlightAutomationStage Stage{get;private set;}
    public bool IsActive{get{lock(gate)return active is not null;}}
    public bool IsControllingFlight=>IsActive&&Stage is FlightAutomationStage.Departing or FlightAutomationStage.Interstellar or FlightAutomationStage.Local;
    public event Action<FlightAutomationProgress>? Progress;
    public event Action<FlightAutomationStopRecord>? StopRecorded;
    public bool IsAwaitingInputFreeJump(PlayerState current,DateTimeOffset now)
    {
        lock(gate)if(active is null||active.IsCancellationRequested||activeParent.IsCancellationRequested||manualStop
            ||ActiveEntry is null||Stage!=FlightAutomationStage.Interstellar)return false;
        return Interstellar.IsAwaitingInputFreeJump(current,now);
    }
    private FlightAutomationStopRecord RecordStopLocked(string source,Exception? cause,bool veto=false)
    {
        var now=time();
        if(lastStop is null)
        {
            FlightStopSnapshot? snapshot=null;try{snapshot=FlightStopSnapshot.Capture(player());}catch{/* A diagnostic read cannot prevent cancellation. */}
            lastStop=new(runId,source,now,ActiveEntry,Stage,activeParent.IsCancellationRequested,cause?.Message??(veto?"用户停止自动驾驶。":"上游已取消自动驾驶。"),cause?.GetType().FullName,snapshot)
                {Cause=cause,ManualVeto=veto,ManualVetoAt=veto?now:null};
        }
        else if(veto&&!lastStop.ManualVeto||activeParent.IsCancellationRequested&&!lastStop.ParentTokenCancelled)
            lastStop=lastStop with{ManualVeto=lastStop.ManualVeto||veto,ManualVetoAt=lastStop.ManualVetoAt??(veto?now:null),ParentTokenCancelled=lastStop.ParentTokenCancelled||activeParent.IsCancellationRequested};
        return lastStop;
    }
    private void PublishStop(FlightAutomationStopRecord record)
    {if(StopRecorded is {} handlers)foreach(Action<FlightAutomationStopRecord> handler in handlers.GetInvocationList())try{handler(record);}catch{/* Logging cannot alter cancellation authority. */}}
    public void Stop()
    {
        CancellationTokenSource stop;FlightAutomationStopRecord record;
        lock(gate)
        {
            if(active is null)return;
            manualStop=true;record=RecordStopLocked("manual-stop",null,veto:true);stop=active;
        }
        PublishStop(record);try{stop.Cancel();}catch(ObjectDisposedException){/* This exact run already finished. */}
    }
    public void PauseForSafety(FlightSafetyPauseException reason)
        =>PauseForMonitoring(reason);
    public void PauseForMonitoring(Exception cause)
    {
        ArgumentNullException.ThrowIfNull(cause);CancellationTokenSource stop;FlightAutomationStopRecord record;
        lock(gate)
        {
            if(active is null||manualStop)return;
            monitoringPause??=cause;record=RecordStopLocked("monitoring-pause",monitoringPause);stop=active;
        }
        PublishStop(record);try{stop.Cancel();}catch(ObjectDisposedException){/* This exact run already finished. */}
    }

    public Task<FlightAutomationResult> NavigateGalaxyAsync(string destination,IFlightControlDriver driver,IFlightDepartureDriver departure,CancellationToken token=default)
        =>RunAsync(FlightAutomationEntry.Galaxy,destination,async (initial,stop,report)=>
        {
            destination=destination.Trim();
            if(initial.SystemName.Equals(destination,StringComparison.OrdinalIgnoreCase))return new(NavigationRunState.Completed);
            var route=NavigationProjector.RemainingRoute(initial.Navigation,destination);
            if(route.Count==0)
            {
                if(!initial.SystemName.Equals(destination,StringComparison.OrdinalIgnoreCase))throw new InvalidOperationException("游戏尚未确认前往目标星系的航线。");
                return new(NavigationRunState.Completed);
            }
            if(!route[^1].Name.Equals(destination,StringComparison.OrdinalIgnoreCase))throw new InvalidOperationException("游戏航线终点与自动导航目标不一致。");
            return await FlyInterstellarAsync(initial,destination,departure,(replan,cancellation)=>
                replan&&departure is IFlightJourneyDriver journey?journey.PlanInterstellarAsync(destination,cancellation):Task.FromResult(driver),stop,report);
        },token);

    public Task<FlightAutomationResult> NavigateLocalAsync(SystemNavigationTarget target,ISystemFlightControlDriver driver,CancellationToken token=default)
        =>RunAsync(FlightAutomationEntry.CurrentSystem,target.Name,async (initial,stop,report)=>
        {
            target.Validate();
            if(initial.Navigation.CurrentSystemAddress!=target.SystemAddress)throw new InvalidOperationException("所选地点已不在当前星系。");
            if(initial.Docked&&target.MarketId is {} market&&initial.MarketId==market)return new(NavigationRunState.Completed);
            if(initial.Docked)
            {
                if(driver is not IFlightDepartureDriver departure)throw new NotSupportedException("当前驾驶接口不支持自动离站。");
                RequireDepartureEligibility(initial,stop);
                var capability=await driver.InspectAsync(stop);
                if(!capability.Supports(target,NavigationAssists.From(initial)))throw new NotSupportedException("当前游戏接口尚未完成此目的地驾驶的验证。");
                await DepartIfNeededAsync(initial,departure,stop,report);
            }
            else if(driver is IFlightDepartureDriver continuation)
            {
                report(FlightAutomationStage.Departing,"正在核对原版离站任务和当前安全空间。");
                await continuation.EnsureDepartureClearanceAsync(target.MarketId,stop);
            }
            RequireSession(initial);stop.ThrowIfCancellationRequested();
            report(FlightAutomationStage.Local,"正在接续星系内自动驾驶。");
            await Local.RunAsync(target,driver,stop);
            return new(Local.State,Local.Failure,Local.HandoffFailed,Local.LastMessage);
        },token);

    public Task<FlightAutomationResult> TravelToStationAsync(Station destination,IFlightJourneyDriver driver,CancellationToken token=default)
        =>StationJourneyAsync(FlightAutomationEntry.Trade,destination,driver,token);

    public Task<FlightAutomationResult> NavigateStationAsync(Station destination,IFlightJourneyDriver driver,CancellationToken token=default)
        =>StationJourneyAsync(FlightAutomationEntry.Galaxy,destination,driver,token);

    private Task<FlightAutomationResult> StationJourneyAsync(FlightAutomationEntry entry,Station destination,IFlightJourneyDriver driver,CancellationToken token)
        =>RunAsync(entry,destination.Name,async (initial,stop,report)=>
        {
            if(destination.MarketId<=0||string.IsNullOrWhiteSpace(destination.SystemName))throw new InvalidDataException("目的站身份缺失。");
            await driver.EnsureReadyAsync(stop);RequireSession(initial);
            stop.ThrowIfCancellationRequested();
            if(AtDestinationStation(destination))return new(NavigationRunState.Completed);
            RequireDepartureEligibility(initial,stop);
            if(!player().SystemName.Equals(destination.SystemName,StringComparison.OrdinalIgnoreCase))
            {
                var interstellar=await FlyInterstellarAsync(initial,destination.SystemName,driver,(_,cancellation)=>driver.PlanInterstellarAsync(destination.SystemName,cancellation),stop,report);
                if(interstellar.State!=NavigationRunState.Completed)return interstellar;
                RequireSession(initial);
                if(!player().SystemName.Equals(destination.SystemName,StringComparison.OrdinalIgnoreCase))throw new InvalidOperationException("游戏尚未确认抵达目的星系。");
            }
            else
            {
                if(player().Docked&&driver is IFlightFuelPreparationDriver fuel)
                {report(FlightAutomationStage.Preparing,"正在核对站内燃油补给。");await fuel.PrepareDepartureFuelAsync(false,stop);RequireSession(initial);}
                await DepartIfNeededAsync(initial,driver,stop,report,destination.MarketId);
            }
            await driver.EnsureReadyAsync(stop);RequireSession(initial);stop.ThrowIfCancellationRequested();
            if(AtDestinationStation(destination))return new(NavigationRunState.Completed);
            report(FlightAutomationStage.Preparing,"正在读取目的站的最新身份。");
            var arrival=await driver.ResolveArrivalAsync(destination,stop);
            if(arrival.Target.SystemAddress!=player().Navigation.CurrentSystemAddress||arrival.Target.MarketId!=destination.MarketId)
                throw new InvalidDataException("目的站目录与当前星系或市场身份不一致。");
            report(FlightAutomationStage.Local,"正在前往目的站并自动停靠。");
            await Local.RunAsync(arrival.Target,arrival.Driver,stop);
            if(Local.State!=NavigationRunState.Completed)
            {
                var failure=Local.Failure;
                // A manual stop and a lost docking handoff are not scene faults.
                // Both UI and trading entries use this same post-release check.
                if(Local.State==NavigationRunState.Failed&&failure is not null&&driver is IFlightJourneyFailureClassifier classifier)
                    failure=await classifier.ClassifyFailureAsync(failure,stop);
                return new(Local.State,failure,Local.HandoffFailed,Local.LastMessage);
            }
            RequireSession(initial);
            if(!player().Docked||player().MarketId!=destination.MarketId)throw new InvalidOperationException("游戏尚未确认实际停靠目的站。");
            return new(NavigationRunState.Completed);
        },token);

    private async Task<FlightAutomationResult> FlyInterstellarAsync(PlayerState initial,string destination,IFlightDepartureDriver departure,
        Func<bool,CancellationToken,Task<IFlightControlDriver>> plan,CancellationToken stop,Action<FlightAutomationStage,string> report)
    {
        var origin=NavigationFuelResupply.CaptureOrigin(player());
        for(var attempt=0;;attempt++)
        {
            RequireDepartureEligibility(initial,stop);
            if(!player().Docked)
            {
                report(FlightAutomationStage.Departing,"正在核对原版离站任务,确认安全空间后才规划后续航段。");
                await departure.EnsureDepartureClearanceAsync(null,stop);RequireSession(initial);stop.ThrowIfCancellationRequested();
            }
            report(FlightAutomationStage.Preparing,"正在核对并规划星际航段及燃油预算。");
            var galaxy=await plan(attempt>0,stop);var runnerStarted=false;var driverReleased=false;
            FlightAutomationResult result;
            try
            {
                RequireSession(initial);stop.ThrowIfCancellationRequested();
                var expected=player().Navigation.Route.Select(point=>point.SystemAddress).ToArray();
                if(player().Docked&&departure is IFlightFuelPreparationDriver fuel)
                {
                    report(FlightAutomationStage.Preparing,"正在核对站内燃油补给,完成后才会离站。");
                    var massIncreased=await fuel.PrepareDepartureFuelAsync(true,stop);RequireSession(initial);stop.ThrowIfCancellationRequested();
                    if(!NavigationRouteProgress.SameItinerary(expected,player().Navigation))throw new InvalidOperationException("补给期间游戏航线已改变,尚未执行离站。");
                    if(massIncreased)
                    {
                        report(FlightAutomationStage.Preparing,"已确认补油增加飞船质量,正在按原目标重新核对原版航线。");
                        await galaxy.ReleaseAsync();driverReleased=true;stop.ThrowIfCancellationRequested();RequireSession(initial);
                        galaxy=await plan(true,stop);driverReleased=false;
                        expected=player().Navigation.Route.Select(point=>point.SystemAddress).ToArray();
                        // Recheck the new route without submitting another service.
                        await fuel.PrepareDepartureFuelAsync(true,stop,refuel:false);RequireSession(initial);stop.ThrowIfCancellationRequested();
                    }
                }
                if(!NavigationRouteProgress.SameItinerary(expected,player().Navigation))throw new InvalidOperationException("补给期间游戏航线已改变,尚未执行离站。");
                await DepartIfNeededAsync(initial,departure,stop,report);
                RequireSession(initial);stop.ThrowIfCancellationRequested();
                if(!NavigationRouteProgress.SameItinerary(expected,player().Navigation))throw new InvalidOperationException("补给或离站期间游戏航线已改变,已停止自动导航。");
                report(FlightAutomationStage.Interstellar,"正在接续星际自动导航。");runnerStarted=true;
                await Interstellar.RunAsync(destination,galaxy,stop,departure as IFlightJourneyFailureClassifier);
                result=new(Interstellar.State,Interstellar.Failure,Message:Interstellar.LastMessage);
            }
            finally{if(!runnerStarted&&!driverReleased)await galaxy.ReleaseAsync();}
            if(result.Failure is not NavigationFuelResupplyRequiredException shortage)
            {
                if(result.State==NavigationRunState.Failed&&result.Failure is {} failure&&departure is IFlightJourneyFailureClassifier classifier)
                    return result with{Failure=await classifier.ClassifyFailureAsync(failure,stop)};
                return result;
            }
            RequireSession(initial);stop.ThrowIfCancellationRequested();
            if(departure is not IFlightFuelPreparationDriver recovery
                ||!NavigationFuelResupply.CanReturn(initial,player(),origin,shortage,attempt>0))return result;
            NavigationFuelResupply.RequireSameCargo(initial,player());
            report(FlightAutomationStage.Preparing,$"燃油不足,正在返回本航程已确认的 {origin!.Name} 补油;原目的地保留。");
            using var returnBudget=CancellationTokenSource.CreateLinkedTokenSource(stop);returnBudget.CancelAfter(TimeSpan.FromMinutes(15));
            var home=await recovery.ResolveFuelReturnAsync(origin!,returnBudget.Token);
            RequireSession(initial);NavigationFuelResupply.RequireSameCargo(initial,player());stop.ThrowIfCancellationRequested();
            if(home.Target.SystemAddress!=initial.Navigation.CurrentSystemAddress||home.Target.MarketId!=origin!.MarketId
                ||home.Target.Kind!=SystemTargetKind.Station)throw new InvalidDataException("补油返站目标与本航程实际离站站点不一致。");
            report(FlightAutomationStage.Local,"正在使用普通超巡返回原站补油。");
            await Local.RunAsync(home.Target,home.Driver,returnBudget.Token);
            stop.ThrowIfCancellationRequested();
            if(returnBudget.IsCancellationRequested)throw new TimeoutException("补油返站超时,原目的地与订单已保留。");
            if(Local.State!=NavigationRunState.Completed)return new(Local.State,Local.Failure,Local.HandoffFailed,Local.LastMessage);
            RequireSession(initial);NavigationFuelResupply.RequireSameCargo(initial,player());
            if(!player().Docked||player().MarketId!=origin.MarketId||!player().SystemName.Equals(origin.SystemName,StringComparison.OrdinalIgnoreCase))
                throw new InvalidDataException("游戏尚未确认停靠原补油站,未重启原航程。");
            // Only this one confirmed return may retry the original destination.
            // Its route is replanned and the normal docked fuel gate runs again.
        }
    }

    private bool AtDestinationStation(Station destination)=>player() is {Docked:true} current&&current.MarketId==destination.MarketId
        &&current.SystemName.Equals(destination.SystemName,StringComparison.OrdinalIgnoreCase);

    private async Task DepartIfNeededAsync(PlayerState initial,IFlightDepartureDriver driver,CancellationToken token,Action<FlightAutomationStage,string> report,long? localDestinationMarket=null)
    {
        RequireDepartureEligibility(initial,token);
        if(!player().Docked)
        {
            report(FlightAutomationStage.Departing,"正在核对原版离站连续性,未将未停靠视为已完成离站。");
            await driver.EnsureDepartureClearanceAsync(localDestinationMarket,token);RequireSession(initial);token.ThrowIfCancellationRequested();return;
        }
        report(FlightAutomationStage.Departing,"正在自动离站,并等待解除质量锁定。");var requestedAt=GameJournalClock.Now(player(),DateTimeOffset.UtcNow);
        await driver.DepartAsync(token);RequireSession(initial);token.ThrowIfCancellationRequested();
        var departed=player();
        if(departed.Docked||departed.Navigation.UndockedAt is not {} at||at<requestedAt.AddSeconds(-1)||at>GameJournalClock.Now(departed,DateTimeOffset.UtcNow).AddSeconds(5))
            throw new InvalidOperationException("游戏尚未确认本次实际离站,未启动后续航段。");
    }
    private void RequireDepartureEligibility(PlayerState initial,CancellationToken token)
    {
        RequireSession(initial);token.ThrowIfCancellationRequested();
        if(player().Docked&&!NavigationAssists.From(player()).AdvancedDocking)
            throw new NotSupportedException("自动离站需要已启用的高级自动起降模块。");
    }
    private void RequireSession(PlayerState initial)
    {
        var current=player();
        if(!current.InGame||string.IsNullOrWhiteSpace(initial.CommanderId)||initial.CurrentShipId is null||initial.LoadGameAt is null
            ||current.CommanderId!=initial.CommanderId||current.CurrentShipId!=initial.CurrentShipId||current.LoadGameAt!=initial.LoadGameAt||NavigationSafety.UnhealthyHull(current))
            throw new InvalidOperationException("飞船、身份、会话或安全状态改变,已停止统一自动驾驶。");
        if(NavigationSafety.HasExternalThreat(current))throw new FlightThreatPauseException();
    }
    private async Task<FlightAutomationResult> RunAsync(FlightAutomationEntry entry,string destination,
        Func<PlayerState,CancellationToken,Action<FlightAutomationStage,string>,Task<FlightAutomationResult>> operation,CancellationToken token)
    {
        CancellationTokenSource stop;
        lock(gate)
        {
            if(active is not null)throw new InvalidOperationException("已有自动驾驶正在运行,请先停止当前航程。");
            stop=CancellationTokenSource.CreateLinkedTokenSource(token);active=stop;activeParent=token;ActiveEntry=entry;Stage=FlightAutomationStage.Preparing;
            monitoringPause=null;manualStop=false;lastStop=null;runId=Guid.NewGuid().ToString("N");
        }
        var thisRun=runId;
        using var parentRegistration=token.Register(()=>
        {
            FlightAutomationStopRecord? record=null;lock(gate)if(ReferenceEquals(active,stop)&&runId==thisRun)record=RecordStopLocked("parent-token",null);
            if(record is not null)PublishStop(record);
        });
        void Report(FlightAutomationStage stage,string message){lock(gate)Stage=stage;Progress?.Invoke(new(entry,stage,destination,message));}
        FlightAutomationResult CancellationResult()
        {
            lock(gate)
                return !token.IsCancellationRequested&&!manualStop&&monitoringPause is {} reason
                    ?new(NavigationRunState.Failed,reason):new(NavigationRunState.Stopped);
        }
        PlayerState? initialState=null;
        void MarkDeparture(Exception? error)
        {
            if(error is null)return;
            if(error is FlightSafetyPauseException pause)
                pause.DepartureMarketId=Stage==FlightAutomationStage.Departing&&initialState is {Docked:true,MarketId:>0}?initialState.MarketId:null;
            if(error is AggregateException aggregate)foreach(var inner in aggregate.InnerExceptions)MarkDeparture(inner);
            else MarkDeparture(error.InnerException);
        }
        try
        {
            stop.Token.ThrowIfCancellationRequested();var initial=player();RequireSession(initial);initialState=initial;
            Report(FlightAutomationStage.Preparing,"正在核对飞船、航程与自动驾驶状态。");
            var result=await operation(initial,stop.Token,Report);
            if(result.State==NavigationRunState.Stopped&&lastStop is null)
            {
                FlightAutomationStopRecord? record=null;
                lock(gate)
                {
                    if(lastStop is null)
                    {
                        var runner=Stage==FlightAutomationStage.Interstellar?Interstellar.LastStop:null;
                        record=lastStop=runner is not null?runner with{RunId=runId,Entry=ActiveEntry,Stage=Stage,ParentTokenCancelled=token.IsCancellationRequested}
                            :RecordStopLocked("inner-runner-stopped",null);
                    }
                }
                if(record is not null)PublishStop(record);
            }
            // Inner runners consume cancellation after releasing their inputs.
            // Preserve the first monitor cause; cleanup cannot replace it with
            // a generic stopped result or a later transport error.
            if(stop.IsCancellationRequested)
            {
                var cancelled=CancellationResult();
                result=cancelled;
            }
            MarkDeparture(result.Failure);
            Report(result.State==NavigationRunState.Completed?FlightAutomationStage.Completed:result.State==NavigationRunState.Stopped?FlightAutomationStage.Stopped:FlightAutomationStage.Failed,
                result.Failure?.Message??result.Message??(result.State==NavigationRunState.Completed?"航程已完成。":"自动驾驶已停止。"));
            return result;
        }
        catch(OperationCanceledException)when(stop.IsCancellationRequested)
        {
            var result=CancellationResult();MarkDeparture(result.Failure);Report(result.Failure is null?FlightAutomationStage.Stopped:FlightAutomationStage.Failed,result.Failure?.Message??"自动驾驶已停止。");return result;
        }
        catch(Exception error)
        {
            if(stop.IsCancellationRequested)
            {
                var cancelled=CancellationResult();MarkDeparture(cancelled.Failure);
                Report(cancelled.Failure is null?FlightAutomationStage.Stopped:FlightAutomationStage.Failed,cancelled.Failure?.Message??"自动驾驶已停止。");return cancelled;
            }
            MarkDeparture(error);Report(FlightAutomationStage.Failed,error.Message);return new(NavigationRunState.Failed,error);
        }
        finally{lock(gate){active=null;ActiveEntry=null;activeParent=default;monitoringPause=null;manualStop=false;}stop.Dispose();}
    }
}