using XFEEDToolBox.Core.Models; using XFEEDToolBox.Core.Navigation; using XFEExtension.NetCore.XUnit.Attributes; namespace XFEEDToolBox.Test; [TestFixture] public class AdaptiveNavigationTests { private static readonly DateTimeOffset Now=DateTimeOffset.Parse("2026-10-22T00:00:00Z"); private static readonly StarEnvelope Star=new(1,"K",Vector3d.Zero,100,null,null); private static readonly NavWaypoint Next=new(2,"Next",new(20,0,0),"K"); private static ShipThermalCalibration Model=>new(){Verified=true,GameSha256="build",LoadoutFingerprint="fit",StarBodyId=1,ExpiresAt=Now.AddMinutes(5), MinimumHeat=0,MaximumHeat=150,ChargeDurationUpperSeconds=15,ChargingGainUpperPerSecond=2,ChargedHoldGainUpperPerSecond=2,UncertaintyPercent=2, DistanceBands=[new(100,1000000,1,1)],Evidence="Independent synthetic thermal bounds"}; private static FlightFrame Frame=>new(){Sequence=1,ObservedAt=Now,GameSha256="build",LoadoutFingerprint="fit",GeometryVerified=true,AdaptiveFlightControl=true, InSupercruise=true,SystemAddress=1,TargetSystemAddress=2,Position=new(150,0,0),Forward=new(0,1,0),Up=new(0,0,1),TargetPosition=new(10000,0,0), CurrentStar=Star,HeatPercent=30,FuelMain=12,FuelRequiredForJump=3,FuelNeededToNextRefuel=4,FuelTankCapacity=32,ScoopPowered=true,PreferredOrbitRadius=125, CanBeginCharge=true,JumpCommitInterlockVerified=true,AlignmentRateLowerDegreesPerSecond=4,AngularSpeedDegreesPerSecond=0, PlannedMinimumStarDistance=149,PlannedMaximumStarDistance=100000,NativeAllowsCharging=true,OutwardDepartureRequired=true}; [Test] public void ArrivalCooldownImmediatelySteersAndFirstIdleFrameStartsCharging() { var navigator=new FlightNavigator(); var cooldown=navigator.Decide(Frame with{Fsd=FsdPhase.Cooldown,CanBeginCharge=false},Next,Model,Now); Check.That(cooldown.Direction is {} direction&&Vector3d.AngleDegrees(direction,new(1,0,0))<1); Check.That(!cooldown.BeginCharging&&cooldown.Throttle==0); var at=Now.AddMilliseconds(40); var idle=navigator.Decide(Frame with{Sequence=2,ObservedAt=at,Fsd=FsdPhase.Idle},Next,Model,at); Check.That(idle.BeginCharging&&idle.Throttle==1,idle.Reason); } [Test] public void FirstVisibleInwardTargetDoesNotRequireAnotherHalfOrbit() { var frame=Frame with{TargetPosition=new(-10000,20000,0)}; Check.That(!FlightGeometry.Obscures(Star,frame.Position,frame.TargetPosition)); Check.That(Vector3d.Dot(frame.Position,frame.TargetPosition-frame.Position)<0); var decision=new FlightNavigator().Decide(frame,Next,Model,Now); Check.That(decision.BeginCharging&&decision.Throttle==1,decision.Reason); Check.That(Vector3d.AngleDegrees(frame.TargetPosition-frame.Position,decision.Direction!.Value)<.001); } [Test] public void VisibleInwardTargetAlignsDuringCooldownBeforeHeatModelIsReady() { var frame=Frame with{Fsd=FsdPhase.Cooldown,CanBeginCharge=false,NativeAllowsCharging=false,TargetPosition=new(-10000,20000,0)}; Check.That(!FlightGeometry.Obscures(Star,frame.Position,frame.TargetPosition)); var navigator=new FlightNavigator();var turn=navigator.Decide(frame,Next,null,Now); Check.That(turn.Direction is {} d&&Vector3d.AngleDegrees(d,frame.TargetPosition-frame.Position)<.001); Check.That(turn.Throttle==0&&!turn.BeginCharging&&!turn.PermitJump); var at=Now.AddMilliseconds(40);frame=frame with{Sequence=2,ObservedAt=at,Fsd=FsdPhase.Idle,CanBeginCharge=true,NativeAllowsCharging=true,Forward=(frame.TargetPosition-frame.Position).Unit()}; var begin=navigator.Decide(frame,Next,Model,at); Check.That(begin.BeginCharging&&begin.Throttle==1&&Vector3d.AngleDegrees(begin.Direction!.Value,frame.Forward)<.001); } [Test] public void ClearTargetTurnsWhileSamplingButObstaclesAndFuelStillRequireTheirOwnManoeuvre() { var frame=Frame with{TargetPosition=new(-10000,20000,0)}; var waiting=new FlightNavigator().Decide(frame,Next,null,Now); Check.That(waiting.Direction is {} d&&Vector3d.AngleDegrees(d,frame.TargetPosition-frame.Position)<.001&&!waiting.BeginCharging); var refuelling=new FlightNavigator().Decide(frame with{Fsd=FsdPhase.Cooldown,FuelMain=3},Next,Model,Now); Check.That(refuelling.State.Contains("补满")&&!refuelling.BeginCharging); var blocked=new FlightNavigator().Decide(frame with{Fsd=FsdPhase.Cooldown,TargetPosition=new(-10000,0,0)},Next,Model,Now); Check.That(blocked.State.Contains("圆弧")&&!blocked.BeginCharging); } [Test] public void InwardAlignmentBrakesNearCompletionAndCommitsAsSoonAsStable() { var navigator=new FlightNavigator(); var frame=Frame with{TargetPosition=new(-10000,20000,0),Fsd=FsdPhase.Charging,ChargeSecondsRemaining=2}; var turning=navigator.Decide(frame,Next,Model,Now); Check.That(turning.Direction is {} d&&Vector3d.AngleDegrees(d,frame.TargetPosition-frame.Position)<.01); Check.Equal(0d,turning.Throttle); var at=Now.AddMilliseconds(40); frame=frame with{Sequence=2,ObservedAt=at,Fsd=FsdPhase.Charged,Forward=(frame.TargetPosition-frame.Position).Unit()}; Check.That(!navigator.Decide(frame,Next,Model,at).PermitJump); at=at.AddMilliseconds(80);frame=frame with{Sequence=3,ObservedAt=at}; var commit=navigator.Decide(frame,Next,Model,at);Check.That(commit.PermitJump&&commit.Throttle==1,commit.Reason); } [Test] public void RefuellingKeepsMovingAndCannotChargeJustBecauseNextJumpIsAffordable() { var frame=Frame with{FuelMain=12,FuelNeededToNextRefuel=31.99,RefuellingToFull=true,ScoopRate=.05,ScoopMaximumRate=.878}; var decision=new FlightNavigator().Decide(frame,Next,Model,Now); Check.That(decision.Direction is not null&&decision.Throttle==1&&!decision.BeginCharging); Check.That(decision.State.Contains("补满")); Check.That(decision.UpDirection is {} up&&Vector3d.Dot(up,Star.Centre-frame.Position)>0&&Math.Abs(Vector3d.Dot(up,decision.Direction!.Value))<1e-8); } [Test] public void ChargeStartsBeforeAlignmentAndCommitAcceptsAnOffsetInsideTheVerifiedCone() { var navigator=new FlightNavigator(); var begin=navigator.Decide(Frame with{Forward=new(0,1,0)},Next,Model,Now); Check.That(begin.BeginCharging&&Vector3d.AngleDegrees(begin.Direction!.Value,new(0,1,0))>80); var angle=.9*Math.PI/180;var at=Now.AddSeconds(1); var charged=Frame with{Sequence=2,ObservedAt=at,Fsd=FsdPhase.Charged,Forward=new(Math.Cos(angle),Math.Sin(angle),0)}; Check.That(!navigator.Decide(charged,Next,Model,at).PermitJump); at=at.AddMilliseconds(80);charged=charged with{Sequence=3,ObservedAt=at}; Check.That(navigator.Decide(charged,Next,Model,at).PermitJump); } [Test] public void ThermalRefusalContinuesSafeDepartureInsteadOfStoppingMotion() { var frame=Frame with{NativeAllowsCharging=false,Fsd=FsdPhase.Charging}; var decision=new FlightNavigator().Decide(frame,Next,Model,Now); Check.That(decision.CancelCharging&&!decision.BeginCharging&&decision.Direction==new Vector3d(1,0,0)&&decision.Throttle==1); } [Test] public void ACancelledThermalForecastRequiresCoolingMarginBeforeRestartingCharge() { var navigator=new FlightNavigator(); var cancel=navigator.Decide(Frame with{Fsd=FsdPhase.Charging,NativeAllowsCharging=false},Next,Model,Now); Check.That(cancel.CancelCharging); var at=Now.AddMilliseconds(40); var wait=navigator.Decide(Frame with{Sequence=2,ObservedAt=at,NativeFullChargePeak=94},Next,Model,at); Check.That(!wait.BeginCharging&&wait.CoolingRecovery); at=at.AddMilliseconds(40); var resume=navigator.Decide(Frame with{Sequence=3,ObservedAt=at,NativeFullChargePeak=89},Next,Model,at); Check.That(resume.BeginCharging&&!resume.CoolingRecovery); } [Test] public void OrbitUsesTheShortArcTowardDestinationAndBestInitialPlaneForRadialTarget() { var target=new Vector3d(-10000,500,0);var plane=FlightGeometry.OrbitPlane(Star,new(150,0,0),target,new(0,0,1)); var direction=FlightGeometry.OrbitAtRadius(Star,new(150,0,0),target,new(0,0,1),150,plane); Check.That(direction.Y>0&&Math.Abs(direction.Z)<1e-8); var radialPlane=FlightGeometry.OrbitPlane(Star,new(150,0,0),new(-10000,0,0),new(0,0,1),new(0,1,0)); Check.That(Vector3d.AngleDegrees(new(0,1,0),FlightGeometry.OrbitAtRadius(Star,new(150,0,0),new(-10000,0,0),new(0,0,1),150,radialPlane))<.01); } [Test] public void ScoopBandSelectsTheFullPowerCircleImmediatelyAndOnlyHeatCanEnlargeIt() { var low=new ScoopBandTracker();low.Update(150,100,60,true,5,Now,1,true,Now); var lowRadius=low.Update(145,100,60,true,5.1,Now.AddSeconds(1),1,true,Now.AddSeconds(1)); Check.That(lowRadius<145); var high=new ScoopBandTracker();high.Update(150,100,60,true,5,Now,1,true,Now); var highRadius=high.Update(140,100,60,true,5.95,Now.AddSeconds(1),1,true,Now.AddSeconds(1)); Check.That(Math.Abs(highRadius-108)<1e-8); Check.That(high.Update(140,100,98,true,6.9,Now.AddSeconds(2),1,true,Now.AddSeconds(2))>140); Check.That(new ScoopBandTracker().Update(150,100,60,false,5,Now,1,true,Now)<150); } [Test] public void ScoopSearchDoesNotFreezeAtEightyPercentAndExpiresStalePower() { var band=new ScoopBandTracker();var radius=150d; for(var i=0;i<400;i++) { var at=Now.AddMilliseconds(i*40);var desired=band.Update(radius,100,81,false,5,Now,1,true,at); Check.That(desired>=108-1e-8&&desired<=radius+.0001); radius+=Math.Clamp(desired-radius,-.03,.03); } Check.That(radius<145,"A cool low-rate orbit must continue approaching the collision envelope."); var hot=band.Update(radius,100,98,true,5.7,Now.AddSeconds(17),1,true,Now.AddSeconds(17)); Check.That(hot>radius); band.Update(radius,100,85,false,5.7,Now.AddSeconds(21),1,true,Now.AddSeconds(21)); Check.That(band.Rate is null); } [Test] public void ArrivalHeatCannotPermanentlyWidenTheFullPowerRefillCircle() { var band=new ScoopBandTracker(); band.Update(150,100,75,false,15,Now,.878,true,Now,110); var hot=band.Update(128,100,94,true,17,Now.AddSeconds(1),.878,true,Now.AddSeconds(1),110); Check.That(hot>128,"Rising heat must retain clearance while escaping."); var cooled=band.Update(180,100,80,false,19,Now.AddSeconds(2),.878,true,Now.AddSeconds(2),110); Check.That(Math.Abs(cooled-108)<1e-8,"Cooling must immediately restore the already computed full-power circle."); } [Test] public void LookaheadOrbitConvergesWithDelayedSteeringAndNeverCrossesCollisionBoundary() { foreach(var velocity in new[]{1d,2.5,4d}) { var p=new Vector3d(150,0,0);var forward=new Vector3d(0,1,0);var plane=new Vector3d(0,0,1); // Independent position/heading plant: finite turn rate and 300 ms response. var angle=Math.PI/2;var rate=0d;var min=double.MaxValue;var command=0d;var lastRate=0d; for(var i=0;i<24000;i++) { const double dt=.02;var wanted=FlightGeometry.OrbitAtRadius(Star,p,new(-10000,300,0),new(0,0,1),108,plane,velocity); var error=Math.Atan2(forward.X*wanted.Y-forward.Y*wanted.X,Vector3d.Dot(forward,wanted))*180/Math.PI; // Telemetry updates at 25 Hz while the plant integrates at 50 Hz. if(i%2==0)lastRate=rate; var target=AttitudeController.FastAxis(error,lastRate); command+=Math.Clamp(target-command,-2.5*dt,2.5*dt); rate+=(4*command-rate)/.3*dt;angle+=rate*Math.PI/180*dt; forward=new(Math.Cos(angle),Math.Sin(angle),0);p+=forward*(velocity*dt);min=Math.Min(min,p.Length); } Check.That(min>105,$"Minimum radius {min} must retain lookahead clearance at speed {velocity}."); Check.That(Math.Abs(p.Length-108)<5,$"Lookahead tracking must settle without walking outward ({p.Length})."); } } [Test] public void RefuelTurnBrakesBeforeAcceleratingAwayFromTheScoopBand() { var frame=Frame with{FuelMain=3,FuelNeededToNextRefuel=31.99,RefuellingToFull=true,Forward=new(1,0,0)}; var decision=new FlightNavigator().Decide(frame,Next,Model,Now); Check.That(decision.Throttle==0&&decision.Direction is not null&&!decision.BeginCharging); } [Test] public void RatedScoopRateUsesTheInstalledEnabledModuleAndEngineeringUnits() { var module=new FittedModule{Item="int_fuelscoop_size6_class5",Enabled=true};var ship=new ShipRecord{Modules=[module]}; Check.Equal(.878,ScoopReference.MaximumRate(ship)!.Value); Check.That(ScoopReference.MaximumRate(ship with{Modules=[module with{Enabled=false}]}) is null); Check.That(Math.Abs(ScoopReference.MaximumRate(ship with{Modules=[module with{Engineering=new(){Modifiers=[new("FuelScoopRate",1053.6,878,false)]}}]})!.Value-1.0536)<1e-9); } [Test] public void RouteFuelBudgetUsesMassAndDistanceAndFallsBackWhenCargoIsUnknown() { var drive=new FsdReference("fixture",1000,8.3,.02,2); var ship=new ShipRecord{UnladenMass=500,FuelMain=32,Modules=[new(){Item="int_fuelscoop_size6_class5",Enabled=true}]}; var player=new PlayerState{CargoReliable=true,Status=new(16,0,5,1,0,null,null,null,null,[]),Navigation=new(){CurrentSystemAddress=1,Route=[new(1,"Here",new(0,0,0),"K"),Next]}}; var budget=NavigationFuelBudget.Calculate(player,ship,drive,5,.5); Check.That(budget.UsesMassReference&&budget.NextJump<3&&budget.UntilRefuel<5); var unknown=NavigationFuelBudget.Calculate(player with{CargoReliable=false},ship,drive,5,.5); Check.That(!unknown.UsesMassReference&&unknown.NextJump==8.3&&unknown.UntilRefuel>8.3); } [Test] public void FullAuthorityWithMeasuredRateBrakingSettlesFasterThanPreviousController() { foreach(var maximum in new[]{4d,12d,50d})foreach(var response in new[]{.15,.4,.8}) { double Time(bool fast) { double angle=120,rate=0,settled=0; for(var i=0;i<12000;i++) { var command=fast?AttitudeController.FastAxis(angle,rate):Math.Clamp(angle*.018,-.6,.6); rate+=(maximum*command-rate)/response*.01;angle-=rate*.01; settled=Math.Abs(angle)<.75&&Math.Abs(rate)<2?settled+.01:0; if(settled>=.1)return i*.01; } return 120; } Check.That(Time(true)