using System.Text.Json;
using XFEEDToolBox.Core.Models;
using XFEEDToolBox.Core.Navigation;
namespace XFEEDToolBox.Core.Execution;
public sealed record EmptyStationValidationPlan(Station Source,Station Destination,string GameSha256,long ShipId,int CargoCapacity,
long RequiredRebuyCredits,double FuelReserveTonnes)
{
public void Validate()
{
if(Source.MarketId<=0||Destination.MarketId<=0||Source.MarketId==Destination.MarketId||string.IsNullOrWhiteSpace(Source.Name)||string.IsNullOrWhiteSpace(Source.SystemName)
||string.IsNullOrWhiteSpace(Destination.Name)||string.IsNullOrWhiteSpace(Destination.SystemName)||GameSha256.Length!=64||GameSha256.Any(c=>!Uri.IsHexDigit(c))
||ShipId<=0||CargoCapacity<=0||RequiredRebuyCredits<=0||!double.IsFinite(FuelReserveTonnes)||FuelReserveTonnes<=0)
throw new ArgumentException("One exact empty-ship route, pinned build, rebuy and fuel reserve are required.");
}
}
public sealed record EmptyStationValidationCapabilities(bool OriginalServerRead,bool OriginalScoCommands,bool OriginalScoFuelBounds,
bool OriginalInterdictionEscape,bool OriginalThrottleReset,bool OriginalDockingAi,bool SameProcessSoloRecovery,bool UsesGameSupercruiseAssist)
{
// This explicit empty trial establishes qualification. Production still
// requires the native module's separate real enable/active/disable/off qualification.
public bool OriginalScoControlQualified{get;init;}
public bool AvailableForEmptyTrial=>OriginalServerRead&&OriginalScoCommands&&OriginalScoFuelBounds&&OriginalInterdictionEscape&&OriginalThrottleReset&&OriginalDockingAi&&SameProcessSoloRecovery&&!UsesGameSupercruiseAssist;
}
public sealed record EmptyStationValidationSnapshot(int ProcessId,long ProcessStartedUtcTicks,string GameSha256,string CommanderId,long ShipId,
int CargoCapacity,int CargoCount,long Credits,bool InGame,bool Docked,long ActualMarketId,int RawDockingState,bool DockedReady,bool ServiceReady,
bool Healthy,bool ExternalThreat,bool UnknownMonetaryIntent,bool? GameScaEnabled,bool? GameScaEngaged,int ObserverPid,long ObserverStartedUtcTicks,
bool ProtectionReady,DateTimeOffset HealthAt,DateTimeOffset ObservedAt);
public sealed record EmptyStationValidationPreflight(EmptyStationValidationCapabilities Capabilities,EmptyStationValidationSnapshot Snapshot);
public sealed record EmptyStationServiceEvidence(long MarketId,long OpeningCredits,long ClosingCredits,long ConfirmedExpenses,bool OriginalFinalQuotesZero,bool UnknownIntent);
public sealed record EmptyStationValidationResult(bool Passed,string Phase,DateTimeOffset Started,DateTimeOffset Finished,bool ActualDocked,bool Maintained,
bool RecoveredSameProcess,EmptyStationFlightEvidence Flight,EmptyStationServiceEvidence? SourceServices,EmptyStationServiceEvidence? DestinationServices,
EmptyStationValidationSnapshot? FinalSnapshot,string? Failure,string? RecoveryFailure);
/// <summary>
/// One toolbox navigation backend owns all launch/jump/SCO/ATC/docking actions and its existing control lease.
/// Inspect and ReadSnapshot are read-only; missing native commands must report unavailable while still docked.
/// There is deliberately no fallback to SCA, another controller, a new observer or a replacement client.
/// </summary>
public interface IEmptyStationValidationBackend
{
Task<EmptyStationValidationPreflight> InspectDockedAsync(EmptyStationValidationPlan plan,CancellationToken token);
Task<EmptyStationServiceEvidence> MaintainAsync(long marketId,CancellationToken token);
Task NavigateAsync(Station destination,EmptyStationFlightProof proof,CancellationToken token);
Task<EmptyStationValidationSnapshot> ReadSnapshotAsync(CancellationToken token);
Task ReleaseNavigationAsync();
Task RecoverSoloSameProcessAsync(int expectedProcessId,long expectedStartedUtcTicks,CancellationToken token);
}
/// <summary>One empty validation attempt. Its future native adapter must supply real evidence before this can pass.</summary>
public sealed class EmptyStationValidationWorkflow(IEmptyStationValidationBackend backend,Func<DateTimeOffset>? clock=null)
{
private readonly Func<DateTimeOffset> now=clock??(()=>DateTimeOffset.UtcNow);
private readonly SemaphoreSlim single=new(1,1);
public event Action<string>? PhaseChanged;
private static bool Fresh(DateTimeOffset at,DateTimeOffset time,double milliseconds)=>at<=time&&time-at<=TimeSpan.FromMilliseconds(milliseconds);
private static void RequireSnapshot(EmptyStationValidationPlan plan,EmptyStationValidationSnapshot value,DateTimeOffset time,EmptyStationValidationSnapshot? identity=null)
{
if(value.ProcessId<=0||value.ProcessStartedUtcTicks<=0||value.GameSha256!=plan.GameSha256||value.ShipId!=plan.ShipId||string.IsNullOrWhiteSpace(value.CommanderId)
||value.CargoCount!=0||value.CargoCapacity!=plan.CargoCapacity||!value.InGame||!value.Healthy||value.ExternalThreat||value.UnknownMonetaryIntent
||value.Credits<plan.RequiredRebuyCredits||value.GameScaEnabled is not false||value.GameScaEngaged is not false||value.ObserverPid<=0||value.ObserverStartedUtcTicks<=0
||!value.ProtectionReady||!Fresh(value.HealthAt,time,500)||!Fresh(value.ObservedAt,time,250))
throw new InvalidDataException("The fresh original empty ship, wallet, disabled game SCA or existing independent protection is not ready.");
if(identity is {} expected&&(value.ProcessId!=expected.ProcessId||value.ProcessStartedUtcTicks!=expected.ProcessStartedUtcTicks||value.CommanderId!=expected.CommanderId
||value.ObserverPid!=expected.ObserverPid||value.ObserverStartedUtcTicks!=expected.ObserverStartedUtcTicks))
throw new InvalidDataException("The validation client, commander or existing independent observer changed.");
}
private static void RequireDocked(EmptyStationValidationSnapshot value,long market)
{
if(!value.Docked||!value.DockedReady||!value.ServiceReady||value.ActualMarketId!=market||value.RawDockingState is not(5 or 7))
throw new InvalidDataException("Actual original docked market and services are not ready.");
}
private static void RequireServices(EmptyStationServiceEvidence value,long market,long opening,long reserve)
{
if(value.MarketId!=market||value.OpeningCredits!=opening||value.ConfirmedExpenses<0||value.ClosingCredits!=value.OpeningCredits-value.ConfirmedExpenses
||value.ClosingCredits<reserve||!value.OriginalFinalQuotesZero||value.UnknownIntent)
throw new InvalidDataException("Original maintenance receipts, final zero quotes and the wallet do not reconcile.");
}
public async Task<EmptyStationValidationResult> RunAsync(EmptyStationValidationPlan plan,string directory,CancellationToken token=default)
{
plan.Validate();await single.WaitAsync(token);
var start=now();var phase="docked-preflight";EmptyStationFlightProof? proof=null;EmptyStationValidationSnapshot? initial=null,final=null;
EmptyStationServiceEvidence? before=null,after=null;string? failure=null,recoveryFailure=null;var navigationStarted=false;var docked=false;var maintained=false;var recovered=false;
FileStream? exclusive=null;var ownsDirectory=false;var released=false;
void Phase(string value){phase=value;PhaseChanged?.Invoke(value);}
try
{
directory=Path.GetFullPath(directory);
if(Directory.Exists(directory))throw new IOException("Use a fresh empty-station validation attempt; previous monetary intents cannot be replayed.");
Directory.CreateDirectory(directory);ownsDirectory=true;exclusive=new(Path.Combine(directory,"runner.lock"),FileMode.CreateNew,FileAccess.ReadWrite,FileShare.None);
await File.WriteAllTextAsync(Path.Combine(directory,"plan.json"),JsonSerializer.Serialize(plan,ToolboxSettings.JsonOptions),token);
var preflight=await backend.InspectDockedAsync(plan,token);initial=preflight.Snapshot;
RequireSnapshot(plan,initial,now());RequireDocked(initial,plan.Source.MarketId);
proof=new(initial.ProcessId,initial.ProcessStartedUtcTicks,plan.GameSha256,plan.Destination.MarketId,plan.FuelReserveTonnes);
if(!preflight.Capabilities.AvailableForEmptyTrial)throw new NotSupportedException("Required original SCO, fuel range, escape or docking interfaces are unavailable; the empty ship remains docked.");
Phase("source-maintenance");before=await backend.MaintainAsync(plan.Source.MarketId,token);RequireServices(before,plan.Source.MarketId,initial.Credits,plan.RequiredRebuyCredits);
var prepared=await backend.ReadSnapshotAsync(token);RequireSnapshot(plan,prepared,now(),initial);RequireDocked(prepared,plan.Source.MarketId);
if(prepared.Credits!=before.ClosingCredits)throw new InvalidDataException("The original departure wallet changed after maintenance.");
token.ThrowIfCancellationRequested();Phase("single-native-navigation");navigationStarted=true;
await backend.NavigateAsync(plan.Destination,proof,token);
final=await backend.ReadSnapshotAsync(token);RequireSnapshot(plan,final,now(),initial);RequireDocked(final,plan.Destination.MarketId);docked=true;
// Even incomplete flight evidence does not skip supported services at an actually reached station.
Phase("destination-maintenance");after=await backend.MaintainAsync(plan.Destination.MarketId,token);RequireServices(after,plan.Destination.MarketId,before.ClosingCredits,plan.RequiredRebuyCredits);
final=await backend.ReadSnapshotAsync(token);RequireSnapshot(plan,final,now(),initial);RequireDocked(final,plan.Destination.MarketId);
if(final.Credits!=after.ClosingCredits)throw new InvalidDataException("Final original maintenance and wallet differ.");
maintained=true;proof.RequireComplete();Phase("passed");
}
catch(Exception error)
{
failure=error.ToString();Phase("failed-preserved");
if(navigationStarted&&initial is not null&&!token.IsCancellationRequested)
{
try
{
await backend.ReleaseNavigationAsync();released=true;var current=await backend.ReadSnapshotAsync(CancellationToken.None);RequireSnapshot(plan,current,now(),initial);
if(!current.Docked)
{
Phase("same-process-original-solo-recovery");await backend.RecoverSoloSameProcessAsync(initial.ProcessId,initial.ProcessStartedUtcTicks,CancellationToken.None);
var restored=await backend.ReadSnapshotAsync(CancellationToken.None);RequireSnapshot(plan,restored,now(),initial);recovered=true;
}
}
catch(Exception recovery){recoveryFailure=recovery.ToString();}
}
}
finally
{
try{if(navigationStarted&&!released)await backend.ReleaseNavigationAsync();}catch(Exception release){failure??=release.ToString();Phase("failed-release");}
exclusive?.Dispose();single.Release();
}
var empty=new EmptyStationFlightEvidence(false,false,false,false,0,0,false,false,false,false,null,null,[],[]);
var result=new EmptyStationValidationResult(failure is null&&docked&&maintained,phase,start,now(),docked,maintained,recovered,proof?.Snapshot()??empty,before,after,final,failure,recoveryFailure);
if(ownsDirectory)
{
var path=Path.Combine(directory,"result.json");var temporary=path+"."+Guid.NewGuid().ToString("N")+".tmp";
await File.WriteAllTextAsync(temporary,JsonSerializer.Serialize(result,ToolboxSettings.JsonOptions),CancellationToken.None);File.Move(temporary,path,false);
}
return result;
}
}
using System.Text.Json;
using XFEEDToolBox.Core.Models;
using XFEEDToolBox.Core.Navigation;
namespace XFEEDToolBox.Core.Execution;
public sealed record EmptyStationValidationPlan(Station Source,Station Destination,string GameSha256,long ShipId,int CargoCapacity,
long RequiredRebuyCredits,double FuelReserveTonnes)
{
public void Validate()
{
if(Source.MarketId<=0||Destination.MarketId<=0||Source.MarketId==Destination.MarketId||string.IsNullOrWhiteSpace(Source.Name)||string.IsNullOrWhiteSpace(Source.SystemName)
||string.IsNullOrWhiteSpace(Destination.Name)||string.IsNullOrWhiteSpace(Destination.SystemName)||GameSha256.Length!=64||GameSha256.Any(c=>!Uri.IsHexDigit(c))
||ShipId<=0||CargoCapacity<=0||RequiredRebuyCredits<=0||!double.IsFinite(FuelReserveTonnes)||FuelReserveTonnes<=0)
throw new ArgumentException("One exact empty-ship route, pinned build, rebuy and fuel reserve are required.");
}
}
public sealed record EmptyStationValidationCapabilities(bool OriginalServerRead,bool OriginalScoCommands,bool OriginalScoFuelBounds,
bool OriginalInterdictionEscape,bool OriginalThrottleReset,bool OriginalDockingAi,bool SameProcessSoloRecovery,bool UsesGameSupercruiseAssist)
{
// This explicit empty trial establishes qualification. Production still
// requires the native module's separate real enable/active/disable/off qualification.
public bool OriginalScoControlQualified{get;init;}
public bool AvailableForEmptyTrial=>OriginalServerRead&&OriginalScoCommands&&OriginalScoFuelBounds&&OriginalInterdictionEscape&&OriginalThrottleReset&&OriginalDockingAi&&SameProcessSoloRecovery&&!UsesGameSupercruiseAssist;
}
public sealed record EmptyStationValidationSnapshot(int ProcessId,long ProcessStartedUtcTicks,string GameSha256,string CommanderId,long ShipId,
int CargoCapacity,int CargoCount,long Credits,bool InGame,bool Docked,long ActualMarketId,int RawDockingState,bool DockedReady,bool ServiceReady,
bool Healthy,bool ExternalThreat,bool UnknownMonetaryIntent,bool? GameScaEnabled,bool? GameScaEngaged,int ObserverPid,long ObserverStartedUtcTicks,
bool ProtectionReady,DateTimeOffset HealthAt,DateTimeOffset ObservedAt);
public sealed record EmptyStationValidationPreflight(EmptyStationValidationCapabilities Capabilities,EmptyStationValidationSnapshot Snapshot);
public sealed record EmptyStationServiceEvidence(long MarketId,long OpeningCredits,long ClosingCredits,long ConfirmedExpenses,bool OriginalFinalQuotesZero,bool UnknownIntent);
public sealed record EmptyStationValidationResult(bool Passed,string Phase,DateTimeOffset Started,DateTimeOffset Finished,bool ActualDocked,bool Maintained,
bool RecoveredSameProcess,EmptyStationFlightEvidence Flight,EmptyStationServiceEvidence? SourceServices,EmptyStationServiceEvidence? DestinationServices,
EmptyStationValidationSnapshot? FinalSnapshot,string? Failure,string? RecoveryFailure);
/// <summary>
/// One toolbox navigation backend owns all launch/jump/SCO/ATC/docking actions and its existing control lease.
/// Inspect and ReadSnapshot are read-only; missing native commands must report unavailable while still docked.
/// There is deliberately no fallback to SCA, another controller, a new observer or a replacement client.
/// </summary>
public interface IEmptyStationValidationBackend
{
Task<EmptyStationValidationPreflight> InspectDockedAsync(EmptyStationValidationPlan plan,CancellationToken token);
Task<EmptyStationServiceEvidence> MaintainAsync(long marketId,CancellationToken token);
Task NavigateAsync(Station destination,EmptyStationFlightProof proof,CancellationToken token);
Task<EmptyStationValidationSnapshot> ReadSnapshotAsync(CancellationToken token);
Task ReleaseNavigationAsync();
Task RecoverSoloSameProcessAsync(int expectedProcessId,long expectedStartedUtcTicks,CancellationToken token);
}
/// <summary>One empty validation attempt. Its future native adapter must supply real evidence before this can pass.</summary>
public sealed class EmptyStationValidationWorkflow(IEmptyStationValidationBackend backend,Func<DateTimeOffset>? clock=null)
{
private readonly Func<DateTimeOffset> now=clock??(()=>DateTimeOffset.UtcNow);
private readonly SemaphoreSlim single=new(1,1);
public event Action<string>? PhaseChanged;
private static bool Fresh(DateTimeOffset at,DateTimeOffset time,double milliseconds)=>at<=time&&time-at<=TimeSpan.FromMilliseconds(milliseconds);
private static void RequireSnapshot(EmptyStationValidationPlan plan,EmptyStationValidationSnapshot value,DateTimeOffset time,EmptyStationValidationSnapshot? identity=null)
{
if(value.ProcessId<=0||value.ProcessStartedUtcTicks<=0||value.GameSha256!=plan.GameSha256||value.ShipId!=plan.ShipId||string.IsNullOrWhiteSpace(value.CommanderId)
||value.CargoCount!=0||value.CargoCapacity!=plan.CargoCapacity||!value.InGame||!value.Healthy||value.ExternalThreat||value.UnknownMonetaryIntent
||value.Credits<plan.RequiredRebuyCredits||value.GameScaEnabled is not false||value.GameScaEngaged is not false||value.ObserverPid<=0||value.ObserverStartedUtcTicks<=0
||!value.ProtectionReady||!Fresh(value.HealthAt,time,500)||!Fresh(value.ObservedAt,time,250))
throw new InvalidDataException("The fresh original empty ship, wallet, disabled game SCA or existing independent protection is not ready.");
if(identity is {} expected&&(value.ProcessId!=expected.ProcessId||value.ProcessStartedUtcTicks!=expected.ProcessStartedUtcTicks||value.CommanderId!=expected.CommanderId
||value.ObserverPid!=expected.ObserverPid||value.ObserverStartedUtcTicks!=expected.ObserverStartedUtcTicks))
throw new InvalidDataException("The validation client, commander or existing independent observer changed.");
}
private static void RequireDocked(EmptyStationValidationSnapshot value,long market)
{
if(!value.Docked||!value.DockedReady||!value.ServiceReady||value.ActualMarketId!=market||value.RawDockingState is not(5 or 7))
throw new InvalidDataException("Actual original docked market and services are not ready.");
}
private static void RequireServices(EmptyStationServiceEvidence value,long market,long opening,long reserve)
{
if(value.MarketId!=market||value.OpeningCredits!=opening||value.ConfirmedExpenses<0||value.ClosingCredits!=value.OpeningCredits-value.ConfirmedExpenses
||value.ClosingCredits<reserve||!value.OriginalFinalQuotesZero||value.UnknownIntent)
throw new InvalidDataException("Original maintenance receipts, final zero quotes and the wallet do not reconcile.");
}
public async Task<EmptyStationValidationResult> RunAsync(EmptyStationValidationPlan plan,string directory,CancellationToken token=default)
{
plan.Validate();await single.WaitAsync(token);
var start=now();var phase="docked-preflight";EmptyStationFlightProof? proof=null;EmptyStationValidationSnapshot? initial=null,final=null;
EmptyStationServiceEvidence? before=null,after=null;string? failure=null,recoveryFailure=null;var navigationStarted=false;var docked=false;var maintained=false;var recovered=false;
FileStream? exclusive=null;var ownsDirectory=false;var released=false;
void Phase(string value){phase=value;PhaseChanged?.Invoke(value);}
try
{
directory=Path.GetFullPath(directory);
if(Directory.Exists(directory))throw new IOException("Use a fresh empty-station validation attempt; previous monetary intents cannot be replayed.");
Directory.CreateDirectory(directory);ownsDirectory=true;exclusive=new(Path.Combine(directory,"runner.lock"),FileMode.CreateNew,FileAccess.ReadWrite,FileShare.None);
await File.WriteAllTextAsync(Path.Combine(directory,"plan.json"),JsonSerializer.Serialize(plan,ToolboxSettings.JsonOptions),token);
var preflight=await backend.InspectDockedAsync(plan,token);initial=preflight.Snapshot;
RequireSnapshot(plan,initial,now());RequireDocked(initial,plan.Source.MarketId);
proof=new(initial.ProcessId,initial.ProcessStartedUtcTicks,plan.GameSha256,plan.Destination.MarketId,plan.FuelReserveTonnes);
if(!preflight.Capabilities.AvailableForEmptyTrial)throw new NotSupportedException("Required original SCO, fuel range, escape or docking interfaces are unavailable; the empty ship remains docked.");
Phase("source-maintenance");before=await backend.MaintainAsync(plan.Source.MarketId,token);RequireServices(before,plan.Source.MarketId,initial.Credits,plan.RequiredRebuyCredits);
var prepared=await backend.ReadSnapshotAsync(token);RequireSnapshot(plan,prepared,now(),initial);RequireDocked(prepared,plan.Source.MarketId);
if(prepared.Credits!=before.ClosingCredits)throw new InvalidDataException("The original departure wallet changed after maintenance.");
token.ThrowIfCancellationRequested();Phase("single-native-navigation");navigationStarted=true;
await backend.NavigateAsync(plan.Destination,proof,token);
final=await backend.ReadSnapshotAsync(token);RequireSnapshot(plan,final,now(),initial);RequireDocked(final,plan.Destination.MarketId);docked=true;
// Even incomplete flight evidence does not skip supported services at an actually reached station.
Phase("destination-maintenance");after=await backend.MaintainAsync(plan.Destination.MarketId,token);RequireServices(after,plan.Destination.MarketId,before.ClosingCredits,plan.RequiredRebuyCredits);
final=await backend.ReadSnapshotAsync(token);RequireSnapshot(plan,final,now(),initial);RequireDocked(final,plan.Destination.MarketId);
if(final.Credits!=after.ClosingCredits)throw new InvalidDataException("Final original maintenance and wallet differ.");
maintained=true;proof.RequireComplete();Phase("passed");
}
catch(Exception error)
{
failure=error.ToString();Phase("failed-preserved");
if(navigationStarted&&initial is not null&&!token.IsCancellationRequested)
{
try
{
await backend.ReleaseNavigationAsync();released=true;var current=await backend.ReadSnapshotAsync(CancellationToken.None);RequireSnapshot(plan,current,now(),initial);
if(!current.Docked)
{
Phase("same-process-original-solo-recovery");await backend.RecoverSoloSameProcessAsync(initial.ProcessId,initial.ProcessStartedUtcTicks,CancellationToken.None);
var restored=await backend.ReadSnapshotAsync(CancellationToken.None);RequireSnapshot(plan,restored,now(),initial);recovered=true;
}
}
catch(Exception recovery){recoveryFailure=recovery.ToString();}
}
}
finally
{
try{if(navigationStarted&&!released)await backend.ReleaseNavigationAsync();}catch(Exception release){failure??=release.ToString();Phase("failed-release");}
exclusive?.Dispose();single.Release();
}
var empty=new EmptyStationFlightEvidence(false,false,false,false,0,0,false,false,false,false,null,null,[],[]);
var result=new EmptyStationValidationResult(failure is null&&docked&&maintained,phase,start,now(),docked,maintained,recovered,proof?.Snapshot()??empty,before,after,final,failure,recoveryFailure);
if(ownsDirectory)
{
var path=Path.Combine(directory,"result.json");var temporary=path+"."+Guid.NewGuid().ToString("N")+".tmp";
await File.WriteAllTextAsync(temporary,JsonSerializer.Serialize(result,ToolboxSettings.JsonOptions),CancellationToken.None);File.Move(temporary,path,false);
}
return result;
}
}