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&¤t.MarketId==destination.MarketId
&¤t.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();}
}
}
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&¤t.MarketId==destination.MarketId
&¤t.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();}
}
}