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=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(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(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); } } }