返回提交历史
Modified
README.md
+2
-2
Modified
Start-Toolbox.cmd
+8
-0
Modified
XFEEDToolBox.Core/Execution/AutomaticSupplyPlanner.cs
+1
-1
Modified
XFEEDToolBox.Core/Execution/AutomaticTradeEngine.cs
+46
-3
Added
XFEEDToolBox.Core/Execution/AutomaticTradeSafetyRecovery.cs
+78
-0
Modified
XFEEDToolBox.Core/Execution/NativeTradeCommandDriver.cs
+2
-1
Modified
XFEEDToolBox.Core/Execution/NativeTradeNavigation.cs
+2
-6
Modified
XFEEDToolBox.Core/Navigation/NativeSystemFlightControlDriver.cs
+12
-7
Modified
XFEEDToolBox.Core/Navigation/NavigationRunner.cs
+11
-6
Modified
XFEEDToolBox.Core/Navigation/NavigationSafety.cs
+10
-1
Added
XFEEDToolBox.Core/Navigation/StationDepartureTarget.cs
+18
-0
Modified
XFEEDToolBox.Core/Navigation/SystemNavigationRunner.cs
+4
-2
Added
XFEEDToolBox.Test/AutomaticTradeSafetyRecoveryTests.cs
+71
-0
Modified
XFEEDToolBox.Test/NavigationTests.cs
+7
-0
Added
XFEEDToolBox.Test/StationDepartureTargetTests.cs
+28
-0
Modified
XFEEDToolBox.Test/SystemNavigationRunnerTests.cs
+7
-0
Modified
XFEEDToolBox/ViewModels/AutomaticSupplyViewModel.cs
+2
-1
Modified
XFEEDToolBox/ViewModels/AutomaticTradeViewModel.cs
+1
-1
Modified
XFEEDToolBox/XFEEDToolBox.csproj
+1
-1
Added
docs/INTERFACE-0.4.25.md
+11
-0
Added
docs/VALIDATION-0.4.25.md
+11
-0
XFEstudio/XFEEDToolBox
fix: resume CG trading safely after station attacks
1c41e3a
代码差异
21 个文件
+333
-32
@@ -1,6 +1,6 @@
1
1
# XFE ED ToolBox
2
2
3
当前 0.4.24 修复 CG 重复跑商固定沿用货源的问题:按库存、需求、实际空舱和可用本金计算真实可采购吨数,将空载返程与必要转场计入 Cr/h;每轮交付后与采购前重新并行比较所有合格货源。已确认采购会扣减旧报价中的可用库存,避免反复访问已买空的站点;切换货源后继续累计已完成轮数。详见 [0.4.24 说明](docs/INTERFACE-0.4.24.md)。
3
当前 0.4.25 补全真实攻击后的安全停靠、维修及原订单接续,并暂缓近期遭到攻击的采购站。跨星系离场直接确认当前停靠站,不再要求另有空间站。保留原交易回执、轮数和收益;人工接管、身份变化及实际威胁保护继续生效。详见 [0.4.25 说明](docs/INTERFACE-0.4.25.md)。
4
4
5
5
0.4.23 修复当前星系原生 3D 合成层为零尺寸的空白问题,并将跑商改为全范围候选、并行计算、真实进度、采购来源类型筛选和按船型核对停机坪;CG 交付候选使用紫色及文字标识。详见 [0.4.23 说明](docs/INTERFACE-0.4.23.md)。当前星系仍支持真实坐标与左侧目录双向选择,实时商品保留报价详情、来源和完整采集时间;两页依据 Imagegen 设计稿实现。重复诊断会合并,跑商实际运行期间可恢复确切的游戏暂停菜单,手动暂停或停止任务后撤销恢复权限。既有范围见 [0.4.22 说明](docs/INTERFACE-0.4.22.md)及[当前星系与星系内驾驶](docs/CURRENT-SYSTEM-NAVIGATION.md)。
6
6
@@ -14,7 +14,7 @@ Elite Dangerous 的 WinUI 3 指挥官工具箱。当前版本提供实时数据
14
14
15
15
## 运行
16
16
17
双击仓库根目录的 `Start-Toolbox.cmd`,优先启动 `dist/XFEEDToolBox-0.4.24-win-x64/XFEEDToolBox.exe`。星图与导航已合并;输入星系名称可自动补全,也可选中/双击星点聚焦,长按恒星待外圈填满后由游戏规划,确认成功后点击“开始自动导航”。航路由游戏计算,软件校验并读取。主页与飞船编辑背景采用本地真实船体的旋转科技线稿。发布包面向 Windows x64,包含 .NET 运行时与 Windows App SDK;源码构建需要 PowerShell 7、.NET 10 SDK、Windows SDK 和 Visual Studio 的 C++ x64 构建工具。`--page Navigation --maximized` 可直接打开最大化星图,`--page Refit` 打开飞船编辑。
17
双击仓库根目录的 `Start-Toolbox.cmd`,优先启动 `dist/XFEEDToolBox-0.4.25-win-x64/XFEEDToolBox.exe`。星图与导航已合并;输入星系名称可自动补全,也可选中/双击星点聚焦,长按恒星待外圈填满后由游戏规划,确认成功后点击“开始自动导航”。航路由游戏计算,软件校验并读取。主页与飞船编辑背景采用本地真实船体的旋转科技线稿。发布包面向 Windows x64,包含 .NET 运行时与 Windows App SDK;源码构建需要 PowerShell 7、.NET 10 SDK、Windows SDK 和 Visual Studio 的 C++ x64 构建工具。`--page Navigation --maximized` 可直接打开最大化星图,`--page Refit` 打开飞船编辑。
18
18
19
19
```powershell
20
20
pwsh -File scripts/Build.ps1
@@ -2,6 +2,14 @@
2
2
setlocal
3
3
cd /d "%~dp0"
4
4
if not defined XFE_TOOLBOX_DATA_DIR if exist "work\toolbox-local-data-dir.txt" set /p "XFE_TOOLBOX_DATA_DIR="<"work\toolbox-local-data-dir.txt"
5
if exist "dist\XFEEDToolBox-0.4.25-win-x64\XFEEDToolBox.exe" (
6
if defined XFE_TOOLBOX_DATA_DIR (
7
start "" "dist\XFEEDToolBox-0.4.25-win-x64\XFEEDToolBox.exe" %* --data-dir "%XFE_TOOLBOX_DATA_DIR%"
8
exit /b
9
)
10
start "" "dist\XFEEDToolBox-0.4.25-win-x64\XFEEDToolBox.exe" %*
11
exit /b
12
)
5
13
if exist "dist\XFEEDToolBox-0.4.24-win-x64\XFEEDToolBox.exe" (
6
14
if defined XFE_TOOLBOX_DATA_DIR (
7
15
start "" "dist\XFEEDToolBox-0.4.24-win-x64\XFEEDToolBox.exe" %* --data-dir "%XFE_TOOLBOX_DATA_DIR%"
@@ -6,7 +6,7 @@ namespace XFEEDToolBox.Core.Execution;
6
6
7
7
public sealed record AutomaticSupplyRequest(AutomaticTradeConfiguration Configuration,PlayerState Player,decimal Credits,decimal Reserve,
8
8
IReadOnlyDictionary<long,IReadOnlyList<CommodityQuote>> ObservedQuotes)
9
{public IReadOnlyList<ConfirmedTradeReceipt> ConfirmedTrades{get;init;}=[];}
9
{public IReadOnlyList<ConfirmedTradeReceipt> ConfirmedTrades{get;init;}=[];public IReadOnlySet<long> ExcludedSourceMarkets{get;init;}=new HashSet<long>();}
10
10
11
11
public sealed record AutomaticSupplyChoice(Station Source,TradeLeg Delivery,long Supply,long Demand,int FreeCapacity,
12
12
double EmptyReturnMinutes,double RepositionMinutes,decimal EstimatedNetProfit,double EstimatedMinutes)
@@ -31,6 +31,7 @@ public sealed class AutomaticTradeEngine(AutomaticTradeConfiguration configurati
31
31
private readonly string root=configuration.WorkingDirectory;
32
32
private Station source=configuration.Source;
33
33
private AutomaticSupplyChoice? supplySelection;
34
private readonly Dictionary<long,TradeSourceThreat> sourceThreats=[];
34
35
private sealed record SelectedSource(int Schema,long Destination,ulong? Goal,AutomaticSupplyChoice Choice);
35
36
36
37
private async Task WriteAsync(string path,object value,CancellationToken token)
@@ -63,9 +64,40 @@ public sealed class AutomaticTradeEngine(AutomaticTradeConfiguration configurati
63
64
{
64
65
token.ThrowIfCancellationRequested();var current=player();
65
66
if(!current.InGame||current.CommanderId!=initial.CommanderId||current.CurrentShipId!=initial.CurrentShipId||current.CargoCapacity!=initial.CargoCapacity
66
||NavigationSafety.UnhealthyHull(current)||NavigationSafety.HasExternalThreat(current))throw new InvalidOperationException("本轮运行期间飞船、货舱、会话或安全状态改变,已交还控制。");
67
||NavigationSafety.UnhealthyHull(current))throw new InvalidOperationException("本轮运行期间飞船、货舱、会话或安全状态改变,已交还控制。");
68
if(NavigationSafety.HasExternalThreat(current))throw new FlightThreatPauseException();
67
69
await beforeInput(token).ConfigureAwait(false);
68
70
}
71
private async Task WaitForSafetyAsync(Station destination,CancellationToken token)
72
{
73
var originalPhase=phase;RevokeMenuRecovery();await driver.ReleaseAllAsync().ConfigureAwait(false);
74
if(destination.MarketId!=configuration.Destination.MarketId&&configuration.UsesDynamicSources)
75
{
76
var now=DateTimeOffset.UtcNow;sourceThreats[destination.MarketId]=new(destination.MarketId,destination.Name,now,now.AddHours(1));
77
await WriteAsync(Path.Combine(root,"source-threats.json"),sourceThreats.Values.ToArray(),token);
78
}
79
await StateAsync("safety-pause","实际威胁已暂停驾驶输入;等待游戏自动降落、安全状态及连续健康确认,不在太空中强行恢复。",destination,token);
80
var safety=new AutomaticTradeSafetyGate(initial);
81
while(true)
82
{
83
token.ThrowIfCancellationRequested();var current=player();
84
var ready=false;
85
// Observe first without arming any input while threat/flight remains.
86
safety.RequireSameIdentity(current);
87
if(current.InGame&¤t.Docked&¤t.MarketId is >0&&!NavigationSafety.HasExternalThreat(current)&&!NavigationSafety.UnhealthyHull(current))
88
{
89
try{await beforeInput(token).ConfigureAwait(false);ready=true;}
90
catch(FlightTelemetryUnavailableException){/* Actual damage must stabilize before authorization. */}
91
}
92
if(safety.Observe(current,DateTimeOffset.UtcNow,ready))break;
93
await Task.Delay(500,token).ConfigureAwait(false);
94
}
95
var dock=player();await StateAsync("recovering-safety","已连续确认安全停靠与健康监测,正在核对原回执并完成站内维修。",token:token);
96
await driver.ServiceDockedAsync(new(dock.MarketId!.Value,initial.CargoCapacity),CheckAsync,token).ConfigureAwait(false);
97
if(recoverToSameMode is not null)await RecoverAsync(token).ConfigureAwait(false);
98
else BeginMenuRecovery(token);
99
await StateAsync(originalPhase,"安全停靠、维修及原会话恢复已确认,继续原订单;近期遭遇攻击的采购站暂缓一小时。",token:token);
100
}
69
101
private async Task RecoverAsync(CancellationToken token)
70
102
{
71
103
if(recoverToSameMode is null)throw new InvalidOperationException("原始返回主菜单恢复未就绪,当前现场已保留。");
@@ -107,6 +139,7 @@ public sealed class AutomaticTradeEngine(AutomaticTradeConfiguration configurati
107
139
try{await driver.ExecuteAsync(new(TradeActionKind.Travel,new(){From=player().MarketId==source.MarketId?source:configuration.Destination,To=destination},null),CheckAsync,token).ConfigureAwait(false);RefreshCompleted();return;}
108
140
catch(DockingHandoffException)when(attempt<3&&recoverToSameMode is not null){await RecoverAsync(token).ConfigureAwait(false);}
109
141
catch(StationDepartureRecoveryException)when(attempt<3&&recoverToSameMode is not null){await RecoverAsync(token).ConfigureAwait(false);}
142
catch(Exception error)when(FlightThreatPauseException.Present(error)&&!token.IsCancellationRequested){await WaitForSafetyAsync(destination,token).ConfigureAwait(false);}
110
143
catch(Exception error)when(NavigationRecovery.CanRetry(error,token))
111
144
{await StateAsync("travelling","正在衔接下一段航程,自动继续",destination,token);await Task.Delay(100,token).ConfigureAwait(false);}
112
145
}
@@ -141,7 +174,7 @@ public sealed class AutomaticTradeEngine(AutomaticTradeConfiguration configurati
141
174
var receipts=ConfirmedSupplyTrades();
142
175
var choice=await selectSource(new(configuration with{Source=source},current,wallet.Credits,
143
176
configuration.PurchaseReserve(wallet.Credits,current.Rebuy,services),AutomaticSupplyPlanner.DeductConfirmedTrades(quotes,receipts))
144
{ConfirmedTrades=receipts},token).ConfigureAwait(false);
177
{ConfirmedTrades=receipts,ExcludedSourceMarkets=sourceThreats.Values.Where(i=>i.AvoidUntil>DateTimeOffset.UtcNow).Select(i=>i.MarketId).ToHashSet()},token).ConfigureAwait(false);
145
178
if(choice is null)
146
179
{
147
180
await StateAsync("waiting-market","当前合格货源的供应、需求或资金不足,保持停靠等待新报价;未沿用已耗尽的货源。",token:token);
@@ -198,6 +231,7 @@ public sealed class AutomaticTradeEngine(AutomaticTradeConfiguration configurati
198
231
try{await journey.RunAsync(leg,token).ConfigureAwait(false);break;}
199
232
catch(NativeTradeJourneyIncompleteException error)when(error.InnerException is DockingHandoffException&&attempt<3&&recoverToSameMode is not null){await RecoverAsync(token).ConfigureAwait(false);}
200
233
catch(NativeTradeJourneyIncompleteException error)when(error.InnerException is StationDepartureRecoveryException&&attempt<3&&recoverToSameMode is not null){await RecoverAsync(token).ConfigureAwait(false);}
234
catch(NativeTradeJourneyIncompleteException error)when(FlightThreatPauseException.Present(error)&&!token.IsCancellationRequested){await WaitForSafetyAsync(leg.To,token).ConfigureAwait(false);}
201
235
catch(NativeTradeJourneyIncompleteException error)when(error.InnerException is {} cause&&NavigationRecovery.CanRetry(cause,token))
202
236
{await StateAsync("cargo-journey","正在衔接下一段航程,保留原货物继续",leg.To,token);await Task.Delay(100,token).ConfigureAwait(false);}
203
237
}
@@ -236,6 +270,12 @@ public sealed class AutomaticTradeEngine(AutomaticTradeConfiguration configurati
236
270
||saved.Choice.Source.MarketId==configuration.Destination.MarketId)throw new InvalidDataException("已保存的CG货源与本次交付活动不一致。");
237
271
source=saved.Choice.Source;
238
272
}
273
var threatsPath=Path.Combine(root,"source-threats.json");
274
if(File.Exists(threatsPath))foreach(var incident in JsonSerializer.Deserialize<TradeSourceThreat[]>(await File.ReadAllTextAsync(threatsPath,token),ToolboxSettings.JsonOptions)??[])
275
if(incident.AvoidUntil>DateTimeOffset.UtcNow)sourceThreats[incident.MarketId]=incident;
276
foreach(var incident in TradeSourceThreatJournal.Read(journalDirectory,initial,DateTimeOffset.UtcNow))
277
if(incident.MarketId!=configuration.Destination.MarketId&&(!sourceThreats.TryGetValue(incident.MarketId,out var previous)||previous.AttackedAt<incident.AttackedAt))sourceThreats[incident.MarketId]=incident;
278
if(sourceThreats.Count>0)await WriteAsync(threatsPath,sourceThreats.Values.ToArray(),token);
239
279
RefreshCompleted();await StateAsync("preflight","正在核对当前飞船和交易接口",token:token);
240
280
if(!initial.InGame||initial.CurrentShipId is null||initial.CargoCapacity<=0||initial.Rebuy<=0||!initial.CargoReliable)
241
281
throw new InvalidOperationException("当前飞船、实际货舱、重购费或货物观测尚未就绪。");
@@ -247,6 +287,8 @@ public sealed class AutomaticTradeEngine(AutomaticTradeConfiguration configurati
247
287
await driver.PreflightAsync(token).ConfigureAwait(false);
248
288
if(validateFlight is not null)await validateFlight(token).ConfigureAwait(false);
249
289
BeginMenuRecovery(token);
290
if(initial.Docked&&initial.MarketId is{} resumedThreatMarket&&sourceThreats.ContainsKey(resumedThreatMarket))
291
await WaitForSafetyAsync(source,token).ConfigureAwait(false);
250
292
if(player().Docked&&player().MarketId is {} currentMarket&¤tMarket!=source.MarketId&¤tMarket!=configuration.Destination.MarketId)
251
293
await driver.ServiceDockedAsync(new(currentMarket,initial.CargoCapacity),CheckAsync,token).ConfigureAwait(false);
252
294
await WriteAsync(Path.Combine(root,"current-ship.json"),new{at=DateTimeOffset.UtcNow,initial.CommanderId,initial.CurrentShipId,initial.Ship,initial.CargoCapacity,
@@ -277,7 +319,8 @@ public sealed class AutomaticTradeEngine(AutomaticTradeConfiguration configurati
277
319
var cycles=0;
278
320
while(!token.IsCancellationRequested)
279
321
{
280
await CheckAsync(token);
322
try{await CheckAsync(token);}
323
catch(FlightThreatPauseException){await WaitForSafetyAsync(player().MarketId==configuration.Destination.MarketId?configuration.Destination:source,token);continue;}
281
324
var atDestination=player().Docked&&player().MarketId==configuration.Destination.MarketId;
282
325
// Complete any configured return trade to this batch's home
283
326
// before comparing suppliers for a new outbound batch.
@@ -0,0 +1,78 @@
1
using System.Text.Json;
2
using XFEEDToolBox.Core.Models;
3
using XFEEDToolBox.Core.Navigation;
4
5
namespace XFEEDToolBox.Core.Execution;
6
7
/// <summary>Does not authorize inputs: the continuous native health guard is required separately.</summary>
8
public sealed class AutomaticTradeSafetyGate(PlayerState initial)
9
{
10
private DateTimeOffset? safeSince;
11
private long? safeMarket;
12
public TimeSpan StableDuration{get;init;}=TimeSpan.FromSeconds(10);
13
public void Reset(){safeSince=null;safeMarket=null;}
14
public void RequireSameIdentity(PlayerState current)
15
{
16
if(current.CommanderId!=initial.CommanderId||current.CurrentShipId!=initial.CurrentShipId||current.CargoCapacity!=initial.CargoCapacity
17
||current.LoadGameAt!=initial.LoadGameAt)
18
throw new InvalidOperationException("安全等待期间身份、飞船、货舱或会话已改变,不能恢复旧控制。");
19
}
20
public bool Observe(PlayerState current,DateTimeOffset now,bool continuousHealthReady)
21
{
22
RequireSameIdentity(current);
23
if(!current.InGame||!current.Docked||current.MarketId is not >0||!current.CargoReliable||!current.CreditsReliable
24
||current.StatusReadAt is not{} read||now-read>TimeSpan.FromSeconds(2)||read>now.AddSeconds(1)
25
||NavigationSafety.UnhealthyHull(current)||NavigationSafety.HasExternalThreat(current)||!continuousHealthReady)
26
{Reset();return false;}
27
if(safeMarket!=current.MarketId){Reset();safeMarket=current.MarketId;}
28
safeSince??=now;return now-safeSince>=StableDuration;
29
}
30
}
31
32
public sealed record TradeSourceThreat(long MarketId,string StationName,DateTimeOffset AttackedAt,DateTimeOffset AvoidUntil);
33
34
/// <summary>Bounded, read-only evidence of attacks during a granted station approach, scoped to this commander and ship.</summary>
35
public static class TradeSourceThreatJournal
36
{
37
public static IReadOnlyList<TradeSourceThreat> Read(string directory,PlayerState player,DateTimeOffset now)
38
{
39
if(!Directory.Exists(directory))return [];
40
var incidents=new List<TradeSourceThreat>();
41
foreach(var path in Directory.EnumerateFiles(directory,"Journal*.log").OrderByDescending(File.GetLastWriteTimeUtc).Take(2))
42
{
43
try
44
{
45
using var file=new FileStream(path,FileMode.Open,FileAccess.Read,FileShare.ReadWrite|FileShare.Delete);
46
using var header=new StreamReader(file,leaveOpen:true);string? commander=null;long? ship=null;
47
for(var lineIndex=0;lineIndex<20&&header.ReadLine() is{} line;lineIndex++)
48
{
49
using var doc=JsonDocument.Parse(line);var row=doc.RootElement;
50
if(row.TryGetProperty("event",out var kind)&&kind.GetString()=="LoadGame")
51
{commander=row.GetProperty("FID").GetString();ship=row.GetProperty("ShipID").GetInt64();break;}
52
}
53
if(commander!=player.CommanderId||ship!=player.CurrentShipId)continue;
54
file.Seek(Math.Max(0,file.Length-262144),SeekOrigin.Begin);
55
using var tail=new StreamReader(file,leaveOpen:true);if(file.Position>0)tail.ReadLine();
56
long market=0;string station="";DateTimeOffset? granted=null;
57
while(tail.ReadLine() is{} line)
58
{
59
try
60
{
61
using var doc=JsonDocument.Parse(line);var row=doc.RootElement;
62
var kind=row.GetProperty("event").GetString();var at=row.GetProperty("timestamp").GetDateTimeOffset();
63
if(kind is "FSDJump" or "StartJump" or "Undocked"){market=0;granted=null;}
64
if(kind=="Loadout"&&row.TryGetProperty("ShipID",out var id))ship=id.GetInt64();
65
if(kind=="DockingGranted")
66
{market=row.GetProperty("MarketID").GetInt64();station=row.GetProperty("StationName").GetString()??"";granted=at;}
67
if(kind=="UnderAttack"&&ship==player.CurrentShipId&&market>0&&row.TryGetProperty("Target",out var target)&&target.GetString()=="You"
68
&&granted is{} permission&&at>=permission&&at-permission<TimeSpan.FromMinutes(10)&&at>now.AddHours(-1)&&at<=now.AddMinutes(1))
69
incidents.Add(new(market,station,at,at.AddHours(1)));
70
}
71
catch(Exception error)when(error is JsonException or KeyNotFoundException or FormatException or InvalidOperationException){/* Invalid appends are not combat witnesses. */}
72
}
73
}
74
catch(Exception error)when(error is IOException or UnauthorizedAccessException or JsonException or KeyNotFoundException or FormatException or InvalidOperationException){/* Missing evidence cannot authorize recovery. */}
75
}
76
return incidents.GroupBy(i=>i.MarketId).Select(g=>g.MaxBy(i=>i.AttackedAt)!).ToArray();
77
}
78
}
@@ -105,8 +105,9 @@ public sealed class NativeTradeCommandDriver:IConfirmedTradeCommandDriver,IConfi
105
105
{
106
106
var current=player();var initial=session??throw new InvalidOperationException("请先核对原生交易会话。");
107
107
if(uncertain||!current.InGame||current.CommanderId!=initial.CommanderId||current.CurrentShipId!=initial.CurrentShipId||current.LoadGameAt!=initial.LoadGameAt
108
||current.CurrentShipId is null||current.LoadGameAt is null||NavigationSafety.UnhealthyHull(current)||NavigationSafety.HasExternalThreat(current)
108
||current.CurrentShipId is null||current.LoadGameAt is null||NavigationSafety.UnhealthyHull(current)
109
109
||market is not null&&(!current.Docked||current.MarketId!=market))throw new InvalidOperationException("当前船舶、停靠、会话或订单状态不适合继续交易。");
110
if(NavigationSafety.HasExternalThreat(current))throw new FlightThreatPauseException();
110
111
return current;
111
112
}
112
113
private void PublishWallet(JsonElement value)
@@ -66,14 +66,10 @@ public sealed class NativeTradeNavigation(int processId,string gameHash,Func<Pla
66
66
{
67
67
if(initial.Docked)
68
68
{
69
using var source=new NativeCurrentSystemSource(processId,gameHash,player);
70
var catalogue=await NavigationRecovery.ReadWithRecoveryAsync(source.ReadLocationsAsync,null,token);
71
var alternate=catalogue.Locations.FirstOrDefault(value=>value.Category==SystemLocationCategory.Station&&value.MarketId is >0&&value.MarketId!=initial.MarketId)
72
??throw new InvalidOperationException("当前目录尚未提供可确认的离场目标。");
73
69
var departure=await LocalAsync(token);
74
70
try
75
71
{
76
Status?.Invoke("正在自动离场");await departure.SelectAsync(Target(catalogue.SystemAddress,alternate),token);await departure.PrepareAsync(token);
72
Status?.Invoke("正在自动离场");await WithFlightSafetyAsync(()=>departure.DepartAsync(token));
77
73
if(player().Docked||player().Navigation.UndockedAt is null)throw new InvalidDataException("游戏尚未确认实际离场。");
78
74
}
79
75
finally{await departure.ReleaseSystemFlightAsync();}
@@ -90,7 +86,7 @@ public sealed class NativeTradeNavigation(int processId,string gameHash,Func<Pla
90
86
route.Progress+=value=>{issue=value.Message;Status?.Invoke(value.Message);};
91
87
await WithFlightSafetyAsync(()=>route.RunAsync(destination.SystemName,interstellar,token));
92
88
if(route.State!=NavigationRunState.Completed||player().Navigation.CurrentSystemAddress!=waypoint.SystemAddress)
93
throw new InvalidOperationException(issue??"星际航段尚未确认完成。");
89
throw new InvalidOperationException(issue??"星际航段尚未确认完成。",route.Failure);
94
90
}
95
91
finally{await interstellar.ReleaseAsync();}
96
92
}
@@ -119,21 +119,25 @@ public sealed partial class NativeSystemFlightControlDriver(int processId,string
119
119
}
120
120
}
121
121
}
122
private async Task DepartDockedStationAsync(CancellationToken token)
122
public async Task DepartAsync(CancellationToken token)
123
{
124
if(ensureFlightProtection is not null)await ensureFlightProtection(token);
125
await DepartDockedStationAsync(token,restoreDestination:false);
126
}
127
private async Task DepartDockedStationAsync(CancellationToken token,bool restoreDestination=true)
123
128
{
124
129
var destination=selected!;var initial=player();
125
130
if(!NavigationAssists.From(initial).AdvancedDocking)throw new NotSupportedException("当前飞船缺少已启用的高级自动起降模块,手动离场尚未完成验证。");
126
131
using var source=new NativeCurrentSystemSource(processId,gameHash,player);var catalogue=await source.ReadLocationsAsync(token);
127
var station=catalogue.Locations.SingleOrDefault(x=>x.MarketId==initial.MarketId)??throw new FlightTelemetryUnavailableException("正在恢复当前停靠站点。");
128
var origin=new SystemNavigationTarget(catalogue.SystemAddress,station.WorldAddress,station.Name,SystemTargetKind.Station,station.MarketId)
129
{NativeKey=station.Key,NativeKind=station.NativeKind,NativeIdentity=station.Identity};
132
var origin=StationDepartureTarget.Resolve(catalogue,initial);
130
133
await SelectAsync(origin,token);var started=DateTimeOffset.UtcNow;var watch=Stopwatch.StartNew();await RequestLaunchAsync(token);
131
134
var engaged=false;
132
135
while(true)
133
136
{
134
137
token.ThrowIfCancellationRequested();var state=player();
135
if(state.CurrentShipId!=initial.CurrentShipId||state.LoadGameAt!=initial.LoadGameAt||state.CommanderId!=initial.CommanderId||NavigationSafety.UnhealthyHull(state)||NavigationSafety.HasExternalThreat(state))
138
if(state.CurrentShipId!=initial.CurrentShipId||state.LoadGameAt!=initial.LoadGameAt||state.CommanderId!=initial.CommanderId||NavigationSafety.UnhealthyHull(state))
136
139
throw new InvalidOperationException("离场中的飞船或安全状态改变。");
140
if(NavigationSafety.HasExternalThreat(state))throw new FlightThreatPauseException();
137
141
StationDepartureWatchdog.Check(initial.MarketId!.Value,watch.Elapsed);
138
142
try
139
143
{
@@ -155,7 +159,7 @@ public sealed partial class NativeSystemFlightControlDriver(int processId,string
155
159
catch(Exception error)when(NavigationRecovery.CanRetry(error,token)){await SuspendSystemFlightAsync();}
156
160
await Task.Delay(80,token);
157
161
}
158
await ReleaseSystemFlightAsync();await SelectAsync(destination,token);
162
await ReleaseSystemFlightAsync();if(restoreDestination)await SelectAsync(destination,token);
159
163
}
160
164
private static void MatchTarget(JsonElement actual,SystemNavigationTarget target)
161
165
{
@@ -424,8 +428,9 @@ public sealed partial class NativeSystemFlightControlDriver(int processId,string
424
428
{
425
429
token.ThrowIfCancellationRequested();
426
430
var current=player();
427
if(current.CurrentShipId!=state.CurrentShipId||current.LoadGameAt!=state.LoadGameAt||current.CommanderId!=state.CommanderId||NavigationSafety.UnhealthyHull(current)||NavigationSafety.HasExternalThreat(current))
431
if(current.CurrentShipId!=state.CurrentShipId||current.LoadGameAt!=state.LoadGameAt||current.CommanderId!=state.CommanderId||NavigationSafety.UnhealthyHull(current))
428
432
throw new InvalidOperationException("起飞前的飞船或安全状态改变。");
433
if(NavigationSafety.HasExternalThreat(current))throw new FlightThreatPauseException();
429
434
if(!current.Docked&¤t.Navigation.UndockedAt>=requested.AddSeconds(-1))return;
430
435
try
431
436
{
@@ -42,10 +42,12 @@ public sealed class NavigationRunner(Func<PlayerState> player,Func<DateTimeOffse
42
42
private readonly object gate=new();
43
43
private CancellationTokenSource? cancellation;
44
44
public NavigationRunState State { get; private set; }
45
public Exception? Failure {get;private set;}
45
46
public event Action<NavigationProgress>? Progress;
46
47
public void Stop(){lock(gate)cancellation?.Cancel();}
47
48
public async Task RunAsync(string destination,IFlightControlDriver driver,CancellationToken token=default)
48
49
{
50
Failure=null;
49
51
CancellationTokenSource stop;
50
52
lock(gate)
51
53
{
@@ -68,14 +70,16 @@ public sealed class NavigationRunner(Func<PlayerState> player,Func<DateTimeOffse
68
70
IReadOnlyList<NavWaypoint> Remaining(NavigationJournalState nav)=>NavigationProjector.RemainingRoute(nav,destination);
69
71
var remaining=Remaining(initial.Navigation);
70
72
if(remaining.Count==0){Report(NavigationRunState.Completed,"已抵达","已到达目标星系。");return;}
71
if(!initial.InGame||initial.Docked||NavigationSafety.HasExternalThreat(initial))throw new InvalidOperationException("请在可控、安全的飞行状态下启动导航。");
73
if(!initial.InGame||initial.Docked)throw new InvalidOperationException("请在可控、安全的飞行状态下启动导航。");
74
if(NavigationSafety.HasExternalThreat(initial))throw new FlightThreatPauseException();
72
75
var route=initial.Navigation.Route.Select(w=>w.SystemAddress).ToArray();
73
76
var finalWaypoint=initial.Navigation.Route[^1];
74
77
void RequireSameFlight()
75
78
{
76
79
var state=player();
77
if(NavigationSafety.UnhealthyHull(state)||!state.InGame||state.Docked||NavigationSafety.HasExternalThreat(state))
80
if(NavigationSafety.UnhealthyHull(state)||!state.InGame||state.Docked)
78
81
throw new InvalidOperationException("游戏会话或飞船安全状态已改变,已释放导航控制。");
82
if(NavigationSafety.HasExternalThreat(state))throw new FlightThreatPauseException();
79
83
if(!route.SequenceEqual(state.Navigation.Route.Select(w=>w.SystemAddress)))
80
84
throw new InvalidOperationException("游戏航线已变更,已停止导航。");
81
85
}
@@ -116,7 +120,8 @@ public sealed class NavigationRunner(Func<PlayerState> player,Func<DateTimeOffse
116
120
{
117
121
stop.Token.ThrowIfCancellationRequested();var state=player();
118
122
if(NavigationSafety.UnhealthyHull(state))throw new InvalidOperationException("船体已失效,已停止自动导航。");
119
if(!state.InGame||NavigationSafety.HasExternalThreat(state))throw new InvalidOperationException("游戏会话结束或出现战斗/拦截威胁,已停止导航。");
123
if(!state.InGame)throw new InvalidOperationException("游戏会话结束,已停止导航。");
124
if(NavigationSafety.HasExternalThreat(state))throw new FlightThreatPauseException();
120
125
if(!route.SequenceEqual(state.Navigation.Route.Select(w=>w.SystemAddress)))
121
126
{
122
127
if(!ExpectedFinalClear(state.Navigation))throw new InvalidOperationException("游戏航线已变更,已停止导航。");
@@ -215,12 +220,12 @@ public sealed class NavigationRunner(Func<PlayerState> player,Func<DateTimeOffse
215
220
}
216
221
}
217
222
catch(OperationCanceledException)when(stop.IsCancellationRequested){Report(NavigationRunState.Stopped,"—","自动导航已停止。");}
218
catch(OperationCanceledException){Report(NavigationRunState.Failed,lastNext,"原生飞行遥测超时,已释放控制。");}
219
catch(Exception e){Report(NavigationRunState.Failed,lastNext,e.Message);}
223
catch(OperationCanceledException e){Failure=e;Report(NavigationRunState.Failed,lastNext,"原生飞行遥测超时,已释放控制。");}
224
catch(Exception e){Failure=e;Report(NavigationRunState.Failed,lastNext,e.Message);}
220
225
finally
221
226
{
222
227
try{await driver.ReleaseAsync();}
223
catch(Exception e){terminal=new(NavigationRunState.Failed,destination,"—","释放自动控制失败:"+e.Message);}
228
catch(Exception e){Failure=e;terminal=new(NavigationRunState.Failed,destination,"—","释放自动控制失败:"+e.Message);}
224
229
finally
225
230
{
226
231
lock(gate){cancellation=null;}stop.Dispose();
@@ -1,6 +1,14 @@
1
1
using XFEEDToolBox.Core.Models;
2
2
namespace XFEEDToolBox.Core.Navigation;
3
3
4
/// <summary>A genuine threat pauses automation; ownership, identity and protocol failures are not retryable through this path.</summary>
5
public sealed class FlightThreatPauseException():InvalidOperationException("检测到实际攻击或拦截威胁,已释放自动驾驶,等待安全停靠。")
6
{
7
public static bool Present(Exception error)=>error is FlightThreatPauseException
8
||error is AggregateException aggregate&&aggregate.InnerExceptions.Any(Present)
9
||error.InnerException is{} inner&&Present(inner);
10
}
11
4
12
/// <summary>Separates combat/interdiction from the game's earlier internal-heat warning.</summary>
5
13
public static class NavigationSafety
6
14
{
@@ -15,8 +23,9 @@ public static class NavigationSafety
15
23
public static void RequireLocalSelection(PlayerState player,long systemAddress)
16
24
{
17
25
var hull=UnhealthyHull(player);var threat=HasExternalThreat(player);
18
if(!player.InGame||hull||threat)
26
if(!player.InGame||hull)
19
27
throw new InvalidOperationException($"自动驾驶目标选择被安全状态拒绝:inGame={player.InGame}, unhealthyHull={hull}, externalThreat={threat}。");
28
if(threat)throw new FlightThreatPauseException();
20
29
if(player.Navigation.CurrentSystemAddress!=systemAddress)
21
30
throw new FlightTelemetryUnavailableException("正在等待跃迁后的原始目录与当前星系一致。",recovery:FlightRecovery.ReselectTarget);
22
31
}
@@ -0,0 +1,18 @@
1
using XFEEDToolBox.Core.Models;
2
3
namespace XFEEDToolBox.Core.Navigation;
4
5
public static class StationDepartureTarget
6
{
7
public static SystemNavigationTarget Resolve(CurrentSystemSnapshot catalogue,PlayerState player)
8
{
9
if(!player.InGame||!player.Docked||player.MarketId is not >0)
10
throw new InvalidOperationException("离场前须确认当前实际停靠站点。");
11
NavigationSafety.RequireLocalSelection(player,catalogue.SystemAddress);
12
var station=catalogue.Locations.SingleOrDefault(row=>row.MarketId==player.MarketId)
13
??throw new FlightTelemetryUnavailableException("正在恢复当前停靠站点。");
14
var target=new SystemNavigationTarget(catalogue.SystemAddress,station.WorldAddress,station.Name,SystemTargetKind.Station,station.MarketId)
15
{NativeKey=station.Key,NativeKind=station.NativeKind,NativeIdentity=station.Identity};
16
target.Validate();return target;
17
}
18
}
@@ -43,8 +43,9 @@ public sealed class SystemNavigationRunner(Func<PlayerState> player,Func<DateTim
43
43
try
44
44
{
45
45
var initial=player();var initialShip=initial.CurrentShipId;var initialCommander=initial.CommanderId;var initialSession=initial.LoadGameAt;
46
if(!initial.InGame||initial.CurrentShipId is null||initial.Navigation.CurrentSystemAddress!=target.SystemAddress||NavigationSafety.UnhealthyHull(initial)||NavigationSafety.HasExternalThreat(initial))
46
if(!initial.InGame||initial.CurrentShipId is null||initial.Navigation.CurrentSystemAddress!=target.SystemAddress||NavigationSafety.UnhealthyHull(initial))
47
47
throw new InvalidOperationException("请先进入健康、安全的飞船驾驶状态。");
48
if(NavigationSafety.HasExternalThreat(initial))throw new FlightThreatPauseException();
48
49
var capabilities=await driver.InspectAsync(stop.Token);
49
50
if(!capabilities.Supports(target,NavigationAssists.From(initial)))throw new NotSupportedException("当前游戏接口尚未完成此类星系内驾驶的验证。");
50
51
await driver.SelectAsync(target,stop.Token);
@@ -56,7 +57,8 @@ public sealed class SystemNavigationRunner(Func<PlayerState> player,Func<DateTim
56
57
{
57
58
stop.Token.ThrowIfCancellationRequested();var current=player();
58
59
if(!current.InGame||current.CurrentShipId!=initialShip||current.CommanderId!=initialCommander||current.LoadGameAt!=initialSession
59
||NavigationSafety.UnhealthyHull(current)||NavigationSafety.HasExternalThreat(current))throw new InvalidOperationException("飞船、会话或安全状态改变,已停止星系内导航。");
60
||NavigationSafety.UnhealthyHull(current))throw new InvalidOperationException("飞船、会话或安全状态改变,已停止星系内导航。");
61
if(NavigationSafety.HasExternalThreat(current))throw new FlightThreatPauseException();
60
62
SystemFlightFrame frame;
61
63
try
62
64
{