XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEEDToolBox

【WinUI】XFE精英危险工具箱

公开
关注 0 Fork 0 Star 0
UTF-8
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.");
        }
    }
}