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)<Time(false)*.8,$"Rate {maximum}, response {response}: fast policy must save at least 20% in the independent angular plant.");
}
}
}
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)<Time(false)*.8,$"Rate {maximum}, response {response}: fast policy must save at least 20% in the independent angular plant.");
}
}
}