返回提交历史
Modified
XFEEDToolBox.Core/Navigation/FlightAutomationCoordinator.cs
+6
-1
Modified
XFEEDToolBox.Core/Navigation/NativeSystemFlightControlDriver.cs
+39
-2
Modified
XFEEDToolBox.Core/Navigation/SystemNavigationRunner.cs
+5
-1
Modified
XFEEDToolBox.Core/Navigation/TravelPreparation.cs
+36
-4
Modified
XFEEDToolBox.Test/FlightAutomationCoordinatorTests.cs
+9
-1
Modified
XFEEDToolBox.Test/NativeDockingObservationTests.cs
+13
-0
Modified
XFEEDToolBox.Test/SystemNavigationRunnerTests.cs
+22
-0
Modified
XFEEDToolBox.Test/TravelPreparationTests.cs
+58
-1
XFEstudio/XFEEDToolBox
Preserve original landing and destination arrival during flight recovery
0ab851e
代码差异
8 个文件
+188
-10
@@ -109,7 +109,8 @@ public sealed class FlightAutomationCoordinator(Func<PlayerState> player)
109
109
{
110
110
if(destination.MarketId<=0||string.IsNullOrWhiteSpace(destination.SystemName))throw new InvalidDataException("目的站身份缺失。");
111
111
await driver.EnsureReadyAsync(stop);RequireSession(initial);
112
if(initial.Docked&&initial.MarketId==destination.MarketId&&initial.SystemName.Equals(destination.SystemName,StringComparison.OrdinalIgnoreCase))return new(NavigationRunState.Completed);
112
stop.ThrowIfCancellationRequested();
113
if(AtDestinationStation(destination))return new(NavigationRunState.Completed);
113
114
await DepartIfNeededAsync(initial,driver,stop,report);
114
115
if(!player().SystemName.Equals(destination.SystemName,StringComparison.OrdinalIgnoreCase))
115
116
{
@@ -123,6 +124,7 @@ public sealed class FlightAutomationCoordinator(Func<PlayerState> player)
123
124
if(!player().SystemName.Equals(destination.SystemName,StringComparison.OrdinalIgnoreCase))throw new InvalidOperationException("游戏尚未确认抵达目的星系。");
124
125
}
125
126
await driver.EnsureReadyAsync(stop);RequireSession(initial);stop.ThrowIfCancellationRequested();
127
if(AtDestinationStation(destination))return new(NavigationRunState.Completed);
126
128
report(FlightAutomationStage.Preparing,"正在读取目的站的最新身份。");
127
129
var arrival=await driver.ResolveArrivalAsync(destination,stop);
128
130
if(arrival.Target.SystemAddress!=player().Navigation.CurrentSystemAddress||arrival.Target.MarketId!=destination.MarketId)
@@ -143,6 +145,9 @@ public sealed class FlightAutomationCoordinator(Func<PlayerState> player)
143
145
return new(NavigationRunState.Completed);
144
146
},token);
145
147
148
private bool AtDestinationStation(Station destination)=>player() is {Docked:true} current&¤t.MarketId==destination.MarketId
149
&¤t.SystemName.Equals(destination.SystemName,StringComparison.OrdinalIgnoreCase);
150
146
151
private async Task DepartIfNeededAsync(PlayerState initial,IFlightDepartureDriver driver,CancellationToken token,Action<FlightAutomationStage,string> report)
147
152
{
148
153
RequireSession(initial);token.ThrowIfCancellationRequested();
@@ -92,7 +92,39 @@ public sealed partial class NativeSystemFlightControlDriver(int processId,string
92
92
{
93
93
if(ensureFlightProtection is not null)await ensureFlightProtection(token);
94
94
if(selected is null)throw new InvalidOperationException("请先选择星系内目的地。");
95
if(player() is {Docked:true} docked&&docked.MarketId!=selected.MarketId)await DepartDockedStationAsync(token);
95
void RequirePreparationIdentity()
96
{
97
token.ThrowIfCancellationRequested();var current=player();
98
if(!current.InGame||current.CommanderId!=commander||current.CurrentShipId!=journalShip||current.LoadGameAt!=sessionAt
99
||current.Navigation.CurrentSystemAddress!=selected.SystemAddress||NavigationSafety.UnhealthyHull(current))
100
throw new InvalidOperationException("准备星系内驾驶时飞船、会话或安全状态改变。");
101
if(NavigationSafety.HasExternalThreat(current))throw new FlightThreatPauseException();
102
}
103
async Task<bool> PreserveArrivalAsync(CancellationToken cancellation)
104
{
105
var observation=Stopwatch.StartNew();
106
while(true)
107
{
108
cancellation.ThrowIfCancellationRequested();RequirePreparationIdentity();
109
if(TravelPreparation.AtDockedDestination(player(),selected))return true;
110
var observed=await ReadSystemFlightAsync(cancellation);RequirePreparationIdentity();
111
if(observed.PlayerOverride)throw new InvalidOperationException("玩家已主动接管星系内驾驶。");
112
if(TravelPreparation.AtDockedDestination(player(),selected)||TravelPreparation.PreserveOriginalLanding(observed,selected,DateTimeOffset.UtcNow))return true;
113
var local=LastTelemetry!.Value;
114
if(!NativeSystemFlightObservation.ObserveOriginalArrival(local.GetProperty("docking"),local.GetProperty("rawDockingState").GetInt32()))return false;
115
// Native states 5/7 are physically docked, before Journal may
116
// arrive. An active original duty without a fresh grant is also
117
// observation-only; neither state permits retracting its gear.
118
if(observation.Elapsed>=TimeSpan.FromSeconds(30))throw new FlightTelemetryUnavailableException("正在等待原版降落与实际停靠记录同步,不发送设备输入。");
119
await Task.Delay(80,cancellation);
120
}
121
}
122
RequirePreparationIdentity();if(TravelPreparation.AtDockedDestination(player(),selected))return;
123
if(player() is {Docked:true} docked&&docked.MarketId!=selected.MarketId)
124
{
125
await DepartDockedStationAsync(token);
126
RequirePreparationIdentity();
127
}
96
128
SystemFlightFrame frame;var watch=Stopwatch.StartNew();
97
129
while(true)
98
130
{
@@ -100,13 +132,15 @@ public sealed partial class NativeSystemFlightControlDriver(int processId,string
100
132
catch(Exception error)when(NavigationRecovery.CanRetry(error,token)&&watch.Elapsed<TimeSpan.FromSeconds(30))
101
133
{await Task.Delay(80,token);}
102
134
}
135
if(await PreserveArrivalAsync(token))return;
103
136
await PhysicalInputPreparation.RequireAsync(pipe,token);
104
137
if(gameInputs is not null)
105
138
{
106
139
await gameInputs.ConnectAsync(owningThread,token);
107
await TravelPreparation.EnsureRetractedAsync(gameInputs,player,token,frame.ShipEntity);
140
if(!await TravelPreparation.EnsureRetractedAsync(gameInputs,player,token,frame.ShipEntity,PreserveArrivalAsync))return;
108
141
}
109
142
else if(TravelPreparation.HasDeployedEquipment(player()))throw new InvalidOperationException("请先收起武器挂点、货舱舱门和起落架。");
143
if(await PreserveArrivalAsync(token))return;
110
144
if(!useNativeSupercruiseAssist)
111
145
{
112
146
var until=DateTimeOffset.UtcNow.AddSeconds(12);
@@ -581,6 +615,9 @@ public sealed partial class NativeSystemFlightControlDriver(int processId,string
581
615
/// <summary>Original duty engagement can outlive its enable flag during a scene drop.</summary>
582
616
public static class NativeSystemFlightObservation
583
617
{
618
public static bool ObserveOriginalArrival(JsonElement docking,int dockingState)=>Flag(docking,"verified")
619
&&!LaunchNeedsCancellation(docking)&&(dockingState is 5 or 7||Flag(docking,"nativeDutyActive"));
620
private static bool Flag(JsonElement value,string name)=>value.TryGetProperty(name,out var flag)&&flag.ValueKind==JsonValueKind.True;
584
621
public static bool DockingInputZeroConfirmed(JsonElement local)
585
622
{
586
623
if(!local.TryGetProperty("throttle",out var throttle)||throttle.ValueKind!=JsonValueKind.Number
@@ -49,14 +49,17 @@ public sealed class SystemNavigationRunner(Func<PlayerState> player,Func<DateTim
49
49
}
50
50
try
51
51
{
52
stop.Token.ThrowIfCancellationRequested();
52
53
var initial=player();var initialShip=initial.CurrentShipId;var initialCommander=initial.CommanderId;var initialSession=initial.LoadGameAt;
53
54
if(!initial.InGame||initial.CurrentShipId is null||initial.Navigation.CurrentSystemAddress!=target.SystemAddress||NavigationSafety.UnhealthyHull(initial))
54
55
throw new InvalidOperationException("请先进入健康、安全的飞船驾驶状态。");
55
56
if(NavigationSafety.HasExternalThreat(initial))throw new FlightThreatPauseException();
57
stop.Token.ThrowIfCancellationRequested();
58
if(TravelPreparation.AtDockedDestination(initial,target)){state=NavigationRunState.Completed;terminal=$"已停靠 {target.Name}";return;}
56
59
var capabilities=await driver.InspectAsync(stop.Token);
57
60
if(!capabilities.Supports(target,NavigationAssists.From(initial)))throw new NotSupportedException("当前游戏接口尚未完成此类星系内驾驶的验证。");
58
61
await driver.SelectAsync(target,stop.Token);
59
await driver.PrepareAsync(stop.Token);
62
if(!TravelPreparation.AtDockedDestination(player(),target))await driver.PrepareAsync(stop.Token);
60
63
var policy=new SystemFlightNavigator();long previous=0;ulong entity=0;string? loadout=null;DateTimeOffset? missingSince=null;
61
64
DockingResetObservationPendingException? pendingReset=null;var landingObserved=false;var requestFailures=0;
62
65
Report(phase,"正在核对星系内目标和驾驶状态。");
@@ -66,6 +69,7 @@ public sealed class SystemNavigationRunner(Func<PlayerState> player,Func<DateTim
66
69
if(!current.InGame||current.CurrentShipId!=initialShip||current.CommanderId!=initialCommander||current.LoadGameAt!=initialSession
67
70
||NavigationSafety.UnhealthyHull(current))throw new InvalidOperationException("飞船、会话或安全状态改变,已停止星系内导航。");
68
71
if(NavigationSafety.HasExternalThreat(current))throw new FlightThreatPauseException();
72
if(TravelPreparation.AtDockedDestination(current,target)){state=NavigationRunState.Completed;terminal=$"已停靠 {target.Name}";break;}
69
73
SystemFlightFrame frame;
70
74
try
71
75
{
@@ -23,14 +23,42 @@ public static class TravelPreparation
23
23
&&player.StatusAt is {} eventAt&&(eventAt<=now.AddSeconds(5)||GameJournalClock.Offset(player,now) is not null)
24
24
&&(player.LoadGameAt is not {} sessionAt||eventAt>=sessionAt)
25
25
&&player.StatusReadAt is {} readAt&&readAt<=now.AddMilliseconds(100)&&now-readAt<=TimeSpan.FromSeconds(2);
26
public static async Task EnsureRetractedAsync(IGameInputChannel channel,Func<PlayerState> player,CancellationToken token,ulong? expectedEntity=null)
26
public static bool AtDockedDestination(PlayerState player,SystemNavigationTarget target)=>target.NeedsDocking&&player.InGame&&player.Docked
27
&&player.MarketId==target.MarketId&&player.Navigation.CurrentSystemAddress==target.SystemAddress;
28
public static bool PreserveOriginalLanding(SystemFlightFrame frame,SystemNavigationTarget target,DateTimeOffset now)=>target.NeedsDocking
29
&&frame.InGame&&!frame.PlayerOverride&&!frame.ExternalThreat&&frame.GeometryVerified&&frame.Sequence>0&&frame.ShipEntity!=0&&frame.ReferenceWorld!=0
30
&&frame.ObservedAt<=now&&now-frame.ObservedAt<=TimeSpan.FromMilliseconds(300)
31
&&frame.SystemAddress==target.SystemAddress&&frame.SelectedTarget is {} selected&&selected.SameIdentity(target)
32
&&frame.Mode is LocalFlightMode.Normal or LocalFlightMode.Transition&&frame.Assists.AutoDock&&frame.DockingAssist==NativeAssistState.Engaged
33
&&frame.Clearance is {Permission:DockingPermission.Granted} grant&&grant.MarketId==target.MarketId&&grant.ExpiresAt>=now
34
&&grant.ObservedAt<=now&&now-grant.ObservedAt<=TimeSpan.FromMilliseconds(300);
35
/// <returns>False when a verified arrival takes priority over travel equipment preparation.</returns>
36
public static async Task<bool> EnsureRetractedAsync(IGameInputChannel channel,Func<PlayerState> player,CancellationToken token,ulong? expectedEntity=null,
37
Func<CancellationToken,Task<bool>>? preserveArrival=null)
27
38
{
39
var initial=player();
40
PlayerState RequireState()
41
{
42
token.ThrowIfCancellationRequested();var current=player();
43
if(!current.InGame||current.CommanderId!=initial.CommanderId||current.CurrentShipId!=initial.CurrentShipId||current.LoadGameAt!=initial.LoadGameAt
44
||current.Navigation.CurrentSystemAddress!=initial.Navigation.CurrentSystemAddress||NavigationSafety.UnhealthyHull(current))
45
throw new InvalidOperationException("收起设备时游戏会话或飞行安全状态已改变。");
46
if(NavigationSafety.HasExternalThreat(current))throw new FlightThreatPauseException();
47
return current;
48
}
49
async Task<bool> YieldToArrivalAsync()
50
{
51
if(preserveArrival is null)return false;
52
var shouldYield=await preserveArrival(token);RequireState();return shouldYield;
53
}
28
54
var clock=Stopwatch.StartNew();ulong entity=0;long waitingGeneration=0;string? waitingInput=null;
29
55
DateTimeOffset? clearSince=null;var sentAt=TimeSpan.Zero;
30
56
while(clock.Elapsed<TimeSpan.FromSeconds(12))
31
57
{
32
token.ThrowIfCancellationRequested();var state=player();
33
if(!state.InGame||state.Docked||NavigationSafety.HasExternalThreat(state))throw new InvalidOperationException("收起设备时游戏会话或飞行安全状态已改变。");
58
var state=RequireState();
59
if(await YieldToArrivalAsync())return false;
60
state=RequireState();
61
if(state.Docked)throw new InvalidOperationException("收起设备时游戏会话或飞行安全状态已改变。");
34
62
GameInputState frame;
35
63
try{frame=await channel.ReadAsync(token);}
36
64
catch(Exception error)when(NavigationRecovery.CanRetry(error,token))
@@ -43,6 +71,10 @@ public static class TravelPreparation
43
71
var now=DateTimeOffset.UtcNow;
44
72
if(frame.Sequence<=0||frame.EntityId==0||frame.ObservedAt>now.AddMilliseconds(100)||now-frame.ObservedAt>TimeSpan.FromMilliseconds(300))throw new FlightTelemetryUnavailableException("收起设备的原生状态已过期。");
45
73
if(expectedEntity is {} expected&&expected!=frame.EntityId)throw new InvalidOperationException("输入通道与当前飞船身份不一致。");
74
if(await YieldToArrivalAsync())return false;
75
state=RequireState();if(state.Docked)throw new InvalidOperationException("收起设备时游戏会话或飞行安全状态已改变。");
76
now=DateTimeOffset.UtcNow;
77
if(now-frame.ObservedAt>TimeSpan.FromMilliseconds(300))throw new FlightTelemetryUnavailableException("收起设备的原生状态已过期。");
46
78
if(!StatusConfirmed(state,now))
47
79
{
48
80
clearSince=null;
@@ -53,7 +85,7 @@ public static class TravelPreparation
53
85
{
54
86
// Status reflects the physical deployment, independently of the
55
87
// desired input. Require a stable native confirmation as well.
56
clearSince??=now;if(now-clearSince>=TimeSpan.FromMilliseconds(80))return;
88
clearSince??=now;if(now-clearSince>=TimeSpan.FromMilliseconds(80))return true;
57
89
}
58
90
else clearSince=null;
59
91
var next=frame.Hardpoints?"hardpoints":frame.CargoScoop?"cargoScoop":frame.LandingGear?"landingGear":null;
@@ -26,6 +26,7 @@ public sealed class FlightAutomationCoordinatorTests
26
26
internal PlayerState Player=Initial;
27
27
internal readonly List<string> Calls=[];
28
28
internal Action? OnDepart;
29
internal Action? OnReady;
29
30
internal Func<CancellationToken,Task>? Departure;
30
31
internal Action? OnInspect;
31
32
internal bool Arrive=true,AutoLand=true;
@@ -34,7 +35,7 @@ public sealed class FlightAutomationCoordinatorTests
34
35
internal int Classifications;
35
36
public Task<Exception> ClassifyFailureAsync(Exception failure,CancellationToken token)
36
37
{token.ThrowIfCancellationRequested();Calls.Add("classify");Classifications++;return Task.FromResult(ClassifiedFailure??failure);}
37
public Task EnsureReadyAsync(CancellationToken token){Calls.Add("ready");return Task.CompletedTask;}
38
public Task EnsureReadyAsync(CancellationToken token){Calls.Add("ready");OnReady?.Invoke();return Task.CompletedTask;}
38
39
public async Task DepartAsync(CancellationToken token)
39
40
{
40
41
Calls.Add("depart");if(Departure is not null)await Departure(token);
@@ -85,6 +86,13 @@ public sealed class FlightAutomationCoordinatorTests
85
86
Check.Equal(1,driver.Calls.Count(value=>value=="depart"));Check.Equal(1,driver.Calls.Count(value=>value=="plan"));
86
87
Check.That(driver.Calls.IndexOf("release-galaxy")<driver.Calls.IndexOf("select"));Check.Equal("release-local",driver.Calls[^1]);
87
88
}
89
[Test]public async Task DockingAtTheDestinationWhileReadinessRecoversDoesNotLaunchAnotherDeparture()
90
{
91
var driver=new Journey();driver.Player=driver.Player with{Docked=false,SystemName="System 2",Navigation=driver.Player.Navigation with{CurrentSystemAddress=2}};
92
driver.OnReady=()=>driver.Player=driver.Player with{Docked=true,MarketId=20};
93
var result=await new FlightAutomationCoordinator(()=>driver.Player).TravelToStationAsync(Destination,driver);
94
Check.Equal(NavigationRunState.Completed,result.State);Check.Equal("ready",string.Join(',',driver.Calls));
95
}
88
96
[Test]public async Task BothStationEntriesClassifyAFailedLocalLegOnlyAfterItsInputsAreReleased()
89
97
{
90
98
foreach(var trade in new[]{false,true})
@@ -6,6 +6,19 @@ namespace XFEEDToolBox.Test;
6
6
[TestFixture]
7
7
public class NativeDockingObservationTests
8
8
{
9
[Test]public void PhysicalDockingAndAnOriginalDutyRemainObservationOnlyWhenTheGrantHasTransitioned()
10
{
11
using var complete=System.Text.Json.JsonDocument.Parse("{\"verified\":true,\"clearanceGranted\":false,\"nativeDutyActive\":false,\"nativeLaunchActive\":false,\"nativeLaunchRequested\":false}");
12
Check.That(NativeSystemFlightObservation.ObserveOriginalArrival(complete.RootElement,5));
13
Check.That(NativeSystemFlightObservation.ObserveOriginalArrival(complete.RootElement,7));
14
Check.That(!NativeSystemFlightObservation.ObserveOriginalArrival(complete.RootElement,3));
15
using var active=System.Text.Json.JsonDocument.Parse("{\"verified\":true,\"clearanceGranted\":false,\"nativeDutyActive\":true}");
16
Check.That(NativeSystemFlightObservation.ObserveOriginalArrival(active.RootElement,4));
17
using var unverified=System.Text.Json.JsonDocument.Parse("{\"verified\":false,\"nativeDutyActive\":true}");
18
Check.That(!NativeSystemFlightObservation.ObserveOriginalArrival(unverified.RootElement,5));
19
using var launch=System.Text.Json.JsonDocument.Parse("{\"verified\":true,\"nativeDutyActive\":true,\"nativeLaunchActive\":true}");
20
Check.That(!NativeSystemFlightObservation.ObserveOriginalArrival(launch.RootElement,5));
21
}
9
22
[Test] public void LaunchCancellationIncludesRequestsWhichHaveNotStartedTheirNativeDuty()
10
23
{
11
24
using var pending=System.Text.Json.JsonDocument.Parse("{\"nativeLaunchActive\":false,\"nativeLaunchRequested\":true}");
@@ -35,6 +35,28 @@ public class SystemNavigationRunnerTests
35
35
var driver=new Fake();var runner=new SystemNavigationRunner(()=>Player,()=>Now);await runner.RunAsync(Target,driver);
36
36
Check.Equal(NavigationRunState.Completed,runner.State);Check.Equal("inspect,select,prepare,read,apply,release",string.Join(',',driver.Calls));
37
37
}
38
[Test]public async Task DockingAtTheSameMarketDuringPreparationCompletesWithoutReadingOrApplyingFlightInputs()
39
{
40
var player=Player;var driver=new Fake{OnPrepare=()=>player=player with{Docked=true,MarketId=20},Frame=_=>throw new InvalidOperationException("Docked physics need not keep updating.")};
41
var runner=new SystemNavigationRunner(()=>player,()=>Now);await runner.RunAsync(Target,driver);
42
Check.Equal(NavigationRunState.Completed,runner.State);Check.Equal("inspect,select,prepare,release",string.Join(',',driver.Calls));
43
}
44
[Test]public async Task ADockedArrivalNeverOverridesAChangedShipOrAnExternalCancellation()
45
{
46
foreach(var cancel in new[]{false,true})
47
{
48
using var stop=new CancellationTokenSource();var player=Player;var driver=new Fake{OnPrepare=()=>
49
{player=player with{Docked=true,MarketId=20};if(cancel)stop.Cancel();else player=player with{CurrentShipId=8};}};
50
var runner=new SystemNavigationRunner(()=>player,()=>Now);await runner.RunAsync(Target,driver,stop.Token);
51
Check.Equal(cancel?NavigationRunState.Stopped:NavigationRunState.Failed,runner.State);Check.Equal(0,driver.Calls.Count(call=>call=="read"||call=="apply"));
52
}
53
}
54
[Test]public async Task APreCancelledRequestAtTheDestinationDoesNotReportSuccessfulArrival()
55
{
56
using var stop=new CancellationTokenSource();stop.Cancel();var driver=new Fake();
57
var runner=new SystemNavigationRunner(()=>Player with{Docked=true,MarketId=20},()=>Now);await runner.RunAsync(Target,driver,stop.Token);
58
Check.Equal(NavigationRunState.Stopped,runner.State);Check.Equal("release",string.Join(',',driver.Calls));
59
}
38
60
[Test]public async Task RepeatedStillFreshPhysicsFrameWaitsWithoutReleasingControlOrReplayingInputs()
39
61
{
40
62
var at=Now;var driver=new Fake{Frame=n=>
@@ -13,11 +13,12 @@ public class TravelPreparationTests
13
13
public GameInputState Frame=new(1,42,DateTimeOffset.UtcNow,true,true,true);
14
14
public readonly List<string> Writes=[];
15
15
public Action? OnWrite;
16
public Action? OnRead;
16
17
public bool Refresh=true;
17
18
public int RefusedReads,Reads;
18
19
public string Refusal="Game input telemetry unavailable or stale";
19
20
public Task ConnectAsync(int thread,CancellationToken token)=>Task.CompletedTask;
20
public Task<GameInputState> ReadAsync(CancellationToken token){Reads++;if(RefusedReads-->0)throw new InvalidOperationException(Refusal);if(Refresh)Frame=Frame with{Sequence=Frame.Sequence+1,ObservedAt=DateTimeOffset.UtcNow};return Task.FromResult(Frame);}
21
public Task<GameInputState> ReadAsync(CancellationToken token){Reads++;OnRead?.Invoke();if(RefusedReads-->0)throw new InvalidOperationException(Refusal);if(Refresh)Frame=Frame with{Sequence=Frame.Sequence+1,ObservedAt=DateTimeOffset.UtcNow};return Task.FromResult(Frame);}
21
22
public Task<long> SetAsync(GameInputState frame,string input,bool value,CancellationToken token)
22
23
{
23
24
Check.That(!value);Writes.Add(input);OnWrite?.Invoke();
@@ -86,4 +87,60 @@ public class TravelPreparationTests
86
87
try{await TravelPreparation.EnsureRetractedAsync(c,()=>Player(1L<<6),CancellationToken.None,42);throw new Exception("Expected freshness check");}catch(FlightTelemetryUnavailableException){}
87
88
Check.Equal(0,c.Writes.Count);
88
89
}
90
private static readonly SystemNavigationTarget Arrival=new(1,10,"Elysian Fields",SystemTargetKind.Station,20);
91
private static SystemFlightFrame Landing=>new(){Sequence=1,ObservedAt=DateTimeOffset.UtcNow,InGame=true,GeometryVerified=true,ShipEntity=42,ReferenceWorld=10,
92
SelectedTarget=Arrival,SystemAddress=1,Mode=LocalFlightMode.Normal,Assists=new(false,true,false),DockingAssist=NativeAssistState.Engaged,
93
Clearance=new(20,DockingPermission.Granted,1,DateTimeOffset.UtcNow,DateTimeOffset.UtcNow.AddMilliseconds(300))};
94
[Test]public async Task ResumingAnOriginalLandingKeepsItsDeployedGearWithoutWritingAnyInput()
95
{
96
var channel=new Channel();var player=Player(4) with{CommanderId="F1",CurrentShipId=7,LoadGameAt=DateTimeOffset.UtcNow.AddHours(-1),Navigation=new(){CurrentSystemAddress=1}};
97
var retracted=await TravelPreparation.EnsureRetractedAsync(channel,()=>player,default,42,
98
_=>Task.FromResult(TravelPreparation.PreserveOriginalLanding(Landing,Arrival,DateTimeOffset.UtcNow)));
99
Check.That(!retracted);Check.Equal(0,channel.Reads);Check.Equal(0,channel.Writes.Count);
100
}
101
[Test]public async Task DockingOrOriginalHandoffDuringAnInputReadWinsBeforeAnyRetractionWrite()
102
{
103
foreach(var completes in new[]{false,true})
104
{
105
var player=Player(4) with{CommanderId="F1",CurrentShipId=7,LoadGameAt=DateTimeOffset.UtcNow.AddHours(-1),Navigation=new(){CurrentSystemAddress=1}};
106
var landing=false;var channel=new Channel{OnRead=()=>{landing=true;if(completes)player=player with{Docked=true,MarketId=20};}};
107
var retracted=await TravelPreparation.EnsureRetractedAsync(channel,()=>player,default,42,
108
_=>Task.FromResult(TravelPreparation.AtDockedDestination(player,Arrival)||landing&&TravelPreparation.PreserveOriginalLanding(Landing,Arrival,DateTimeOffset.UtcNow)));
109
Check.That(!retracted);Check.Equal(1,channel.Reads);Check.Equal(0,channel.Writes.Count);
110
}
111
}
112
[Test]public async Task ArrivalYieldCannotHideCancellationOrChangedShipHealthIdentity()
113
{
114
foreach(var cause in new[]{"cancel","commander","ship","session","hull","threat"})
115
{
116
using var stop=new CancellationTokenSource();var channel=new Channel();var player=Player(4) with{CommanderId="F1",CurrentShipId=7};
117
try
118
{
119
await TravelPreparation.EnsureRetractedAsync(channel,()=>player,stop.Token,42,async _=>
120
{
121
await Task.Delay(20);
122
if(cause=="cancel")stop.Cancel();
123
else if(cause=="commander")player=player with{CommanderId="other"};
124
else if(cause=="ship")player=player with{CurrentShipId=8};
125
else if(cause=="session")player=player with{LoadGameAt=DateTimeOffset.UtcNow};
126
else if(cause=="hull")player=player with{Ships=[new(){ShipId=7,HullHealth=0}]};
127
else player=player with{Navigation=new(){ExternalThreat=true}};
128
return true;
129
});
130
throw new Exception("An arrival must not override protection or user cancellation.");
131
}
132
catch(OperationCanceledException)when(cause=="cancel"){}
133
catch(InvalidOperationException)when(cause!="cancel"){}
134
Check.Equal(0,channel.Writes.Count);
135
}
136
}
137
[Test]public void OriginalLandingYieldRequiresFreshSameTargetClearanceAndNoManualOverride()
138
{
139
var frame=Landing;var now=DateTimeOffset.UtcNow;Check.That(TravelPreparation.PreserveOriginalLanding(frame,Arrival,now));
140
foreach(var invalid in new[]{frame with{PlayerOverride=true},frame with{ExternalThreat=true},frame with{DockingAssist=NativeAssistState.Ready},
141
frame with{ObservedAt=now.AddSeconds(-1)},frame with{SystemAddress=2},frame with{Clearance=frame.Clearance! with{MarketId=99}},
142
frame with{Clearance=frame.Clearance! with{ExpiresAt=now.AddMilliseconds(-1)}}})
143
Check.That(!TravelPreparation.PreserveOriginalLanding(invalid,Arrival,now));
144
Check.That(!TravelPreparation.AtDockedDestination(Player(4) with{Docked=true,MarketId=99,Navigation=new(){CurrentSystemAddress=1}},Arrival));
145
}
89
146
}