using XFEEDToolBox.Core.Models;
using XFEEDToolBox.Core.Navigation;
using XFEExtension.NetCore.XUnit.Attributes;
namespace XFEEDToolBox.Test;
[TestFixture]
public sealed class StationDepartureTakeoverPolicyTests
{
private static readonly DateTimeOffset Now=DateTimeOffset.UtcNow;
private static readonly SystemNavigationTarget Origin=new(1,2,"Origin station",SystemTargetKind.Station,3);
private static PlayerState Player=>new(){InGame=true,CommanderId="F1",CurrentShipId=7,LoadGameAt=Now.AddMinutes(-5),
Navigation=new(){UndockedAt=Now.AddSeconds(-30)}};
private static SystemFlightFrame Frame=>new(){Sequence=10,ObservedAt=Now,ShipEntity=4,ReferenceWorld=5,SystemAddress=1,SelectedTarget=Origin,
GeometryVerified=true,InGame=true,Mode=LocalFlightMode.Normal,FsdReady=true,Position=new(0,0,9000),TargetPosition=Vector3d.Zero,Forward=new(0,0,1),Up=new(0,1,0)};
private static StationDepartureAction Decide(SystemFlightFrame? frame=null,PlayerState? player=null,TimeSpan? elapsed=null,
bool observed=true,bool active=false,bool requested=false,bool station=true,DateTimeOffset? requestedAt=null,bool duty=false)
=>StationDepartureTakeoverPolicy.Decide(frame??Frame,Origin,player??Player,requestedAt??Now.AddMinutes(-1),elapsed??TimeSpan.FromSeconds(60),Now,observed,active,requested,duty,station);
[Test]public void CompletedDepartureDoesNotRequireHavingSeenTheBriefNativeActiveFrame()
{
Check.Equal(StationDepartureAction.Completed,Decide());
// The old docking object may be released after leaving the station;
// actual native inactive flags and mass-lock release still establish completion.
Check.Equal(StationDepartureAction.Completed,Decide(station:false));
Check.Equal(StationDepartureAction.Completed,Decide(elapsed:StationDepartureWatchdog.RecoveryAfter));
}
[Test]public void AStillActiveOriginalDutyIsCancelledOnlyAfterTheStationIsClear()
{
Check.Equal(StationDepartureAction.WaitForNative,Decide(active:true));
Check.Equal(StationDepartureAction.CancelNativeForTakeover,Decide(active:true,elapsed:StationDepartureTakeoverPolicy.TakeoverAfter));
Check.Equal(StationDepartureAction.CancelNativeForTakeover,Decide(requested:true,elapsed:StationDepartureTakeoverPolicy.TakeoverAfter));
Check.Equal(StationDepartureAction.WaitForNative,Decide(Frame with{FsdReady=false},active:true,elapsed:StationDepartureTakeoverPolicy.TakeoverAfter));
Check.Equal(StationDepartureAction.WaitForNative,Decide(active:true,station:false,elapsed:StationDepartureTakeoverPolicy.TakeoverAfter));
Check.That(StationDepartureTakeoverPolicy.TakeoverAfter<StationDepartureWatchdog.RecoveryAfter);
}
[Test]public void InsideTheStationNoRadialEscapeIsInventedAndRecoveryPrecedesThePenalty()
{
foreach(var frame in new[]{Frame with{FsdReady=false},Frame with{Mode=LocalFlightMode.Docked},Frame with{GeometryVerified=false},Frame with{ObservedAt=Now.AddSeconds(-1)}})
{
Check.Equal(StationDepartureAction.WaitForNative,Decide(frame,active:true,elapsed:StationDepartureTakeoverPolicy.TakeoverAfter));
Check.Equal(StationDepartureAction.RecoverSession,Decide(frame,active:true,elapsed:StationDepartureWatchdog.RecoveryAfter));
}
Check.That(TimeSpan.FromMinutes(5)-StationDepartureWatchdog.RecoveryAfter>=TimeSpan.FromMinutes(1));
}
[Test]public void WrongTargetOldUndockAndUnverifiedNativeFlagsCannotClaimAuthority()
{
Check.Equal(StationDepartureAction.WaitForNative,Decide(Frame with{SelectedTarget=Origin with{MarketId=99}}));
Check.Equal(StationDepartureAction.WaitForNative,Decide(Frame with{SystemAddress=99}));
Check.Equal(StationDepartureAction.WaitForNative,Decide(Frame with{ShipEntity=0}));
Check.Equal(StationDepartureAction.WaitForNative,Decide(Frame with{Velocity=new(double.NaN,0,0)}));
Check.Equal(StationDepartureAction.WaitForNative,Decide(observed:false));
Check.Equal(StationDepartureAction.WaitForNative,Decide(player:Player with{InGame=false}));
Check.Equal(StationDepartureAction.WaitForNative,Decide(player:Player with{Docked=true}));
Check.Equal(StationDepartureAction.WaitForNative,Decide(player:Player with{Navigation=Player.Navigation with{UndockedAt=Now.AddMinutes(-2)}}));
Check.Equal(StationDepartureAction.WaitForNative,Decide(player:Player with{Navigation=Player.Navigation with{UndockedAt=Now.AddSeconds(6)}}));
}
[Test]public void JournalLeadUsesTheVerifiedClockAndKeepsNativeFreshnessOnWallTime()
{
var player=Player with{Status=new(16777232,0,32,1,0,null,null,null,null,[]),StatusAt=Now.AddSeconds(6.464),StatusWrittenAt=Now,StatusReadAt=Now,
Navigation=Player.Navigation with{UndockedAt=Now.AddSeconds(6.464)}};
Check.Equal(StationDepartureAction.Completed,Decide(player:player,requestedAt:Now.AddSeconds(5)));
Check.Equal(StationDepartureAction.WaitForNative,Decide(Frame with{ObservedAt=Now.AddSeconds(6)},player:player,requestedAt:Now.AddSeconds(5)));
Check.Equal(StationDepartureAction.WaitForNative,Decide(player:player with{StatusWrittenAt=null},requestedAt:Now.AddSeconds(5)));
var behind=player with{StatusAt=Now.AddSeconds(-6.464),Navigation=Player.Navigation with{UndockedAt=Now.AddSeconds(-6.464)}};
Check.Equal(StationDepartureAction.Completed,Decide(player:behind,requestedAt:Now.AddSeconds(-7)));
}
[Test]public void ManualTakeoverWinsEvenAtTheRecoveryDeadline()
{
Check.Equal(StationDepartureAction.ManualTakeover,Decide(Frame with{PlayerOverride=true},active:true,elapsed:StationDepartureWatchdog.RecoveryAfter));
}
private static StationDeparturePathEvidence ObserveOriginalExit(SystemFlightFrame end,PlayerState? player=null)
{
var proof=new StationDeparturePathEvidence();var state=player??Player;
for(var index=0;index<4;index++)
{
var at=Now.AddMilliseconds(-800+index*200);
var frame=end with{Sequence=index+1,ObservedAt=at,Position=end.Position-new Vector3d(0,0,100-index*30),Velocity=new(0,0,150)};
proof.Observe(frame,Origin,state,Now.AddMinutes(-1),at,true,true,false,false);
}
proof.Observe(end,Origin,state,Now.AddMinutes(-1),Now,true,false,false,false);
return proof;
}
private static StationDepartureAction WithPath(SystemFlightFrame frame,StationDeparturePathEvidence proof,TimeSpan? elapsed=null,PlayerState? player=null)
=>StationDepartureTakeoverPolicy.Decide(frame,Origin,player??Player,Now.AddMinutes(-1),elapsed??TimeSpan.FromSeconds(80),Now,true,false,false,false,true,proof);
[Test]public void OtherOriginalDutyCannotCompleteContinueOrBeCancelledAsAnOwnedLaunch()
{
Check.Equal(StationDepartureAction.WaitForNative,Decide(duty:true));
Check.Equal(StationDepartureAction.WaitForNative,Decide(duty:true,elapsed:StationDepartureTakeoverPolicy.TakeoverAfter));
var frame=Frame with{Sequence=5,FsdReady=false,Position=new(0,0,4186),TargetExclusionRadius=3100};var proof=ObserveOriginalExit(frame);
Check.Equal(StationDepartureAction.WaitForNative,StationDepartureTakeoverPolicy.Decide(frame,Origin,Player,Now.AddMinutes(-1),TimeSpan.FromSeconds(80),Now,true,false,false,true,true,proof));
foreach(var ready in new[]{false,true})
Check.Equal(StationDepartureAction.WaitForNative,StationDepartureTakeoverPolicy.Decide(frame with{FsdReady=ready},Origin,Player,Now.AddMinutes(-1),TimeSpan.FromSeconds(130),Now,false,false,false,false,true,proof));
}
[Test]public void OriginalNaturalExitAt4186MetresContinuesStraightWhileMassLockedAndOnlyCompletesWhenFsdReady()
{
var frame=Frame with{Sequence=5,FsdReady=false,Position=new(0,0,4186),TargetDistance=4186,Speed=0,Throttle=0,TargetExclusionRadius=3100};
var state=Player with{Status=new(0x1010000,0,32,1,0,null,null,null,null,[])};
var proof=ObserveOriginalExit(frame,state);
Check.That(proof.OriginalDutyObserved&&proof.OriginalDutyNaturallyEnded&&proof.PathVerified);
Check.Equal(StationDepartureAction.ContinueVerifiedDeparture,WithPath(frame,proof,player:state));
Check.Equal(StationDepartureAction.Completed,WithPath(frame with{FsdReady=true},proof,player:state));
Check.Equal(StationDepartureAction.RecoverSession,WithPath(frame,proof,StationDepartureWatchdog.RecoveryAfter,state));
Check.Equal(.5,StationDeparturePathEvidence.ContinuationThrottle);
}
[Test]public void ContinuationUsesOriginalStationEnvelopeRatherThanAnArbitraryThreeKilometreRule()
{
var small=Frame with{Sequence=5,FsdReady=false,Position=new(0,0,500),TargetExclusionRadius=350};
Check.Equal(StationDepartureAction.ContinueVerifiedDeparture,WithPath(small,ObserveOriginalExit(small)));
var large=Frame with{Sequence=5,FsdReady=false,Position=new(0,0,4186),TargetExclusionRadius=5000};
Check.Equal(StationDepartureAction.WaitForNative,WithPath(large,ObserveOriginalExit(large)));
var unknown=small with{TargetExclusionRadius=null};
Check.Equal(StationDepartureAction.WaitForNative,WithPath(unknown,ObserveOriginalExit(unknown)));
}
[Test]public void ColdInactiveCancelledAndUnobservedLaunchCannotInventAnExit()
{
var frame=Frame with{Sequence=5,FsdReady=false,Position=new(0,0,4186),TargetExclusionRadius=3100};
var cold=new StationDeparturePathEvidence();cold.Observe(frame,Origin,Player,Now.AddMinutes(-1),Now,true,false,false,false);
Check.Equal(StationDepartureAction.WaitForNative,WithPath(frame,cold));
var cancelled=ObserveOriginalExit(frame);
cancelled.Observe(frame with{Sequence=6,ObservedAt=Now.AddMilliseconds(80)},Origin,Player,Now.AddMinutes(-1),Now.AddMilliseconds(80),true,false,false,true);
Check.That(!cancelled.CanContinue(frame,Now));
Check.Equal(StationDepartureAction.WaitForNative,Decide(frame));
}
[Test]public void ProvenExitAuthorityIsWithdrawnOnAHeadingChangeReverseMotionEntityChangeOrStaleGeometry()
{
var end=Frame with{Sequence=5,FsdReady=false,Position=new(0,0,4186),TargetExclusionRadius=3100};
foreach(var changed in new[]{end with{Forward=new(0,0,-1)},end with{Position=new(0,0,4180)},end with{Position=new(3,0,4188)},
end with{ShipEntity=99},end with{GeometryVerified=false},end with{TargetExclusionRadius=null},end with{Velocity=new(0,0,-10)}})
{
var proof=ObserveOriginalExit(end);var next=changed with{Sequence=6,ObservedAt=Now.AddMilliseconds(80)};
proof.Observe(next,Origin,Player,Now.AddMinutes(-1),next.ObservedAt,true,false,false,false);
Check.That(!proof.CanContinue(next,next.ObservedAt));
}
var stale=ObserveOriginalExit(end);var gap=end with{Sequence=6,ObservedAt=Now.AddSeconds(2)};
stale.Observe(gap,Origin,Player,Now.AddMinutes(-1),gap.ObservedAt,true,false,false,false);
Check.That(!stale.CanContinue(gap,gap.ObservedAt));
Check.Equal(StationDepartureAction.ManualTakeover,WithPath(end with{PlayerOverride=true},ObserveOriginalExit(end)));
}
private static (StationDeparturePathEvidence Proof,SystemFlightFrame Last) ActiveOutsideExit()
{
var proof=new StationDeparturePathEvidence();SystemFlightFrame last=Frame;
for(var index=0;index<4;index++)
{
last=Frame with{Sequence=index+1,ObservedAt=Now.AddMilliseconds(-600+index*200),FsdReady=false,
Position=new(0,0,3386.5+index*20),TargetDistance=3386.5+index*20,TargetExclusionRadius=3000,Speed=99,Velocity=new(0,0,99)};
proof.Observe(last,Origin,Player,Now.AddMinutes(-1),last.ObservedAt,true,true,false,false);
}
Check.That(proof.PathVerified);return(proof,last);
}
[Test]public void RepeatedPhysicsPublicationDoesNotRevokeOrInventANaturalDutyTransition()
{
var (proof,last)=ActiveOutsideExit();
for(var index=0;index<10;index++)proof.Observe(last,Origin,Player,Now.AddMinutes(-1),Now,true,true,false,false);
proof.Observe(last,Origin,Player,Now.AddMinutes(-1),Now,true,false,false,false);
Check.That(proof.PathVerified&&!proof.OriginalDutyNaturallyEnded);Check.Equal<string?>(null,proof.WithdrawalReason);
var end=last with{Sequence=5,ObservedAt=Now.AddMilliseconds(200),Position=last.Position+new Vector3d(0,0,20),Velocity=Vector3d.Zero,Speed=0};
proof.Observe(end,Origin,Player,Now.AddMinutes(-1),end.ObservedAt,true,false,false,false);
Check.That(proof.OriginalDutyNaturallyEnded&&proof.CanContinue(end,end.ObservedAt));
}
[Test]public void BoundedGapDuringOriginalFlightRebuildsAContinuousSegmentBeforeTheNaturalExit()
{
var (proof,last)=ActiveOutsideExit();
// Actual round-15 diagnostic endpoints were 3446.50 -> 3651.24 m,
// with the original launch still active. The logger's two-second
// cadence is not evidence that every underlying poll had this gap.
var resumed=last with{Sequence=5,ObservedAt=Now.AddMilliseconds(2068),Position=new(0,0,3651.24),TargetDistance=3651.24};
proof.Observe(resumed,Origin,Player,Now.AddMinutes(-1),resumed.ObservedAt,true,true,false,false);
Check.That(!proof.PathVerified&&!proof.OriginalDutyNaturallyEnded);Check.Equal<string?>(null,proof.WithdrawalReason);
for(var index=1;index<=3;index++)
{
var sample=resumed with{Sequence=5+index,ObservedAt=resumed.ObservedAt.AddMilliseconds(index*200),Position=resumed.Position+new Vector3d(0,0,index*20)};
proof.Observe(sample,Origin,Player,Now.AddMinutes(-1),sample.ObservedAt,true,true,false,false);
Check.Equal(index==3,proof.PathVerified);
}
var end=resumed with{Sequence=9,ObservedAt=resumed.ObservedAt.AddMilliseconds(800),Position=resumed.Position+new Vector3d(0,0,80),Speed=0,Velocity=Vector3d.Zero};
proof.Observe(end,Origin,Player,Now.AddMinutes(-1),end.ObservedAt,true,false,false,false);
Check.That(proof.CanContinue(end,end.ObservedAt));
}
[Test]public void MissingExitSamplesCannotBeReplacedByAStationaryInactiveEndpointOrALongGap()
{
foreach(var seconds in new[]{2d,6d})
{
var (proof,last)=ActiveOutsideExit();var resumed=last with{Sequence=5,ObservedAt=Now.AddSeconds(seconds),Position=last.Position+new Vector3d(0,0,100)};
proof.Observe(resumed,Origin,Player,Now.AddMinutes(-1),resumed.ObservedAt,true,true,false,false);
var end=resumed with{Sequence=6,ObservedAt=resumed.ObservedAt.AddMilliseconds(80),Speed=0,Velocity=Vector3d.Zero};
proof.Observe(end,Origin,Player,Now.AddMinutes(-1),end.ObservedAt,true,false,false,false);
Check.That(!proof.CanContinue(end,end.ObservedAt));Check.That(proof.WithdrawalReason is not null);
}
}
[Test]public void AnOutsideGapWithReverseLateralOrHeadingDiscontinuityPermanentlyWithdrawsAuthority()
{
foreach(var cause in new[]{"reverse","lateral","heading","velocity"})
{
var (proof,last)=ActiveOutsideExit();var resumed=last with{Sequence=5,ObservedAt=Now.AddSeconds(2),Position=last.Position+new Vector3d(0,0,100)};
resumed=cause switch
{
"reverse"=>resumed with{Position=last.Position-new Vector3d(0,0,2)},
"lateral"=>resumed with{Position=resumed.Position+new Vector3d(3,0,0)},
"heading"=>resumed with{Forward=new(0,0,-1)},
_=>resumed with{Velocity=new(0,0,-10)}
};
proof.Observe(resumed,Origin,Player,Now.AddMinutes(-1),resumed.ObservedAt,true,true,false,false);
Check.That(!proof.PathVerified);Check.Equal("unsafe-outside-path-across-observation-gap",proof.WithdrawalReason);
}
}
[Test]public void DuplicateHandlingNeverMasksManualDamageIdentityOrConflictingPose()
{
foreach(var cause in new[]{"manual","damage","commander","ship","session","entity","target","conflicting-pose","reordered"})
{
var (proof,last)=ActiveOutsideExit();var state=Player;var sample=last;
switch(cause)
{
case "manual":sample=sample with{PlayerOverride=true};break;
case "damage":state=state with{Ships=[new(){ShipId=7,HullHealth=0}]};break;
case "commander":state=state with{CommanderId="other"};break;
case "ship":state=state with{CurrentShipId=8};break;
case "session":state=state with{LoadGameAt=Now.AddMinutes(-2)};break;
case "entity":sample=sample with{ShipEntity=99};break;
case "target":sample=sample with{SelectedTarget=Origin with{MarketId=99}};break;
case "conflicting-pose":sample=sample with{Position=sample.Position+new Vector3d(1,0,0)};break;
case "reordered":sample=sample with{Sequence=3,ObservedAt=Now.AddMilliseconds(-80)};break;
}
proof.Observe(sample,Origin,state,Now.AddMinutes(-1),Now,true,true,false,false);
Check.That(!proof.PathVerified&&proof.WithdrawalReason is not null);
}
}
}
using XFEEDToolBox.Core.Models;
using XFEEDToolBox.Core.Navigation;
using XFEExtension.NetCore.XUnit.Attributes;
namespace XFEEDToolBox.Test;
[TestFixture]
public sealed class StationDepartureTakeoverPolicyTests
{
private static readonly DateTimeOffset Now=DateTimeOffset.UtcNow;
private static readonly SystemNavigationTarget Origin=new(1,2,"Origin station",SystemTargetKind.Station,3);
private static PlayerState Player=>new(){InGame=true,CommanderId="F1",CurrentShipId=7,LoadGameAt=Now.AddMinutes(-5),
Navigation=new(){UndockedAt=Now.AddSeconds(-30)}};
private static SystemFlightFrame Frame=>new(){Sequence=10,ObservedAt=Now,ShipEntity=4,ReferenceWorld=5,SystemAddress=1,SelectedTarget=Origin,
GeometryVerified=true,InGame=true,Mode=LocalFlightMode.Normal,FsdReady=true,Position=new(0,0,9000),TargetPosition=Vector3d.Zero,Forward=new(0,0,1),Up=new(0,1,0)};
private static StationDepartureAction Decide(SystemFlightFrame? frame=null,PlayerState? player=null,TimeSpan? elapsed=null,
bool observed=true,bool active=false,bool requested=false,bool station=true,DateTimeOffset? requestedAt=null,bool duty=false)
=>StationDepartureTakeoverPolicy.Decide(frame??Frame,Origin,player??Player,requestedAt??Now.AddMinutes(-1),elapsed??TimeSpan.FromSeconds(60),Now,observed,active,requested,duty,station);
[Test]public void CompletedDepartureDoesNotRequireHavingSeenTheBriefNativeActiveFrame()
{
Check.Equal(StationDepartureAction.Completed,Decide());
// The old docking object may be released after leaving the station;
// actual native inactive flags and mass-lock release still establish completion.
Check.Equal(StationDepartureAction.Completed,Decide(station:false));
Check.Equal(StationDepartureAction.Completed,Decide(elapsed:StationDepartureWatchdog.RecoveryAfter));
}
[Test]public void AStillActiveOriginalDutyIsCancelledOnlyAfterTheStationIsClear()
{
Check.Equal(StationDepartureAction.WaitForNative,Decide(active:true));
Check.Equal(StationDepartureAction.CancelNativeForTakeover,Decide(active:true,elapsed:StationDepartureTakeoverPolicy.TakeoverAfter));
Check.Equal(StationDepartureAction.CancelNativeForTakeover,Decide(requested:true,elapsed:StationDepartureTakeoverPolicy.TakeoverAfter));
Check.Equal(StationDepartureAction.WaitForNative,Decide(Frame with{FsdReady=false},active:true,elapsed:StationDepartureTakeoverPolicy.TakeoverAfter));
Check.Equal(StationDepartureAction.WaitForNative,Decide(active:true,station:false,elapsed:StationDepartureTakeoverPolicy.TakeoverAfter));
Check.That(StationDepartureTakeoverPolicy.TakeoverAfter<StationDepartureWatchdog.RecoveryAfter);
}
[Test]public void InsideTheStationNoRadialEscapeIsInventedAndRecoveryPrecedesThePenalty()
{
foreach(var frame in new[]{Frame with{FsdReady=false},Frame with{Mode=LocalFlightMode.Docked},Frame with{GeometryVerified=false},Frame with{ObservedAt=Now.AddSeconds(-1)}})
{
Check.Equal(StationDepartureAction.WaitForNative,Decide(frame,active:true,elapsed:StationDepartureTakeoverPolicy.TakeoverAfter));
Check.Equal(StationDepartureAction.RecoverSession,Decide(frame,active:true,elapsed:StationDepartureWatchdog.RecoveryAfter));
}
Check.That(TimeSpan.FromMinutes(5)-StationDepartureWatchdog.RecoveryAfter>=TimeSpan.FromMinutes(1));
}
[Test]public void WrongTargetOldUndockAndUnverifiedNativeFlagsCannotClaimAuthority()
{
Check.Equal(StationDepartureAction.WaitForNative,Decide(Frame with{SelectedTarget=Origin with{MarketId=99}}));
Check.Equal(StationDepartureAction.WaitForNative,Decide(Frame with{SystemAddress=99}));
Check.Equal(StationDepartureAction.WaitForNative,Decide(Frame with{ShipEntity=0}));
Check.Equal(StationDepartureAction.WaitForNative,Decide(Frame with{Velocity=new(double.NaN,0,0)}));
Check.Equal(StationDepartureAction.WaitForNative,Decide(observed:false));
Check.Equal(StationDepartureAction.WaitForNative,Decide(player:Player with{InGame=false}));
Check.Equal(StationDepartureAction.WaitForNative,Decide(player:Player with{Docked=true}));
Check.Equal(StationDepartureAction.WaitForNative,Decide(player:Player with{Navigation=Player.Navigation with{UndockedAt=Now.AddMinutes(-2)}}));
Check.Equal(StationDepartureAction.WaitForNative,Decide(player:Player with{Navigation=Player.Navigation with{UndockedAt=Now.AddSeconds(6)}}));
}
[Test]public void JournalLeadUsesTheVerifiedClockAndKeepsNativeFreshnessOnWallTime()
{
var player=Player with{Status=new(16777232,0,32,1,0,null,null,null,null,[]),StatusAt=Now.AddSeconds(6.464),StatusWrittenAt=Now,StatusReadAt=Now,
Navigation=Player.Navigation with{UndockedAt=Now.AddSeconds(6.464)}};
Check.Equal(StationDepartureAction.Completed,Decide(player:player,requestedAt:Now.AddSeconds(5)));
Check.Equal(StationDepartureAction.WaitForNative,Decide(Frame with{ObservedAt=Now.AddSeconds(6)},player:player,requestedAt:Now.AddSeconds(5)));
Check.Equal(StationDepartureAction.WaitForNative,Decide(player:player with{StatusWrittenAt=null},requestedAt:Now.AddSeconds(5)));
var behind=player with{StatusAt=Now.AddSeconds(-6.464),Navigation=Player.Navigation with{UndockedAt=Now.AddSeconds(-6.464)}};
Check.Equal(StationDepartureAction.Completed,Decide(player:behind,requestedAt:Now.AddSeconds(-7)));
}
[Test]public void ManualTakeoverWinsEvenAtTheRecoveryDeadline()
{
Check.Equal(StationDepartureAction.ManualTakeover,Decide(Frame with{PlayerOverride=true},active:true,elapsed:StationDepartureWatchdog.RecoveryAfter));
}
private static StationDeparturePathEvidence ObserveOriginalExit(SystemFlightFrame end,PlayerState? player=null)
{
var proof=new StationDeparturePathEvidence();var state=player??Player;
for(var index=0;index<4;index++)
{
var at=Now.AddMilliseconds(-800+index*200);
var frame=end with{Sequence=index+1,ObservedAt=at,Position=end.Position-new Vector3d(0,0,100-index*30),Velocity=new(0,0,150)};
proof.Observe(frame,Origin,state,Now.AddMinutes(-1),at,true,true,false,false);
}
proof.Observe(end,Origin,state,Now.AddMinutes(-1),Now,true,false,false,false);
return proof;
}
private static StationDepartureAction WithPath(SystemFlightFrame frame,StationDeparturePathEvidence proof,TimeSpan? elapsed=null,PlayerState? player=null)
=>StationDepartureTakeoverPolicy.Decide(frame,Origin,player??Player,Now.AddMinutes(-1),elapsed??TimeSpan.FromSeconds(80),Now,true,false,false,false,true,proof);
[Test]public void OtherOriginalDutyCannotCompleteContinueOrBeCancelledAsAnOwnedLaunch()
{
Check.Equal(StationDepartureAction.WaitForNative,Decide(duty:true));
Check.Equal(StationDepartureAction.WaitForNative,Decide(duty:true,elapsed:StationDepartureTakeoverPolicy.TakeoverAfter));
var frame=Frame with{Sequence=5,FsdReady=false,Position=new(0,0,4186),TargetExclusionRadius=3100};var proof=ObserveOriginalExit(frame);
Check.Equal(StationDepartureAction.WaitForNative,StationDepartureTakeoverPolicy.Decide(frame,Origin,Player,Now.AddMinutes(-1),TimeSpan.FromSeconds(80),Now,true,false,false,true,true,proof));
foreach(var ready in new[]{false,true})
Check.Equal(StationDepartureAction.WaitForNative,StationDepartureTakeoverPolicy.Decide(frame with{FsdReady=ready},Origin,Player,Now.AddMinutes(-1),TimeSpan.FromSeconds(130),Now,false,false,false,false,true,proof));
}
[Test]public void OriginalNaturalExitAt4186MetresContinuesStraightWhileMassLockedAndOnlyCompletesWhenFsdReady()
{
var frame=Frame with{Sequence=5,FsdReady=false,Position=new(0,0,4186),TargetDistance=4186,Speed=0,Throttle=0,TargetExclusionRadius=3100};
var state=Player with{Status=new(0x1010000,0,32,1,0,null,null,null,null,[])};
var proof=ObserveOriginalExit(frame,state);
Check.That(proof.OriginalDutyObserved&&proof.OriginalDutyNaturallyEnded&&proof.PathVerified);
Check.Equal(StationDepartureAction.ContinueVerifiedDeparture,WithPath(frame,proof,player:state));
Check.Equal(StationDepartureAction.Completed,WithPath(frame with{FsdReady=true},proof,player:state));
Check.Equal(StationDepartureAction.RecoverSession,WithPath(frame,proof,StationDepartureWatchdog.RecoveryAfter,state));
Check.Equal(.5,StationDeparturePathEvidence.ContinuationThrottle);
}
[Test]public void ContinuationUsesOriginalStationEnvelopeRatherThanAnArbitraryThreeKilometreRule()
{
var small=Frame with{Sequence=5,FsdReady=false,Position=new(0,0,500),TargetExclusionRadius=350};
Check.Equal(StationDepartureAction.ContinueVerifiedDeparture,WithPath(small,ObserveOriginalExit(small)));
var large=Frame with{Sequence=5,FsdReady=false,Position=new(0,0,4186),TargetExclusionRadius=5000};
Check.Equal(StationDepartureAction.WaitForNative,WithPath(large,ObserveOriginalExit(large)));
var unknown=small with{TargetExclusionRadius=null};
Check.Equal(StationDepartureAction.WaitForNative,WithPath(unknown,ObserveOriginalExit(unknown)));
}
[Test]public void ColdInactiveCancelledAndUnobservedLaunchCannotInventAnExit()
{
var frame=Frame with{Sequence=5,FsdReady=false,Position=new(0,0,4186),TargetExclusionRadius=3100};
var cold=new StationDeparturePathEvidence();cold.Observe(frame,Origin,Player,Now.AddMinutes(-1),Now,true,false,false,false);
Check.Equal(StationDepartureAction.WaitForNative,WithPath(frame,cold));
var cancelled=ObserveOriginalExit(frame);
cancelled.Observe(frame with{Sequence=6,ObservedAt=Now.AddMilliseconds(80)},Origin,Player,Now.AddMinutes(-1),Now.AddMilliseconds(80),true,false,false,true);
Check.That(!cancelled.CanContinue(frame,Now));
Check.Equal(StationDepartureAction.WaitForNative,Decide(frame));
}
[Test]public void ProvenExitAuthorityIsWithdrawnOnAHeadingChangeReverseMotionEntityChangeOrStaleGeometry()
{
var end=Frame with{Sequence=5,FsdReady=false,Position=new(0,0,4186),TargetExclusionRadius=3100};
foreach(var changed in new[]{end with{Forward=new(0,0,-1)},end with{Position=new(0,0,4180)},end with{Position=new(3,0,4188)},
end with{ShipEntity=99},end with{GeometryVerified=false},end with{TargetExclusionRadius=null},end with{Velocity=new(0,0,-10)}})
{
var proof=ObserveOriginalExit(end);var next=changed with{Sequence=6,ObservedAt=Now.AddMilliseconds(80)};
proof.Observe(next,Origin,Player,Now.AddMinutes(-1),next.ObservedAt,true,false,false,false);
Check.That(!proof.CanContinue(next,next.ObservedAt));
}
var stale=ObserveOriginalExit(end);var gap=end with{Sequence=6,ObservedAt=Now.AddSeconds(2)};
stale.Observe(gap,Origin,Player,Now.AddMinutes(-1),gap.ObservedAt,true,false,false,false);
Check.That(!stale.CanContinue(gap,gap.ObservedAt));
Check.Equal(StationDepartureAction.ManualTakeover,WithPath(end with{PlayerOverride=true},ObserveOriginalExit(end)));
}
private static (StationDeparturePathEvidence Proof,SystemFlightFrame Last) ActiveOutsideExit()
{
var proof=new StationDeparturePathEvidence();SystemFlightFrame last=Frame;
for(var index=0;index<4;index++)
{
last=Frame with{Sequence=index+1,ObservedAt=Now.AddMilliseconds(-600+index*200),FsdReady=false,
Position=new(0,0,3386.5+index*20),TargetDistance=3386.5+index*20,TargetExclusionRadius=3000,Speed=99,Velocity=new(0,0,99)};
proof.Observe(last,Origin,Player,Now.AddMinutes(-1),last.ObservedAt,true,true,false,false);
}
Check.That(proof.PathVerified);return(proof,last);
}
[Test]public void RepeatedPhysicsPublicationDoesNotRevokeOrInventANaturalDutyTransition()
{
var (proof,last)=ActiveOutsideExit();
for(var index=0;index<10;index++)proof.Observe(last,Origin,Player,Now.AddMinutes(-1),Now,true,true,false,false);
proof.Observe(last,Origin,Player,Now.AddMinutes(-1),Now,true,false,false,false);
Check.That(proof.PathVerified&&!proof.OriginalDutyNaturallyEnded);Check.Equal<string?>(null,proof.WithdrawalReason);
var end=last with{Sequence=5,ObservedAt=Now.AddMilliseconds(200),Position=last.Position+new Vector3d(0,0,20),Velocity=Vector3d.Zero,Speed=0};
proof.Observe(end,Origin,Player,Now.AddMinutes(-1),end.ObservedAt,true,false,false,false);
Check.That(proof.OriginalDutyNaturallyEnded&&proof.CanContinue(end,end.ObservedAt));
}
[Test]public void BoundedGapDuringOriginalFlightRebuildsAContinuousSegmentBeforeTheNaturalExit()
{
var (proof,last)=ActiveOutsideExit();
// Actual round-15 diagnostic endpoints were 3446.50 -> 3651.24 m,
// with the original launch still active. The logger's two-second
// cadence is not evidence that every underlying poll had this gap.
var resumed=last with{Sequence=5,ObservedAt=Now.AddMilliseconds(2068),Position=new(0,0,3651.24),TargetDistance=3651.24};
proof.Observe(resumed,Origin,Player,Now.AddMinutes(-1),resumed.ObservedAt,true,true,false,false);
Check.That(!proof.PathVerified&&!proof.OriginalDutyNaturallyEnded);Check.Equal<string?>(null,proof.WithdrawalReason);
for(var index=1;index<=3;index++)
{
var sample=resumed with{Sequence=5+index,ObservedAt=resumed.ObservedAt.AddMilliseconds(index*200),Position=resumed.Position+new Vector3d(0,0,index*20)};
proof.Observe(sample,Origin,Player,Now.AddMinutes(-1),sample.ObservedAt,true,true,false,false);
Check.Equal(index==3,proof.PathVerified);
}
var end=resumed with{Sequence=9,ObservedAt=resumed.ObservedAt.AddMilliseconds(800),Position=resumed.Position+new Vector3d(0,0,80),Speed=0,Velocity=Vector3d.Zero};
proof.Observe(end,Origin,Player,Now.AddMinutes(-1),end.ObservedAt,true,false,false,false);
Check.That(proof.CanContinue(end,end.ObservedAt));
}
[Test]public void MissingExitSamplesCannotBeReplacedByAStationaryInactiveEndpointOrALongGap()
{
foreach(var seconds in new[]{2d,6d})
{
var (proof,last)=ActiveOutsideExit();var resumed=last with{Sequence=5,ObservedAt=Now.AddSeconds(seconds),Position=last.Position+new Vector3d(0,0,100)};
proof.Observe(resumed,Origin,Player,Now.AddMinutes(-1),resumed.ObservedAt,true,true,false,false);
var end=resumed with{Sequence=6,ObservedAt=resumed.ObservedAt.AddMilliseconds(80),Speed=0,Velocity=Vector3d.Zero};
proof.Observe(end,Origin,Player,Now.AddMinutes(-1),end.ObservedAt,true,false,false,false);
Check.That(!proof.CanContinue(end,end.ObservedAt));Check.That(proof.WithdrawalReason is not null);
}
}
[Test]public void AnOutsideGapWithReverseLateralOrHeadingDiscontinuityPermanentlyWithdrawsAuthority()
{
foreach(var cause in new[]{"reverse","lateral","heading","velocity"})
{
var (proof,last)=ActiveOutsideExit();var resumed=last with{Sequence=5,ObservedAt=Now.AddSeconds(2),Position=last.Position+new Vector3d(0,0,100)};
resumed=cause switch
{
"reverse"=>resumed with{Position=last.Position-new Vector3d(0,0,2)},
"lateral"=>resumed with{Position=resumed.Position+new Vector3d(3,0,0)},
"heading"=>resumed with{Forward=new(0,0,-1)},
_=>resumed with{Velocity=new(0,0,-10)}
};
proof.Observe(resumed,Origin,Player,Now.AddMinutes(-1),resumed.ObservedAt,true,true,false,false);
Check.That(!proof.PathVerified);Check.Equal("unsafe-outside-path-across-observation-gap",proof.WithdrawalReason);
}
}
[Test]public void DuplicateHandlingNeverMasksManualDamageIdentityOrConflictingPose()
{
foreach(var cause in new[]{"manual","damage","commander","ship","session","entity","target","conflicting-pose","reordered"})
{
var (proof,last)=ActiveOutsideExit();var state=Player;var sample=last;
switch(cause)
{
case "manual":sample=sample with{PlayerOverride=true};break;
case "damage":state=state with{Ships=[new(){ShipId=7,HullHealth=0}]};break;
case "commander":state=state with{CommanderId="other"};break;
case "ship":state=state with{CurrentShipId=8};break;
case "session":state=state with{LoadGameAt=Now.AddMinutes(-2)};break;
case "entity":sample=sample with{ShipEntity=99};break;
case "target":sample=sample with{SelectedTarget=Origin with{MarketId=99}};break;
case "conflicting-pose":sample=sample with{Position=sample.Position+new Vector3d(1,0,0)};break;
case "reordered":sample=sample with{Sequence=3,ObservedAt=Now.AddMilliseconds(-80)};break;
}
proof.Observe(sample,Origin,state,Now.AddMinutes(-1),Now,true,true,false,false);
Check.That(!proof.PathVerified&&proof.WithdrawalReason is not null);
}
}
}