using XFEEDToolBox.Core.Models;
namespace XFEEDToolBox.Core.Navigation;
/// <summary>A confirmed fuel shortfall is a station-service task, not a transient telemetry wait.</summary>
public sealed class NavigationFuelResupplyRequiredException(long systemAddress,double available,double required)
:InvalidOperationException($"当前燃油 {available:F2} t 不足以覆盖已核对航程及储备 {required:F2} t,需要先在站点补油;原目的地已保留。")
{
public long SystemAddress{get;}=systemAddress;
public double Available{get;}=available;
public double Required{get;}=required;
}
public interface IFlightFuelPreparationDriver
{
Task<bool> PrepareDepartureFuelAsync(bool interstellar,CancellationToken token,bool refuel=true);
Task<(SystemNavigationTarget Target,ISystemFlightControlDriver Driver)> ResolveFuelReturnAsync(Station origin,CancellationToken token);
}
public sealed record DockedFuelSnapshot(PlayerState Player,double Available,double Required);
public static class NavigationFuelResupply
{
public static DateTimeOffset? FuelObservedAt(PlayerState state)=>state.Navigation.FuelAt>state.StatusAt
?state.Navigation.FuelAt:state.StatusAt??state.Navigation.FuelAt;
public static NavigationFuelResupplyRequiredException? Required(FlightFrame frame)
{
var minimum=frame.MinimumFuelToNextRefuel??frame.FuelNeededToNextRefuel;
if(frame.ScoopPowered||frame.Fsd==FsdPhase.Jumping||frame.FuelMain is not {} available||minimum is not {} required
||!double.IsFinite(available)||!double.IsFinite(required)||available<0||required<=0||available>=required)return null;
return new(frame.SystemAddress,available,required);
}
public static double CurrentFuel(PlayerState state,DateTimeOffset now)
{
var journalNewer=state.Navigation.FuelAt>state.StatusAt;
var fuel=journalNewer?state.Navigation.MainFuel:state.Status?.FuelMain??state.Navigation.MainFuel;
var at=FuelObservedAt(state);
if(fuel is not {} value||!double.IsFinite(value)||value<0||at is null
||GameJournalClock.Now(state,now)-at>TimeSpan.FromSeconds(10)&&!ReconfirmedDockedStatus(state,at.Value,now)
||at>GameJournalClock.Now(state,now).AddSeconds(5))
throw new FlightTelemetryUnavailableException("等待本次飞船的连续燃油状态,尚未离站。");
return value;
}
private static bool ReconfirmedDockedStatus(PlayerState state,DateTimeOffset fuelAt,DateTimeOffset now)
{
// An unchanged, stable Status file is reread by the monitor. Its old
// content timestamp alone is not stale while the same ship is docked.
// Never apply this exception to an old journal fuel event or in flight.
return state.InGame&&state.Docked&&state.MarketId is >0&&state.LoadGameAt is {} loaded
&&state.Status is {} status&&(status.Flags&1)!=0&&(status.Flags&16)==0&&state.StatusAt==fuelAt&&fuelAt>=loaded
&&GameJournalClock.Offset(state,now) is {} offset&&state.StatusWrittenAt>=loaded-offset-TimeSpan.FromSeconds(1);
}
private static bool FuelAmounts(PlayerState state,out double main,out double reservoir)
{
main=state.Status?.FuelMain??double.NaN;reservoir=state.Status?.FuelReserve??double.NaN;
return double.IsFinite(main)&&main>=0&&double.IsFinite(reservoir)&&reservoir>=0;
}
public static bool FuelMassIncreased(PlayerState before,PlayerState current)
=>FuelAmounts(before,out var oldMain,out var oldReservoir)&&FuelAmounts(current,out var main,out var reservoir)
&&main+reservoir>oldMain+oldReservoir;
public static bool IsPostReceiptSample(PlayerState before,PlayerState current,DateTimeOffset submittedWall,DateTimeOffset confirmedWall,DateTimeOffset now,
bool originalFinalQuoteZero)
{
// Game timestamps are second-resolution and their calibrated offset
// changes slightly between writes. Bind this paid mutation to a new
// stable file write/read in wall time, not to a fabricated next second.
return originalFinalQuoteZero&&FuelAmounts(before,out var oldFuel,out var oldReservoir)&&FuelAmounts(current,out var fuel,out var reservoir)
&&(fuel>oldFuel||reservoir>oldReservoir)
&¤t.InGame&¤t.Docked&¤t.MarketId==before.MarketId&¤t.CommanderId==before.CommanderId
&¤t.CurrentShipId==before.CurrentShipId&¤t.LoadGameAt==before.LoadGameAt
&¤t.Status is {} status&&(status.Flags&1)!=0&&(status.Flags&16)==0
&¤t.StatusAt is {} contentAt&&FuelObservedAt(current)==contentAt
&¤t.LoadGameAt is {} loaded&&contentAt>=loaded&&confirmedWall>=submittedWall&&confirmedWall<=now.AddMilliseconds(100)
&¤t.StatusWrittenAt is {} written&&written>=submittedWall&&written>before.StatusWrittenAt
&¤t.StatusReadAt is {} read&&read>=confirmedWall&&read>=written&&read<=now.AddMilliseconds(100)&&now-read<=TimeSpan.FromSeconds(2);
}
public static async Task<DockedFuelSnapshot> ReadDockedBudgetAsync(Func<PlayerState> player,Func<CancellationToken,Task> check,
Func<double> reserve,bool interstellar,CancellationToken token,TimeSpan? timeout=null,TimeSpan? pollInterval=null)
{
using var deadline=CancellationTokenSource.CreateLinkedTokenSource(token);deadline.CancelAfter(timeout??TimeSpan.FromSeconds(10));
FlightTelemetryUnavailableException? last=null;
try
{
while(true)
{
// Safety, identity, cargo and user cancellation are outside the
// narrow fuel-read retry: those errors must remain terminal.
await check(deadline.Token);deadline.Token.ThrowIfCancellationRequested();var current=player();
try
{
var available=CurrentFuel(current,DateTimeOffset.UtcNow);
var required=DepartureBudget(current,reserve(),interstellar,DateTimeOffset.UtcNow);
return new(current,available,required);
}
catch(FlightTelemetryUnavailableException error){last=error;}
await Task.Delay(pollInterval??TimeSpan.FromMilliseconds(100),deadline.Token);
}
}
catch(OperationCanceledException)when(!token.IsCancellationRequested)
{throw new InvalidDataException("站内燃油状态未在期限内确认,已保留任务及既有补给回执,未执行离站。",last);}
}
public static double DepartureBudget(PlayerState state,double reserve,bool interstellar,DateTimeOffset now)
{
if(!double.IsFinite(reserve)||reserve<0||reserve>100)throw new InvalidDataException("自动导航燃油储备无效。");
if(!interstellar)return reserve+.25;
var ship=state.Ships.SingleOrDefault(value=>value.ShipId==state.CurrentShipId)??throw new InvalidDataException("当前飞船身份缺失,无法核对燃油预算。");
var drive=NavigationPreviewBuilder.Drive(ship)??throw new InvalidDataException("当前 FSD 装配未确认,无法核对燃油预算。");
var route=state.Navigation.Route;var index=route.ToList().FindIndex(point=>point.SystemAddress==state.Navigation.CurrentSystemAddress);
if(index<0||index>=route.Count-1)throw new InvalidDataException("当前原版航线不完整,尚未离站。");
return NavigationFuelBudget.Calculate(state,ship,drive,CurrentFuel(state,now),reserve).UntilRefuel;
}
public static Station? CaptureOrigin(PlayerState state)=>state is {Docked:true,MarketId:>0,StationServicesKnown:true}
&&state.Navigation.CurrentSystemAddress is >0&&!string.IsNullOrWhiteSpace(state.StationName)&&!string.IsNullOrWhiteSpace(state.SystemName)
&&state.StationServices.Contains("refuel")
?new(){MarketId=state.MarketId.Value,Name=state.StationName,SystemName=state.SystemName,Position=state.Position}:null;
public static bool CanReturn(PlayerState initial,PlayerState current,Station? origin,NavigationFuelResupplyRequiredException shortage,bool alreadyReturned)
=>!alreadyReturned&&origin is not null&&!current.Docked&¤t.InGame
&¤t.Navigation.CurrentSystemAddress==initial.Navigation.CurrentSystemAddress&&shortage.SystemAddress==initial.Navigation.CurrentSystemAddress
&¤t.SystemName.Equals(origin.SystemName,StringComparison.OrdinalIgnoreCase);
public static void RequireSameCargo(PlayerState initial,PlayerState current)
{
if(!initial.CargoReliable||!current.CargoReliable||initial.CargoCapacity!=current.CargoCapacity||initial.CargoCount!=current.CargoCount
||!initial.Inventory.Select(item=>(item.Key,item.Count,item.Stolen,item.MissionId)).OrderBy(item=>item.Key).ThenBy(item=>item.MissionId).ThenBy(item=>item.Stolen)
.SequenceEqual(current.Inventory.Select(item=>(item.Key,item.Count,item.Stolen,item.MissionId)).OrderBy(item=>item.Key).ThenBy(item=>item.MissionId).ThenBy(item=>item.Stolen)))
throw new InvalidDataException("补油期间实际货舱或货物身份改变,已保留原任务并停止驾驶。");
}
}
using XFEEDToolBox.Core.Models;
namespace XFEEDToolBox.Core.Navigation;
/// <summary>A confirmed fuel shortfall is a station-service task, not a transient telemetry wait.</summary>
public sealed class NavigationFuelResupplyRequiredException(long systemAddress,double available,double required)
:InvalidOperationException($"当前燃油 {available:F2} t 不足以覆盖已核对航程及储备 {required:F2} t,需要先在站点补油;原目的地已保留。")
{
public long SystemAddress{get;}=systemAddress;
public double Available{get;}=available;
public double Required{get;}=required;
}
public interface IFlightFuelPreparationDriver
{
Task<bool> PrepareDepartureFuelAsync(bool interstellar,CancellationToken token,bool refuel=true);
Task<(SystemNavigationTarget Target,ISystemFlightControlDriver Driver)> ResolveFuelReturnAsync(Station origin,CancellationToken token);
}
public sealed record DockedFuelSnapshot(PlayerState Player,double Available,double Required);
public static class NavigationFuelResupply
{
public static DateTimeOffset? FuelObservedAt(PlayerState state)=>state.Navigation.FuelAt>state.StatusAt
?state.Navigation.FuelAt:state.StatusAt??state.Navigation.FuelAt;
public static NavigationFuelResupplyRequiredException? Required(FlightFrame frame)
{
var minimum=frame.MinimumFuelToNextRefuel??frame.FuelNeededToNextRefuel;
if(frame.ScoopPowered||frame.Fsd==FsdPhase.Jumping||frame.FuelMain is not {} available||minimum is not {} required
||!double.IsFinite(available)||!double.IsFinite(required)||available<0||required<=0||available>=required)return null;
return new(frame.SystemAddress,available,required);
}
public static double CurrentFuel(PlayerState state,DateTimeOffset now)
{
var journalNewer=state.Navigation.FuelAt>state.StatusAt;
var fuel=journalNewer?state.Navigation.MainFuel:state.Status?.FuelMain??state.Navigation.MainFuel;
var at=FuelObservedAt(state);
if(fuel is not {} value||!double.IsFinite(value)||value<0||at is null
||GameJournalClock.Now(state,now)-at>TimeSpan.FromSeconds(10)&&!ReconfirmedDockedStatus(state,at.Value,now)
||at>GameJournalClock.Now(state,now).AddSeconds(5))
throw new FlightTelemetryUnavailableException("等待本次飞船的连续燃油状态,尚未离站。");
return value;
}
private static bool ReconfirmedDockedStatus(PlayerState state,DateTimeOffset fuelAt,DateTimeOffset now)
{
// An unchanged, stable Status file is reread by the monitor. Its old
// content timestamp alone is not stale while the same ship is docked.
// Never apply this exception to an old journal fuel event or in flight.
return state.InGame&&state.Docked&&state.MarketId is >0&&state.LoadGameAt is {} loaded
&&state.Status is {} status&&(status.Flags&1)!=0&&(status.Flags&16)==0&&state.StatusAt==fuelAt&&fuelAt>=loaded
&&GameJournalClock.Offset(state,now) is {} offset&&state.StatusWrittenAt>=loaded-offset-TimeSpan.FromSeconds(1);
}
private static bool FuelAmounts(PlayerState state,out double main,out double reservoir)
{
main=state.Status?.FuelMain??double.NaN;reservoir=state.Status?.FuelReserve??double.NaN;
return double.IsFinite(main)&&main>=0&&double.IsFinite(reservoir)&&reservoir>=0;
}
public static bool FuelMassIncreased(PlayerState before,PlayerState current)
=>FuelAmounts(before,out var oldMain,out var oldReservoir)&&FuelAmounts(current,out var main,out var reservoir)
&&main+reservoir>oldMain+oldReservoir;
public static bool IsPostReceiptSample(PlayerState before,PlayerState current,DateTimeOffset submittedWall,DateTimeOffset confirmedWall,DateTimeOffset now,
bool originalFinalQuoteZero)
{
// Game timestamps are second-resolution and their calibrated offset
// changes slightly between writes. Bind this paid mutation to a new
// stable file write/read in wall time, not to a fabricated next second.
return originalFinalQuoteZero&&FuelAmounts(before,out var oldFuel,out var oldReservoir)&&FuelAmounts(current,out var fuel,out var reservoir)
&&(fuel>oldFuel||reservoir>oldReservoir)
&¤t.InGame&¤t.Docked&¤t.MarketId==before.MarketId&¤t.CommanderId==before.CommanderId
&¤t.CurrentShipId==before.CurrentShipId&¤t.LoadGameAt==before.LoadGameAt
&¤t.Status is {} status&&(status.Flags&1)!=0&&(status.Flags&16)==0
&¤t.StatusAt is {} contentAt&&FuelObservedAt(current)==contentAt
&¤t.LoadGameAt is {} loaded&&contentAt>=loaded&&confirmedWall>=submittedWall&&confirmedWall<=now.AddMilliseconds(100)
&¤t.StatusWrittenAt is {} written&&written>=submittedWall&&written>before.StatusWrittenAt
&¤t.StatusReadAt is {} read&&read>=confirmedWall&&read>=written&&read<=now.AddMilliseconds(100)&&now-read<=TimeSpan.FromSeconds(2);
}
public static async Task<DockedFuelSnapshot> ReadDockedBudgetAsync(Func<PlayerState> player,Func<CancellationToken,Task> check,
Func<double> reserve,bool interstellar,CancellationToken token,TimeSpan? timeout=null,TimeSpan? pollInterval=null)
{
using var deadline=CancellationTokenSource.CreateLinkedTokenSource(token);deadline.CancelAfter(timeout??TimeSpan.FromSeconds(10));
FlightTelemetryUnavailableException? last=null;
try
{
while(true)
{
// Safety, identity, cargo and user cancellation are outside the
// narrow fuel-read retry: those errors must remain terminal.
await check(deadline.Token);deadline.Token.ThrowIfCancellationRequested();var current=player();
try
{
var available=CurrentFuel(current,DateTimeOffset.UtcNow);
var required=DepartureBudget(current,reserve(),interstellar,DateTimeOffset.UtcNow);
return new(current,available,required);
}
catch(FlightTelemetryUnavailableException error){last=error;}
await Task.Delay(pollInterval??TimeSpan.FromMilliseconds(100),deadline.Token);
}
}
catch(OperationCanceledException)when(!token.IsCancellationRequested)
{throw new InvalidDataException("站内燃油状态未在期限内确认,已保留任务及既有补给回执,未执行离站。",last);}
}
public static double DepartureBudget(PlayerState state,double reserve,bool interstellar,DateTimeOffset now)
{
if(!double.IsFinite(reserve)||reserve<0||reserve>100)throw new InvalidDataException("自动导航燃油储备无效。");
if(!interstellar)return reserve+.25;
var ship=state.Ships.SingleOrDefault(value=>value.ShipId==state.CurrentShipId)??throw new InvalidDataException("当前飞船身份缺失,无法核对燃油预算。");
var drive=NavigationPreviewBuilder.Drive(ship)??throw new InvalidDataException("当前 FSD 装配未确认,无法核对燃油预算。");
var route=state.Navigation.Route;var index=route.ToList().FindIndex(point=>point.SystemAddress==state.Navigation.CurrentSystemAddress);
if(index<0||index>=route.Count-1)throw new InvalidDataException("当前原版航线不完整,尚未离站。");
return NavigationFuelBudget.Calculate(state,ship,drive,CurrentFuel(state,now),reserve).UntilRefuel;
}
public static Station? CaptureOrigin(PlayerState state)=>state is {Docked:true,MarketId:>0,StationServicesKnown:true}
&&state.Navigation.CurrentSystemAddress is >0&&!string.IsNullOrWhiteSpace(state.StationName)&&!string.IsNullOrWhiteSpace(state.SystemName)
&&state.StationServices.Contains("refuel")
?new(){MarketId=state.MarketId.Value,Name=state.StationName,SystemName=state.SystemName,Position=state.Position}:null;
public static bool CanReturn(PlayerState initial,PlayerState current,Station? origin,NavigationFuelResupplyRequiredException shortage,bool alreadyReturned)
=>!alreadyReturned&&origin is not null&&!current.Docked&¤t.InGame
&¤t.Navigation.CurrentSystemAddress==initial.Navigation.CurrentSystemAddress&&shortage.SystemAddress==initial.Navigation.CurrentSystemAddress
&¤t.SystemName.Equals(origin.SystemName,StringComparison.OrdinalIgnoreCase);
public static void RequireSameCargo(PlayerState initial,PlayerState current)
{
if(!initial.CargoReliable||!current.CargoReliable||initial.CargoCapacity!=current.CargoCapacity||initial.CargoCount!=current.CargoCount
||!initial.Inventory.Select(item=>(item.Key,item.Count,item.Stolen,item.MissionId)).OrderBy(item=>item.Key).ThenBy(item=>item.MissionId).ThenBy(item=>item.Stolen)
.SequenceEqual(current.Inventory.Select(item=>(item.Key,item.Count,item.Stolen,item.MissionId)).OrderBy(item=>item.Key).ThenBy(item=>item.MissionId).ThenBy(item=>item.Stolen)))
throw new InvalidDataException("补油期间实际货舱或货物身份改变,已保留原任务并停止驾驶。");
}
}