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

XFEEDToolBox

【WinUI】XFE精英危险工具箱

公开
关注 0 Fork 0 Star 0
返回提交历史

XFEstudio/XFEEDToolBox

fix: resume CG trading safely after station attacks

1c41e3a
XFE工作室室长 <mail@xfegzs.com>
提交于

代码差异

21 个文件 +333 -32
Modified README.md +2 -2
@@ -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
Modified Start-Toolbox.cmd +8 -0
@@ -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%"
Modified XFEEDToolBox.Core/Execution/AutomaticSupplyPlanner.cs +1 -1
@@ -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)
Modified XFEEDToolBox.Core/Execution/AutomaticTradeEngine.cs +46 -3
@@ -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&&current.Docked&&current.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&&currentMarket!=source.MarketId&&currentMarket!=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.
Added XFEEDToolBox.Core/Execution/AutomaticTradeSafetyRecovery.cs +78 -0
@@ -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 }
Modified XFEEDToolBox.Core/Execution/NativeTradeCommandDriver.cs +2 -1
@@ -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)
Modified XFEEDToolBox.Core/Execution/NativeTradeNavigation.cs +2 -6
@@ -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 }
Modified XFEEDToolBox.Core/Navigation/NativeSystemFlightControlDriver.cs +12 -7
@@ -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&&current.Navigation.UndockedAt>=requested.AddSeconds(-1))return;
430 435 try
431 436 {
Modified XFEEDToolBox.Core/Navigation/NavigationRunner.cs +11 -6
@@ -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();
Modified XFEEDToolBox.Core/Navigation/NavigationSafety.cs +10 -1
@@ -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 }
Added XFEEDToolBox.Core/Navigation/StationDepartureTarget.cs +18 -0
@@ -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 }
Modified XFEEDToolBox.Core/Navigation/SystemNavigationRunner.cs +4 -2
@@ -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 {
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