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

XFEEDToolBox

【WinUI】XFE精英危险工具箱

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

XFEstudio/XFEEDToolBox

Fix automatic trade recovery and deliver toolbox 0.4.19

Wait for fresh health and game readiness instead of failing during transitions. Reconcile journal-proven manual sales against fresh original server cargo; preserve failed native receipts and keep financial differences separate from trade completion. Recover server-error sessions through the official client without replaying uncertain orders. Keep the trade button available during background data refreshes, show this run's profit and quote estimates, and wait for known station fines to clear. Publish the self-contained WinUI application with the existing NativeAOT package and unchanged ABI. Validation: 85 trade tests, 3 external expense tests, Release publish; native original manual sale reconciled and the application resumed independent departure.

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

代码差异

22 个文件 +680 -36
Modified XFEEDToolBox.Core/Execution/AutomaticTradeEngine.cs +13 -7
@@ -4,7 +4,8 @@ using XFEEDToolBox.Core.Navigation;
4 4
5 5 namespace XFEEDToolBox.Core.Execution;
6 6
7 public sealed record AutomaticTradeProgress(string Phase,string Message,string NextDestination,int CompletedBatches,decimal NetProfit,string? ActiveJourney=null);
7 public sealed record AutomaticTradeProgress(string Phase,string Message,string NextDestination,int CompletedBatches,decimal NetProfit,string? ActiveJourney=null)
8 {public AutomaticTradeEstimate? Estimate{get;init;}}
8 9
9 10 /// <summary>A toolbox-owned loop. No agent, scheduler or UI callback is needed to advance it.</summary>
10 11 public sealed class AutomaticTradeEngine(AutomaticTradeConfiguration configuration,NativeTradeCommandDriver driver,IOriginalCommerceChannel channel,
@@ -19,6 +20,7 @@ public sealed class AutomaticTradeEngine(AutomaticTradeConfiguration configurati
19 20 private string? active;
20 21 private int completed;
21 22 private decimal profit;
23 private AutomaticTradeEstimate? estimate;
22 24 private PlayerState initial=new();
23 25 private TradeProfitLedger? routeLedger;
24 26 private readonly string root=configuration.WorkingDirectory;
@@ -28,7 +30,7 @@ public sealed class AutomaticTradeEngine(AutomaticTradeConfiguration configurati
28 30 private async Task StateAsync(string value,string text,Station? destination=null,CancellationToken token=default)
29 31 {
30 32 phase=value;message=text;if(destination is not null)next=destination.DisplayName;
31 Progress?.Invoke(new(phase,message,next,completed,profit,active));
33 Progress?.Invoke(new(phase,message,next,completed,profit,active){Estimate=estimate});
32 34 var current=player();
33 35 await WriteAsync(Path.Combine(root,"state.json"),new{at=DateTimeOffset.UtcNow,phase,issue=value=="requires-recovery"?text:null,message=text,nextDestination=next,
34 36 executor="XFEEDToolBox",agentControlsGame=false,scheduledTask=false,independentLoop=true,toolboxProcessId=Environment.ProcessId,
@@ -43,6 +45,8 @@ public sealed class AutomaticTradeEngine(AutomaticTradeConfiguration configurati
43 45 var file=Path.Combine(path,"result.json");if(!File.Exists(file))continue;
44 46 var result=JsonSerializer.Deserialize<NativeTradeJourneyResult>(File.ReadAllText(file),ToolboxSettings.JsonOptions);
45 47 if(result?.Passed==true){profit+=result.Profit.NetProfit;completed++;}
48 else if(File.Exists(Path.Combine(path,"external-result.json")))
49 {using var external=JsonDocument.Parse(File.ReadAllText(Path.Combine(path,"external-result.json")));if(external.RootElement.GetProperty("passed").GetBoolean())profit+=external.RootElement.GetProperty("profit").GetProperty("netProfit").GetDecimal();}
46 50 }
47 51 }
48 52 private async Task CheckAsync(CancellationToken token)
@@ -114,11 +118,11 @@ public sealed class AutomaticTradeEngine(AutomaticTradeConfiguration configurati
114 118 }
115 119 private async Task CompleteAsync(string directory,TradeLeg leg,CancellationToken token)
116 120 {
117 active=directory;await StateAsync("cargo-journey","正在采购、运输并出售本批货物",leg.To,token);
121 estimate=AutomaticTradeEstimate.For(leg,root,initial.CurrentShipId);active=directory;await StateAsync("cargo-journey","正在采购、运输并出售本批货物",leg.To,token);
118 122 for(var attempt=0;;attempt++)
119 123 {
120 var journey=new NativeTradeJourney(driver,player,CheckAsync,directory);
121 journey.Status+=text=>Progress?.Invoke(new(phase,text,next,completed,profit,active));
124 var journey=new NativeTradeJourney(driver,player,CheckAsync,directory){JournalDirectory=journalDirectory};
125 journey.Status+=text=>Progress?.Invoke(new(phase,text,next,completed,profit,active){Estimate=estimate});
122 126 try{await journey.RunAsync(leg,token).ConfigureAwait(false);break;}
123 127 catch(NativeTradeJourneyIncompleteException error)when(error.InnerException is DockingHandoffException&&attempt<3&&recoverToSameMode is not null){await RecoverAsync(token).ConfigureAwait(false);}
124 128 catch(NativeTradeJourneyIncompleteException error)when(error.InnerException is StationDepartureRecoveryException&&attempt<3&&recoverToSameMode is not null){await RecoverAsync(token).ConfigureAwait(false);}
@@ -157,6 +161,8 @@ public sealed class AutomaticTradeEngine(AutomaticTradeConfiguration configurati
157 161 throw new InvalidOperationException("当前飞船、实际货舱、重购费或货物观测尚未就绪。");
158 162 var inventory=await channel.CallAsync(new{op="commerce.inventory"},token).ConfigureAwait(false);
159 163 await ExternalTradeSettlement.ReconcileAsync(root,journalDirectory,initial,inventory,token).ConfigureAwait(false);
164 await ExternalNativeSaleSettlement.ReconcileAsync(root,journalDirectory,initial,inventory,channel,token).ConfigureAwait(false);
165 RefreshCompleted();
160 166 await driver.PreflightAsync(token).ConfigureAwait(false);
161 167 if(validateFlight is not null)await validateFlight(token).ConfigureAwait(false);
162 168 if(player().Docked&&player().MarketId is {} currentMarket&&currentMarket!=configuration.Source.MarketId&&currentMarket!=configuration.Destination.MarketId)
@@ -230,11 +236,11 @@ public sealed class AutomaticTradeEngine(AutomaticTradeConfiguration configurati
230 236 }
231 237 token.ThrowIfCancellationRequested();
232 238 }
233 catch(OperationCanceledException)when(token.IsCancellationRequested){await StateAsync("stopped","自动跑商已取消,原始订单和恢复记录已保留。");}
239 catch(OperationCanceledException)when(token.IsCancellationRequested){AutomaticTradeRunSession.Finish(root);await StateAsync("stopped","自动跑商已取消,原始订单和恢复记录已保留。");}
234 240 catch(Exception error)
235 241 {
236 242 await WriteAsync(Path.Combine(root,"failure-"+DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()+".json"),new{at=DateTimeOffset.UtcNow,phase,error=error.ToString()},CancellationToken.None);
237 await StateAsync("requires-recovery",error.Message);throw;
243 await StateAsync(NativeOrderReconciliationRequiredException.Present(error)?"reconciling-order":"requires-recovery",error.Message);throw;
238 244 }
239 245 finally{driver.ServiceConfirmed-=RecordOutsideJourney;try{await driver.ReleaseAllAsync().ConfigureAwait(false);}finally{routeLedger=null;}}
240 246 }
Added XFEEDToolBox.Core/Execution/AutomaticTradeRunSession.cs +44 -0
@@ -0,0 +1,44 @@
1 using System.Text.Json;
2 using XFEEDToolBox.Core.Models;
3
4 namespace XFEEDToolBox.Core.Execution;
5
6 public sealed record AutomaticTradeRunSession(string Id,DateTimeOffset StartedAt,decimal BaselineNetProfit,bool Active)
7 {
8 public decimal Profit(decimal confirmedTotal)=>confirmedTotal-BaselineNetProfit;
9 public static AutomaticTradeRunSession Open(string root,decimal confirmedTotal)
10 {
11 var path=Path.Combine(root,"run-session.json");
12 if(File.Exists(path)&&JsonSerializer.Deserialize<AutomaticTradeRunSession>(File.ReadAllText(path),ToolboxSettings.JsonOptions) is {Active:true} existing)return existing;
13 var created=new AutomaticTradeRunSession(Guid.NewGuid().ToString("N"),DateTimeOffset.UtcNow,confirmedTotal,true);Save(root,created);return created;
14 }
15 public static void Finish(string root)
16 {
17 var path=Path.Combine(root,"run-session.json");if(!File.Exists(path))return;
18 var current=JsonSerializer.Deserialize<AutomaticTradeRunSession>(File.ReadAllText(path),ToolboxSettings.JsonOptions);if(current is not null)Save(root,current with{Active=false});
19 }
20 private static void Save(string root,AutomaticTradeRunSession value)
21 {Directory.CreateDirectory(root);var path=Path.Combine(root,"run-session.json");File.WriteAllText(path+".tmp",JsonSerializer.Serialize(value,ToolboxSettings.JsonOptions));File.Move(path+".tmp",path,true);}
22 }
23
24 public sealed record AutomaticTradeEstimate(decimal NetProfit,double Minutes,string Basis)
25 {
26 public decimal Hourly=>Minutes>0?NetProfit*60/(decimal)Minutes:0;
27 public static AutomaticTradeEstimate For(TradeLeg leg,string root,long? ship)
28 {
29 var samples=new List<(DateTimeOffset At,double Minutes,decimal Costs)>();
30 foreach(var directory in Directory.EnumerateDirectories(root,"journey-*"))
31 {
32 var r=Path.Combine(directory,"result.json");var p=Path.Combine(directory,"leg.json");if(!File.Exists(r)||!File.Exists(p))continue;
33 var result=JsonSerializer.Deserialize<NativeTradeJourneyResult>(File.ReadAllText(r),ToolboxSettings.JsonOptions);
34 if(result?.Passed!=true||result.Checkpoint.ShipId!=ship)continue;
35 var prior=JsonSerializer.Deserialize<TradeLeg>(File.ReadAllText(p),ToolboxSettings.JsonOptions)!;var duration=(result.Finished-result.Checkpoint.StartedAt).TotalMinutes;
36 if(prior.From.MarketId==leg.From.MarketId&&prior.To.MarketId==leg.To.MarketId&&duration is >=1 and <=30)samples.Add((result.Finished,duration,result.Profit.Expenses));
37 }
38 var recent=samples.OrderByDescending(s=>s.At).Take(5).ToArray();
39 var fallback=2+1.5+1+Math.Sqrt(Math.Max(0,leg.To.DistanceToArrivalLs??1000))/32;
40 var minutes=recent.Length>0?recent.Average(s=>s.Minutes):leg.EstimatedMinutes>0?leg.EstimatedMinutes:fallback;
41 var costs=recent.Length>0?recent.Average(s=>s.Costs):leg.OperatingCost;
42 return new(leg.GrossProfit-costs,minutes,recent.Length>0?"按同船同航段近期实测估算,实际利润以成交及费用回执为准。":"按路线参数估算,尚无同船完整实测;实际费用以回执为准。");
43 }
44 }
Added XFEEDToolBox.Core/Execution/ExternalNativeSaleSettlement.cs +99 -0
@@ -0,0 +1,99 @@
1 using System.Security.Cryptography;
2 using System.Text.Json;
3 using XFEEDToolBox.Core.Models;
4
5 namespace XFEEDToolBox.Core.Execution;
6
7 /// <summary>Accounts for an actual official-game manual sale, preserving the failed automated receipt.</summary>
8 public static class ExternalNativeSaleSettlement
9 {
10 public static async Task ReconcileAsync(string root,string journals,PlayerState player,JsonElement inventory,IOriginalCommerceChannel channel,CancellationToken token)
11 {
12 if(!player.InGame||!player.Docked||!player.CargoReliable||!player.CreditsReliable||player.CargoCount!=0||player.MarketId is null||!inventory.GetProperty("verified").GetBoolean()||inventory.GetProperty("count").GetInt32()!=0)return;
13 foreach(var directory in Directory.EnumerateDirectories(root,"journey-*"))
14 {
15 var checkpointPath=Path.Combine(directory,"journey.json");var legPath=Path.Combine(directory,"leg.json");
16 if(!File.Exists(checkpointPath)||!File.Exists(legPath)||File.Exists(Path.Combine(directory,"settled.json")))continue;
17 var checkpoint=TradeJourneyCheckpoints.Load(checkpointPath);if(checkpoint.Bought is not{} bought||checkpoint.Sold is not null||checkpoint.Commander!=player.CommanderId||checkpoint.ShipId!=player.CurrentShipId)continue;
18 var leg=JsonSerializer.Deserialize<TradeLeg>(await File.ReadAllTextAsync(legPath,token),ToolboxSettings.JsonOptions)!;
19 var ops=Path.Combine(root,"operations");var pendingFiles=new List<string>();
20 foreach(var file in Directory.EnumerateFiles(ops,"*.pending.json"))
21 {
22 if(File.Exists(file.Replace(".pending.json",".confirmed.json",StringComparison.Ordinal)))continue;
23 var statusFile=file.Replace(".pending.json",".status.json",StringComparison.Ordinal);if(!File.Exists(statusFile))continue;
24 using var status=JsonDocument.Parse(await File.ReadAllTextAsync(statusFile,token));if(!NativeOrderReconciliation.IsServerFailure(status.RootElement))continue;
25 using var p=JsonDocument.Parse(await File.ReadAllTextAsync(file,token));var row=p.RootElement;var intent=row.GetProperty("intent");
26 if(row.GetProperty("CommanderId").GetString()==checkpoint.Commander&&row.GetProperty("CurrentShipId").GetInt64()==checkpoint.ShipId
27 &&intent.GetProperty("market").GetInt64()==leg.To.MarketId&&intent.GetProperty("delta").GetInt32()==-bought.QuantityDelta
28 &&intent.GetProperty("Commodity").GetString()==bought.Commodity)pendingFiles.Add(file);
29 }
30 if(pendingFiles.Count!=1)continue;var pending=pendingFiles[0];
31 JsonElement? sale=null;var conflict=false;decimal laterTradeCashChange=0;
32 var laterTrades=new List<JsonElement>();var laterCargo=new Dictionary<string,long>(StringComparer.Ordinal);
33 foreach(var file in Directory.EnumerateFiles(journals,"Journal*.log").Where(p=>File.GetLastWriteTimeUtc(p)>=bought.ConfirmedAt.UtcDateTime).Order(StringComparer.Ordinal))
34 {
35 await using var stream=new FileStream(file,FileMode.Open,FileAccess.Read,FileShare.ReadWrite|FileShare.Delete);using var reader=new StreamReader(stream);var fid="";long ship=0;
36 while(await reader.ReadLineAsync(token) is{} line)
37 {
38 JsonDocument d;try{d=JsonDocument.Parse(line);}catch(JsonException){continue;}
39 using var parsed=d;var r=d.RootElement;var kind=r.TryGetProperty("event",out var e)?e.GetString():null;
40 if(kind=="LoadGame"){fid=r.TryGetProperty("FID",out var f)?f.GetString()??"":"";ship=r.TryGetProperty("ShipID",out var s)&&s.TryGetInt64(out var id)?id:0;}
41 if(kind=="Loadout")ship=r.TryGetProperty("ShipID",out var s)&&s.TryGetInt64(out var id)?id:0;
42 if(fid!=checkpoint.Commander||ship!=checkpoint.ShipId||!r.TryGetProperty("timestamp",out var t)||t.GetDateTimeOffset()<bought.ConfirmedAt)continue;
43 if(kind=="MarketBuy")
44 {
45 if(sale is null){conflict=true;break;}
46 var commodity=r.GetProperty("Type").GetString()!;var quantity=r.GetProperty("Count").GetInt32();var cost=r.GetProperty("TotalCost").GetInt64();
47 if(quantity<=0||cost<=0||cost!=(decimal)quantity*r.GetProperty("BuyPrice").GetInt64()){conflict=true;break;}
48 laterCargo[commodity]=laterCargo.GetValueOrDefault(commodity)+quantity;laterTradeCashChange-=cost;laterTrades.Add(r.Clone());continue;
49 }
50 if(kind!="MarketSell")continue;
51 if(sale is not null)
52 {
53 var commodity=r.GetProperty("Type").GetString()!;var quantity=r.GetProperty("Count").GetInt32();var proceeds=r.GetProperty("TotalSale").GetInt64();
54 if(quantity<=0||proceeds<=0||proceeds!=(decimal)quantity*r.GetProperty("SellPrice").GetInt64()||laterCargo.GetValueOrDefault(commodity)<quantity){conflict=true;break;}
55 laterCargo[commodity]-=quantity;laterTradeCashChange+=proceeds;laterTrades.Add(r.Clone());continue;
56 }
57 if(r.GetProperty("MarketID").GetInt64()!=leg.To.MarketId||r.GetProperty("Type").GetString()!=bought.Commodity
58 ||r.GetProperty("Count").GetInt32()!=bought.QuantityDelta||r.GetProperty("AvgPricePaid").GetInt64()!=bought.UnitPrice
59 ||r.GetProperty("TotalSale").GetInt64()!=(decimal)r.GetProperty("SellPrice").GetInt64()*bought.QuantityDelta){conflict=true;break;}
60 sale=r.Clone();
61 }
62 if(conflict)break;
63 }
64 if(conflict||sale is not{} sold||laterCargo.Values.Any(quantity=>quantity!=0))continue;
65 var queued=await channel.CallAsync(new{op="commerce.market.read",marketId=player.MarketId.Value,refreshClient=false},token);var generation=queued.GetProperty("generation").GetUInt64();JsonElement market=default;
66 for(var attempt=0;attempt<350;attempt++)
67 {market=await channel.CallAsync(new{op="commerce.market.status",generation,maximum=50},token);if(market.GetProperty("state").GetInt32()>=3)break;await Task.Delay(100,token);}
68 if(market.GetProperty("state").GetInt32()!=3||!market.TryGetProperty("cargo",out var cargo)||cargo.ValueKind!=JsonValueKind.Object||cargo.EnumerateObject().Any(p=>p.Value.GetInt64()!=0))continue;
69 var actual=await channel.CallAsync(new{op="commerce.inventory"},token);if(!actual.GetProperty("verified").GetBoolean()||actual.GetProperty("count").GetInt32()!=0||actual.GetProperty("credits").GetInt64()!=player.Credits)continue;
70 if(market.TryGetProperty("serverCredits",out var serverCredits)&&serverCredits.GetInt64()!=player.Credits)continue;
71 using var ledger=new TradeProfitLedger(Path.Combine(directory,"ledger"));ledger.RecordTrade(bought);
72 using var openingDocument=JsonDocument.Parse(await File.ReadAllTextAsync(Path.Combine(directory,"opening-wallet.json"),token));var opening=openingDocument.RootElement.GetProperty("credits").GetInt64();
73 var key=Convert.ToHexStringLower(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(sold.GetRawText())));var revenue=sold.GetProperty("TotalSale").GetInt64();
74 var soldAt=sold.GetProperty("timestamp").GetDateTimeOffset();
75 var allFees=await ExternalServiceCosts.ReadAsync(journals,checkpoint.Commander,checkpoint.ShipId,bought.ConfirmedAt,[leg.From.MarketId,leg.To.MarketId],token);
76 var fees=allFees.Where(p=>p.Entry.At<=soldAt&&!ledger.ContainsOperation(p.Entry.OperationKey)).ToArray();var laterFees=allFees.Where(p=>p.Entry.At>soldAt).ToArray();
77 var afterSettlementCashChange=laterTradeCashChange-laterFees.Sum(p=>p.Entry.Amount);
78 var expected=opening+ledger.Snapshot.CashChange+(ledger.ContainsOperation(key)?0:revenue)-fees.Sum(p=>p.Entry.Amount);
79 var observedWallet=actual.GetProperty("credits").GetInt64();var closing=observedWallet-afterSettlementCashChange;
80 var walletReconciled=expected==closing;
81 ledger.Record(new(key,TradeCashKind.Sale,revenue,leg.To.MarketId,sold.GetProperty("timestamp").GetDateTimeOffset(),bought.Commodity,bought.QuantityDelta));
82 var feeProofs=Path.Combine(directory,"external-service-proofs");Directory.CreateDirectory(feeProofs);
83 foreach(var fee in fees){await Save(Path.Combine(feeProofs,fee.Entry.OperationKey+".json"),fee,token);ledger.Record(fee.Entry);}
84 var proof=new{status="manual-sale-confirmed",proofKind="official-manual-sale-after-native-failure",commanderId=checkpoint.Commander,shipId=checkpoint.ShipId,
85 operationKey=Path.GetFileName(pending).Replace(".pending.json",""),purchaseOperationKey=bought.OperationKey,
86 pendingSha256=Convert.ToHexStringLower(SHA256.HashData(await File.ReadAllBytesAsync(pending,token))),saleProof=sold,actualInventory=actual,originalServerCargo=cargo,
87 observedWallet,afterSettlementCashChange,laterTrades,laterFees,historicalClosingWalletFromVerifiedJournalChain=closing,laterManualTradesIncludedInAutomationProfit=false,
88 walletReconciled,expectedClosingFromConfirmedReceipts=expected,unexplainedWalletDifference=closing-expected,
89 originalNativeReceiptRewritten=false,nativeJourneyPassed=false,at=DateTimeOffset.UtcNow};
90 var marker=pending.Replace(".pending.json",".externally-settled.json",StringComparison.Ordinal);
91 await Save(marker,proof,token);
92 await Save(Path.Combine(directory,"external-result.json"),new{passed=walletReconciled,tradeSettled=true,walletReconciled,manual=true,nativeJourneyPassed=false,profit=ledger.Snapshot,openingCredits=opening,closingCredits=closing,finished=DateTimeOffset.UtcNow,proof=marker},token);
93 await Save(Path.Combine(directory,"external-completion.json"),proof,token);
94 await Save(Path.Combine(directory,"settled.json"),new{manual=true,nativeJourneyPassed=false,at=DateTimeOffset.UtcNow},token);
95 }
96 }
97 private static async Task Save(string file,object value,CancellationToken token)
98 {await File.WriteAllTextAsync(file+".tmp",JsonSerializer.Serialize(value,ToolboxSettings.JsonOptions),token);File.Move(file+".tmp",file,true);}
99 }
Added XFEEDToolBox.Core/Execution/ExternalServiceCosts.cs +64 -0
@@ -0,0 +1,64 @@
1 using System.Security.Cryptography;
2 using System.Text;
3 using System.Text.Json;
4 using XFEEDToolBox.Core.Models;
5
6 namespace XFEEDToolBox.Core.Execution;
7
8 /// <summary>Recognizes journal-confirmed station services only when they exactly explain the original wallet gap.</summary>
9 public static class ExternalServiceCosts
10 {
11 public sealed record Proof(TradeCashEntry Entry,string File,int Line,string Original);
12 public static async Task<bool> ReconcileAsync(string journals,string evidenceDirectory,TradeJourneyCheckpoint checkpoint,
13 TradeLeg leg,TradeProfitLedger ledger,long opening,long actual,CancellationToken token)
14 {
15 var gap=opening+ledger.Snapshot.CashChange-actual;
16 if(gap<=0||checkpoint.Bought is not {} bought||!Directory.Exists(journals))return false;
17 var candidates=(await ReadAsync(journals,checkpoint.Commander,checkpoint.ShipId,bought.ConfirmedAt,[leg.From.MarketId,leg.To.MarketId],token))
18 .Where(p=>!ledger.ContainsOperation(p.Entry.OperationKey)).ToArray();
19 if(candidates.Length==0||candidates.Sum(v=>v.Entry.Amount)!=gap)return false;
20 var proofs=Path.Combine(evidenceDirectory,"external-service-proofs");Directory.CreateDirectory(proofs);
21 foreach(var value in candidates)
22 {
23 var path=Path.Combine(proofs,value.Entry.OperationKey+".json");
24 await File.WriteAllTextAsync(path+".tmp",JsonSerializer.Serialize(new{checkpoint.Commander,checkpoint.ShipId,value.File,value.Line,value.Original,
25 entry=value.Entry,walletGap=gap,actualWallet=actual,at=DateTimeOffset.UtcNow},ToolboxSettings.JsonOptions),token);
26 File.Move(path+".tmp",path,true);ledger.Record(value.Entry);
27 }
28 return opening+ledger.Snapshot.CashChange==actual;
29 }
30 public static async Task<IReadOnlyList<Proof>> ReadAsync(string journals,string commander,long expectedShip,DateTimeOffset since,IReadOnlyList<long> markets,CancellationToken token)
31 {
32 if(!Directory.Exists(journals))return [];
33 var candidates=new Dictionary<string,Proof>();
34 foreach(var file in Directory.EnumerateFiles(journals,"Journal*.log").Where(p=>File.GetLastWriteTimeUtc(p)>=since.UtcDateTime).Order(StringComparer.Ordinal))
35 {
36 await using var stream=new FileStream(file,FileMode.Open,FileAccess.Read,FileShare.ReadWrite|FileShare.Delete);
37 using var reader=new StreamReader(stream);var fid="";long ship=0,market=0;var lineNumber=0;
38 while(await reader.ReadLineAsync(token) is {} line)
39 {
40 lineNumber++;JsonDocument document;
41 try{document=JsonDocument.Parse(line);}catch(JsonException){continue;}
42 using(document)
43 {
44 var row=document.RootElement;
45 string Text(string key)=>row.TryGetProperty(key,out var v)&&v.ValueKind==JsonValueKind.String?v.GetString()!:"";
46 long Number(string key)=>row.TryGetProperty(key,out var v)&&v.TryGetInt64(out var n)?n:0;
47 var kind=Text("event");
48 if(kind=="LoadGame"){fid=Text("FID");ship=Number("ShipID");market=0;}
49 if(kind=="Loadout")ship=Number("ShipID");
50 if(kind=="ShipyardSwap"){ship=0;market=0;}
51 if(kind is "Docked" or "Location")market=kind=="Docked"||row.TryGetProperty("Docked",out var docked)&&docked.ValueKind==JsonValueKind.True?Number("MarketID"):0;
52 if(kind=="Undocked")market=0;
53 var cashKind=kind switch{"RefuelAll" or "RefuelPartial"=>(TradeCashKind?)TradeCashKind.Fuel,"RepairAll" or "Repair"=>TradeCashKind.Repair,"PayFines" or "PayBounties"=>TradeCashKind.OtherExpense,_=>null};
54 var cost=kind is "PayFines" or "PayBounties"?Number("Amount"):Number("Cost");
55 if(cashKind is null||fid!=commander||ship!=expectedShip||!markets.Contains(market)
56 ||!DateTimeOffset.TryParse(Text("timestamp"),out var at)||at<since||at>DateTimeOffset.UtcNow||cost<=0)continue;
57 var key=Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(Path.GetFileName(file)+"\n"+lineNumber+"\n"+line)));
58 candidates.TryAdd(key,new(new(key,cashKind.Value,cost,market,at),Path.GetFileName(file),lineNumber,line));
59 }
60 }
61 }
62 return candidates.Values.ToArray();
63 }
64 }
Added XFEEDToolBox.Core/Execution/NativeOrderReconciliation.cs +84 -0
@@ -0,0 +1,84 @@
1 using System.Security.Cryptography;
2 using System.Text.Json;
3 using XFEEDToolBox.Core.Data;
4 using XFEEDToolBox.Core.Models;
5 using XFEEDToolBox.Core.Navigation;
6
7 namespace XFEEDToolBox.Core.Execution;
8
9 public sealed class NativeOrderReconciliationRequiredException(JsonElement receipt):IOException("原始服务器请求未确认,正在恢复官方会话并核对原单。")
10 {
11 public JsonElement Receipt{get;}=receipt.Clone();
12 public static bool Present(Exception error)=>error is NativeOrderReconciliationRequiredException||error.InnerException is {} inner&&Present(inner);
13 }
14
15 /// <summary>Never resubmits an uncertain key. A fresh official client plus original server inventory must prove non-application.</summary>
16 public static class NativeOrderReconciliation
17 {
18 private static string Marker(string pending)=>pending.Replace(".pending.json",".not-applied-after-reload.json",StringComparison.Ordinal);
19 private static string Hash(byte[] bytes)=>Convert.ToHexStringLower(SHA256.HashData(bytes));
20 public static bool IsServerFailure(JsonElement value)=>value.TryGetProperty("state",out var state)&&state.GetInt32()==4
21 &&value.TryGetProperty("error",out var error)&&error.TryGetInt32(out var code)&&code is >=500 and <=599
22 &&value.TryGetProperty("mutationsSent",out var sent)&&sent.GetInt32()==1
23 &&value.TryGetProperty("serviceKind",out var kind)&&kind.GetInt32()==1
24 &&value.TryGetProperty("serverConfirmed",out var server)&&server.ValueKind==JsonValueKind.False
25 &&value.TryGetProperty("clientApplied",out var applied)&&applied.ValueKind==JsonValueKind.False;
26 public static bool IsResolved(string pending,PlayerState current)
27 {
28 var marker=Marker(pending);var status=pending.Replace(".pending.json",".status.json",StringComparison.Ordinal);
29 if(!File.Exists(marker)||!File.Exists(status))return false;
30 using var proof=JsonDocument.Parse(File.ReadAllText(marker));var p=proof.RootElement;
31 return p.GetProperty("commanderId").GetString()==current.CommanderId&&p.GetProperty("shipId").GetInt64()==current.CurrentShipId
32 &&p.GetProperty("status").GetString()=="official-reload-proved-not-applied"
33 &&p.GetProperty("pendingSha256").GetString()==Hash(File.ReadAllBytes(pending))&&p.GetProperty("statusSha256").GetString()==Hash(File.ReadAllBytes(status));
34 }
35 public static async Task WaitForResolutionAsync(string pending,IOriginalCommerceChannel channel,Func<PlayerState> player,string? journals,Action<string>? progress,CancellationToken token)
36 {
37 var statusPath=pending.Replace(".pending.json",".status.json",StringComparison.Ordinal);
38 if(!File.Exists(statusPath))return;
39 var originalStatus=await File.ReadAllBytesAsync(statusPath,token);using var status=JsonDocument.Parse(originalStatus);
40 if(!IsServerFailure(status.RootElement))return;
41 var bytes=await File.ReadAllBytesAsync(pending,token);using var original=JsonDocument.Parse(bytes);var row=original.RootElement;
42 if(row.GetProperty("channel").GetString()==channel.Identity)throw new NativeOrderReconciliationRequiredException(status.RootElement);
43 while(!IsResolved(pending,player()))
44 {
45 token.ThrowIfCancellationRequested();progress?.Invoke("正在只读核对原订单、服务器货舱和官方会话余额,不重复提交。");
46 try{if(await TryResolveAsync(pending,bytes,originalStatus,row,channel,player(),journals,token))return;}
47 catch(Exception error)when(NavigationRecovery.CanRetry(error,token)){}
48 await Task.Delay(1000,token);
49 }
50 }
51 private static async Task<bool> TryResolveAsync(string pending,byte[] originalBytes,byte[] originalStatus,JsonElement row,
52 IOriginalCommerceChannel channel,PlayerState player,string? journals,CancellationToken token)
53 {
54 var created=row.GetProperty("created").GetDateTimeOffset();var intent=row.GetProperty("intent");var before=intent.GetProperty("before");var market=intent.GetProperty("market").GetInt64();
55 var parts=channel.Identity.Split(':');var old=row.GetProperty("channel").GetString()!.Split(':');
56 if(parts.Length!=2||old.Length!=2||parts[0]==old[0]||!long.TryParse(parts[1],out var processStarted)||processStarted<=created.UtcTicks
57 ||!player.InGame||!player.Docked||player.MarketId!=market||!player.CargoReliable||!player.CreditsReliable||player.LoadGameAt is null||player.LoadGameAt<=created
58 ||player.CommanderId!=row.GetProperty("CommanderId").GetString()||player.CurrentShipId!=row.GetProperty("CurrentShipId").GetInt64()
59 ||player.CargoCapacity!=before.GetProperty("capacity").GetInt32())return false;
60 var fees=journals is null?[]:await ExternalServiceCosts.ReadAsync(journals,player.CommanderId,player.CurrentShipId!.Value,created,[market],token);
61 var expected=before.GetProperty("credits").GetInt64()-fees.Sum(f=>f.Entry.Amount);
62 var current=await channel.CallAsync(new{op="commerce.inventory"},token);
63 if(!current.GetProperty("verified").GetBoolean()||!before.GetProperty("verified").GetBoolean()||current.GetProperty("credits").GetInt64()!=expected
64 ||player.Credits!=expected||current.GetProperty("count").GetInt32()!=before.GetProperty("count").GetInt32()||player.CargoCount!=before.GetProperty("count").GetInt32())return false;
65 var queued=await channel.CallAsync(new{op="commerce.market.read",marketId=market,refreshClient=false},token);var generation=queued.GetProperty("generation").GetUInt64();
66 var actual=await NavigationRecovery.ReadWithRecoveryAsync(async cancellation=>
67 {
68 var result=await channel.CallAsync(new{op="commerce.market.status",generation,maximum=50},cancellation);
69 if(result.GetProperty("state").GetInt32()<3)throw new FlightTelemetryUnavailableException("等待原始服务器货舱快照。");
70 if(result.GetProperty("state").GetInt32()!=3)throw new FlightTelemetryUnavailableException("服务器只读货舱查询暂未成功。");return result;
71 },null,token);
72 if(!actual.TryGetProperty("cargo",out var cargo)||cargo.ValueKind!=JsonValueKind.Object)return false;
73 var commodity=CommodityCatalog.Key(intent.GetProperty("Commodity").GetString()!);long total=0,quantity=0;
74 foreach(var item in cargo.EnumerateObject())
75 {if(!item.Value.TryGetInt32(out var count)||count<0)return false;total+=count;if(CommodityCatalog.Key(item.Name)==commodity)quantity+=count;}
76 var delta=intent.GetProperty("delta").GetInt32();var wanted=delta<0?Math.Abs((long)delta):0;
77 if(total!=before.GetProperty("count").GetInt32()||quantity!=wanted||total!=wanted
78 ||actual.TryGetProperty("serverCredits",out var serverCredits)&&serverCredits.GetInt64()!=expected)return false;
79 var proof=new{status="official-reload-proved-not-applied",commanderId=player.CommanderId,shipId=player.CurrentShipId,
80 pendingSha256=Hash(originalBytes),statusSha256=Hash(originalStatus),channel=channel.Identity,player.LoadGameAt,
81 actualClientInventory=current,originalServerCargo=cargo,knownExternalExpenses=fees,expectedCredits=expected,originalRequestReplayed=false,at=DateTimeOffset.UtcNow};
82 await File.WriteAllTextAsync(Marker(pending)+".tmp",JsonSerializer.Serialize(proof,ToolboxSettings.JsonOptions),token);File.Move(Marker(pending)+".tmp",Marker(pending),true);return true;
83 }
84 }
Modified XFEEDToolBox.Core/Execution/NativeTradeCommandDriver.cs +16 -1
@@ -57,6 +57,7 @@ public sealed class NativeTradeCommandDriver:IConfirmedTradeCommandDriver,IConfi
57 57 public event Action<ConfirmedDockedServiceReceipt>? ServiceConfirmed;
58 58 public event Action<ConfirmedTradeReceipt>? TradeConfirmed;
59 59 public event Action<string>? Status;
60 public string? JournalDirectory{get;init;}
60 61 public NativeTradeCommandDriver(IOriginalCommerceChannel channel,Func<PlayerState> player,string operationDirectory,
61 62 Func<Station,Func<CancellationToken,Task>,CancellationToken,Task> travel,Func<CancellationToken,Task> ensureProtection,Func<Task>? release=null,NativeTradeBatchPolicy? batchPolicy=null)
62 63 {
@@ -78,9 +79,21 @@ public sealed class NativeTradeCommandDriver:IConfirmedTradeCommandDriver,IConfi
78 79 if(batchPolicy is not null&&(!hello.TryGetProperty("marketWalletOptional",out var walletSchema)||walletSchema.ValueKind!=JsonValueKind.True))
79 80 throw new NativeTradeSdkUpgradeRequiredException("原生采购入口仍要求行情中不存在的钱包字段,需要加载修正后的原生插件。");
80 81 session=player();ValidateSession();await ensureProtection(token);
82 if(session.Docked&&session.MarketId is {} dockedMarket)await StationAccessWaiter.WaitAsync(JournalDirectory,player,dockedMarket,text=>Status?.Invoke(text),token);
81 83 await NativeUnsubmittedTradeIntents.ReconcileAsync(directory,channel,session,token);
82 84 foreach(var file in Directory.EnumerateFiles(directory,"*.pending.json"))
83 if(!File.Exists(file.Replace(".pending.json",".confirmed.json",StringComparison.Ordinal))&&!ExternalTradeSettlement.IsSettled(file,session))throw new InvalidOperationException("存在尚未核对的原生订单,正在等待实际结果;不会重复下单。");
85 {
86 var oldStatus=file.Replace(".pending.json",".status.json",StringComparison.Ordinal);
87 if(File.Exists(oldStatus))
88 {
89 using var state=JsonDocument.Parse(await File.ReadAllTextAsync(oldStatus,token));
90 if(NativeOrderReconciliation.IsServerFailure(state.RootElement))
91 {using var oldIntent=JsonDocument.Parse(await File.ReadAllTextAsync(file,token));if(oldIntent.RootElement.GetProperty("channel").GetString()==channel.Identity)throw new NativeOrderReconciliationRequiredException(state.RootElement);}
92 }
93 if(File.Exists(file.Replace(".pending.json",".confirmed.json",StringComparison.Ordinal))||ExternalTradeSettlement.IsSettled(file,session)||NativeOrderReconciliation.IsResolved(file,session))continue;
94 await NativeOrderReconciliation.WaitForResolutionAsync(file,channel,player,JournalDirectory,text=>Status?.Invoke(text),token);
95 if(!NativeOrderReconciliation.IsResolved(file,player()))throw new InvalidOperationException("存在尚未核对的原生订单,正在等待实际结果;不会重复下单。");
96 }
84 97 await gate.WaitAsync(token);try{PublishWallet(await channel.CallAsync(new{op="commerce.inventory"},token));}finally{gate.Release();}
85 98 }
86 99 private PlayerState ValidateSession(long? market=null)
@@ -114,6 +127,7 @@ public sealed class NativeTradeCommandDriver:IConfirmedTradeCommandDriver,IConfi
114 127 {
115 128 if(value.GetProperty("state").GetInt32()!=3)
116 129 {
130 if(NativeOrderReconciliation.IsServerFailure(value))throw new NativeOrderReconciliationRequiredException(value);
117 131 var stage=value.TryGetProperty("failureStage",out var failure)?failure.GetString():null;
118 132 var code=value.TryGetProperty("error",out var error)?error.ToString():"unknown";
119 133 throw new InvalidOperationException("原生请求尚未完整核对"+(stage is null?"。":"("+stage+")。")+"原始错误码 "+code+";不会将客户端未更新当作未成交,也不会重复提交。");
@@ -350,6 +364,7 @@ public sealed class NativeTradeCommandDriver:IConfirmedTradeCommandDriver,IConfi
350 364 try
351 365 {
352 366 var current=ValidateSession(market);await beforeInput(token);await ensureProtection(token);
367 await StationAccessWaiter.WaitAsync(JournalDirectory,player,market,text=>Status?.Invoke(text),token);
353 368 batchPolicy?.ValidateAction(action,current);
354 369 var snapshot=await MarketAsync(market,token);var generation=snapshot.GetProperty("generation").GetUInt64();JsonElement? quote=null;
355 370 for(var offset=0;offset<snapshot.GetProperty("commodityCount").GetInt32();offset+=50)
Modified XFEEDToolBox.Core/Execution/NativeTradeJourney.cs +5 -1
@@ -4,12 +4,14 @@ using XFEEDToolBox.Core.Models;
4 4 namespace XFEEDToolBox.Core.Execution;
5 5
6 6 public sealed record NativeTradeJourneyResult(bool Passed,TradeJourneyCheckpoint Checkpoint,TradeProfitSnapshot Profit,long OpeningCredits,long? ClosingCredits,string? Failure,DateTimeOffset Finished);
7 public sealed class NativeTradeJourneyIncompleteException(NativeTradeJourneyResult result,Exception? cause):InvalidOperationException("跑商批次未完成,原始货物和回执已保留。",cause)
7 public sealed class NativeTradeJourneyIncompleteException(NativeTradeJourneyResult result,Exception? cause):InvalidOperationException(
8 cause is null?"跑商批次未完成,原始货物和回执已保留。":$"跑商批次未完成:{cause.Message};原始货物和回执已保留。",cause)
8 9 {public NativeTradeJourneyResult Result{get;}=result;}
9 10
10 11 /// <summary>A resumable production cargo journey, driven entirely by the toolbox.</summary>
11 12 public sealed class NativeTradeJourney(NativeTradeCommandDriver driver,Func<PlayerState> player,Func<CancellationToken,Task> check,string directory)
12 13 {
14 public string? JournalDirectory{get;init;}
13 15 public event Action<string>? Status;
14 16 public async Task<NativeTradeJourneyResult> RunAsync(TradeLeg leg,CancellationToken token)
15 17 {
@@ -90,6 +92,8 @@ public sealed class NativeTradeJourney(NativeTradeCommandDriver driver,Func<Play
90 92 // finished; service verification remains mandatory on resume.
91 93 if(saleWasAlreadyConfirmed)await driver.ServiceDockedAsync(new(leg.To.MarketId,order.Quantity),check,stop.Token);
92 94 closing=(await driver.RefreshWalletAsync(stop.Token)).Credits;
95 if(JournalDirectory is {} journals&&opening+ledger.Snapshot.CashChange!=closing)
96 await ExternalServiceCosts.ReconcileAsync(journals,directory,checkpoint,leg,ledger,opening,closing.Value,stop.Token);
93 97 if(!player().Docked||player().MarketId!=leg.To.MarketId||player().CargoCount!=0||checkpoint.Sold is null||opening+ledger.Snapshot.CashChange!=closing)
94 98 throw new InvalidDataException("本批次原始余额、空货舱或实际停靠尚未完整核对。");
95 99 }
Added XFEEDToolBox.Core/Execution/StationAccessWaiter.cs +50 -0
@@ -0,0 +1,50 @@
1 using System.Text.Json;
2 using XFEEDToolBox.Core.Models;
3
4 namespace XFEEDToolBox.Core.Execution;
5
6 /// <summary>Waits for known journal-confirmed fines affecting this station, without submitting a trade.</summary>
7 public static class StationAccessWaiter
8 {
9 public static async Task<long> KnownFineAsync(string journals,string commander,long market,CancellationToken token)
10 {
11 if(!Directory.Exists(journals))return 0;
12 var fines=new Dictionary<string,long>(StringComparer.Ordinal);var faction="";
13 foreach(var file in Directory.EnumerateFiles(journals,"Journal*.log").Order(StringComparer.Ordinal))
14 {
15 await using var stream=new FileStream(file,FileMode.Open,FileAccess.Read,FileShare.ReadWrite|FileShare.Delete);using var reader=new StreamReader(stream);var fid="";
16 while(await reader.ReadLineAsync(token) is {} line)
17 {
18 if(!line.Contains("\"LoadGame\"",StringComparison.Ordinal)&&!line.Contains("\"Docked\"",StringComparison.Ordinal)&&!line.Contains("\"Location\"",StringComparison.Ordinal)
19 &&!line.Contains("\"CommitCrime\"",StringComparison.Ordinal)&&!line.Contains("\"PayFines\"",StringComparison.Ordinal))continue;
20 JsonDocument document;try{document=JsonDocument.Parse(line);}catch(JsonException){continue;}
21 using(document)
22 {
23 var row=document.RootElement;string Text(string name)=>row.TryGetProperty(name,out var v)&&v.ValueKind==JsonValueKind.String?v.GetString()!:"";
24 long Number(string name)=>row.TryGetProperty(name,out var v)&&v.ValueKind==JsonValueKind.Number&&v.TryGetInt64(out var n)?n:0;
25 var kind=Text("event");if(kind=="LoadGame")fid=Text("FID");if(fid!=commander)continue;
26 if(kind is "Location" or "Docked"&&Number("MarketID")==market&&row.TryGetProperty("StationFaction",out var station)&&station.TryGetProperty("Name",out var name))faction=name.GetString()??"";
27 var authority=Text("Faction");
28 if(kind=="CommitCrime"&&Number("Fine")>0&&authority.Length>0)fines[authority]=checked(fines.GetValueOrDefault(authority)+Number("Fine"));
29 if(kind=="PayFines")
30 {
31 if(row.TryGetProperty("AllFines",out var all)&&all.ValueKind==JsonValueKind.True)fines.Clear();
32 else if(authority.Length>0)fines[authority]=Math.Max(0,fines.GetValueOrDefault(authority)-Number("Amount"));
33 }
34 }
35 }
36 }
37 return fines.GetValueOrDefault(faction);
38 }
39 public static async Task WaitAsync(string? journals,Func<PlayerState> player,long market,Action<string>? progress,CancellationToken token)
40 {
41 if(journals is null)return;
42 while(true)
43 {
44 token.ThrowIfCancellationRequested();var current=player();
45 var fine=await KnownFineAsync(journals,current.CommanderId,market,token);if(fine<=0)return;
46 progress?.Invoke($"本站有未缴罚款 {fine:N0} Cr,请在行政联系人缴纳;程序保留原货物并等待,缴清后自动继续。");
47 await Task.Delay(1000,token);
48 }
49 }
50 }
Modified XFEEDToolBox.Core/Execution/TradeProfitLedger.cs +1 -0
@@ -32,6 +32,7 @@ public sealed class TradeProfitLedger : IDisposable
32 32 private long sequence;
33 33 private bool disposed;
34 34 public TradeProfitSnapshot Snapshot { get { lock (sync) return snapshot; } }
35 public bool ContainsOperation(string operationKey) { lock(sync)return entries.ContainsKey(operationKey); }
35 36
36 37 // Use a distinct account directory for each continuous trading session.
37 38 // Reopening that directory restores the same accumulated profit after a restart.
Modified XFEEDToolBox.Core/Navigation/NavigationRecovery.cs +8 -4
@@ -5,15 +5,18 @@ public enum FlightRecovery { RefreshTelemetry, ReadNextFrame, ReselectTarget, Re
5 5 /// <summary>Only known timing/transport refusals are retried. Protocol, ownership and build failures remain terminal.</summary>
6 6 public static class NavigationRecovery
7 7 {
8 public static async Task<T> ReadWithRecoveryAsync<T>(Func<CancellationToken,Task<T>> read,Action? recovering,CancellationToken token)
8 public static Task<T> ReadWithRecoveryAsync<T>(Func<CancellationToken,Task<T>> read,Action? recovering,CancellationToken token)
9 =>ReadWithRecoveryAsync(read,recovering,token,TimeSpan.FromSeconds(30));
10 public static async Task<T> ReadWithRecoveryAsync<T>(Func<CancellationToken,Task<T>> read,Action? recovering,CancellationToken token,TimeSpan maximumWait)
9 11 {
10 var watch=System.Diagnostics.Stopwatch.StartNew();
12 var watch=System.Diagnostics.Stopwatch.StartNew();var attempts=0;var limit=maximumWait;
13 if(limit<TimeSpan.Zero&&limit!=Timeout.InfiniteTimeSpan)throw new ArgumentOutOfRangeException(nameof(maximumWait));
11 14 while(true)
12 15 {
13 16 token.ThrowIfCancellationRequested();
14 17 try{return await read(token);}
15 catch(Exception error)when(CanRetry(error,token)&&watch.Elapsed<TimeSpan.FromSeconds(30))
16 {recovering?.Invoke();await Task.Delay(100,token);}
18 catch(Exception error)when(CanRetry(error,token)&&(limit==Timeout.InfiniteTimeSpan||watch.Elapsed<limit))
19 {recovering?.Invoke();attempts=Math.Min(10,attempts+1);await Task.Delay(100*attempts,token);}
17 20 }
18 21 }
19 22 public static async Task<FlightDriverCapabilities> PreflightWithRecoveryAsync(IFlightControlDriver driver,Action? recovering,CancellationToken token)
@@ -62,6 +65,7 @@ public static class NavigationRecovery
62 65 "Local control frame expired or selection changed"=>FlightRecovery.ReadNextFrame,
63 66 "Native target or primary-star envelope changed"=>FlightRecovery.ReselectTarget,
64 67 "Native player telemetry unavailable or stale" or
68 "Native main-ship health unavailable or stale" or
65 69 "Local target telemetry unavailable or stale" or
66 70 "Game input telemetry unavailable or stale" or
67 71 "A fresh active ship and system target are required"=>FlightRecovery.RefreshTelemetry,
Modified XFEEDToolBox.Core/Session/NativeFlightTestProtection.cs +3 -1
@@ -68,7 +68,9 @@ public sealed class NativeFlightTestProtection:IAsyncDisposable
68 68 try
69 69 {
70 70 if(protection.ownsMonitor)protection.monitor.EnableTarget(target);
71 var health=await protection.ReadAsync(token);protection.monitor.Observe(health,DateTimeOffset.UtcNow);protection.monitor.RequireHealthyShip();
71 // Scene transitions can precede the first original health frame.
72 // Keep all callers waiting for a fresh sample before arming input.
73 var health=await NavigationRecovery.ReadWithRecoveryAsync(protection.ReadAsync,null,token,Timeout.InfiniteTimeSpan);protection.monitor.Observe(health,DateTimeOffset.UtcNow);protection.monitor.RequireHealthyShip();
72 74 if(health.HullHealth<.5)throw new InvalidOperationException("船体完整度低于 50%,已停止测试;初始低血量不会触发大退。");
73 75 if(Interlocked.CompareExchange(ref active,protection,null) is not null)throw new InvalidOperationException("已有飞行测试保护正在运行。");
74 76 protection.polling=protection.PollAsync();return protection;
Added XFEEDToolBox.Test/ExternalServiceCostsTests.cs +48 -0
@@ -0,0 +1,48 @@
1 using System.Text.Json;
2 using XFEEDToolBox.Core.Execution;
3 using XFEEDToolBox.Core.Models;
4 using XFEExtension.NetCore.XUnit.Attributes;
5
6 namespace XFEEDToolBox.Test;
7
8 [TestFixture]
9 public class ExternalServiceCostsTests
10 {
11 private static async Task<(TradeJourneyCheckpoint Checkpoint,TradeLeg Leg)> Fixture(TemporaryDirectory temp,string fid="F1",int ship=7,int market=1)
12 {
13 var at=DateTimeOffset.UtcNow.AddMinutes(-1);var bought=new ConfirmedTradeReceipt(Guid.NewGuid().ToString("N"),1,"gold",1,40,10000,9960,0,1,"F1",7,at.AddMinutes(-1),at,true,true);
14 object[] rows=[new{timestamp=at.AddSeconds(-10),@event="LoadGame",FID=fid,ShipID=ship},new{timestamp=at.AddSeconds(1),@event="Docked",MarketID=market},
15 new{timestamp=at.AddSeconds(2),@event="RefuelAll",Cost=23},new{timestamp=at.AddSeconds(3),@event="RepairAll",Cost=65}];
16 await File.WriteAllLinesAsync(temp.File("Journal.fixture.log"),rows.Select(r=>JsonSerializer.Serialize(r)));
17 return(new("F1",7,at,bought),new(){From=new(){MarketId=1},To=new(){MarketId=2}});
18 }
19 [Test] public async Task ExactConfirmedServiceCostsExplainWalletGapAndNeverDuplicate()
20 {
21 using var temp=new TemporaryDirectory();var (checkpoint,leg)=await Fixture(temp);using var ledger=new TradeProfitLedger(temp.File("ledger"));ledger.RecordTrade(checkpoint.Bought!);
22 Check.That(await ExternalServiceCosts.ReconcileAsync(temp.Path,temp.Path,checkpoint,leg,ledger,10000,9872,default));
23 Check.Equal(23m,ledger.Snapshot.Fuel);Check.Equal(65m,ledger.Snapshot.Repairs);
24 Check.That(!await ExternalServiceCosts.ReconcileAsync(temp.Path,temp.Path,checkpoint,leg,ledger,10000,9872,default));
25 Check.Equal(88m,ledger.Snapshot.Expenses);Check.Equal(2,Directory.GetFiles(temp.File("external-service-proofs")).Length);
26 }
27 [Test] public async Task WrongWalletShipCommanderOrStationCannotCreateBalancingExpenses()
28 {
29 foreach(var fixture in new[]{("F1",7,1,9871L),("F2",7,1,9872L),("F1",8,1,9872L),("F1",7,99,9872L)})
30 {
31 using var temp=new TemporaryDirectory();var (checkpoint,leg)=await Fixture(temp,fixture.Item1,fixture.Item2,fixture.Item3);using var ledger=new TradeProfitLedger(temp.File("ledger"));ledger.RecordTrade(checkpoint.Bought!);
32 Check.That(!await ExternalServiceCosts.ReconcileAsync(temp.Path,temp.Path,checkpoint,leg,ledger,10000,fixture.Item4,default));Check.Equal(0m,ledger.Snapshot.Expenses);
33 }
34 }
35 [Test] public async Task RecoveryAfterOnlyOneExternalCostWasSavedAddsOnlyTheRemainingReceipt()
36 {
37 using var temp=new TemporaryDirectory();var (checkpoint,leg)=await Fixture(temp);
38 using(var completed=new TradeProfitLedger(temp.File("complete-ledger")))
39 {completed.RecordTrade(checkpoint.Bought!);Check.That(await ExternalServiceCosts.ReconcileAsync(temp.Path,temp.Path,checkpoint,leg,completed,10000,9872,default));}
40 var first=Directory.GetFiles(temp.File("external-service-proofs")).Select(path=>JsonDocument.Parse(File.ReadAllText(path)))
41 .Select(doc=>{using(doc)return JsonSerializer.Deserialize<TradeCashEntry>(doc.RootElement.GetProperty("entry"),ToolboxSettings.JsonOptions)!;})
42 .Single(entry=>entry.Kind==TradeCashKind.Fuel);
43 using(var partial=new TradeProfitLedger(temp.File("partial-ledger"))){partial.RecordTrade(checkpoint.Bought!);partial.Record(first);}
44 using var restored=new TradeProfitLedger(temp.File("partial-ledger"));
45 Check.That(await ExternalServiceCosts.ReconcileAsync(temp.Path,temp.Path,checkpoint,leg,restored,10000,9872,default));
46 Check.Equal(23m,restored.Snapshot.Fuel);Check.Equal(65m,restored.Snapshot.Repairs);Check.Equal(88m,restored.Snapshot.Expenses);
47 }
48 }
Modified XFEEDToolBox.Test/NativeFlightTestProtectionTests.cs +50 -4
Modified XFEEDToolBox.Test/NativeTradeCommandDriverTests.cs +18 -0
Modified XFEEDToolBox.Test/NavigationTests.cs +2 -0
Modified XFEEDToolBox.Test/TradeJourneyCheckpointTests.cs +7 -0
Added XFEEDToolBox.Test/TradeRecoveryTests.cs +115 -0
Modified XFEEDToolBox/Services/WindowsTradingBootstrap.cs +10 -6
Modified XFEEDToolBox/ViewModels/AutomaticTradeViewModel.cs +39 -8
Modified XFEEDToolBox/ViewModels/TradeExecutionViewModel.cs +2 -2
Modified XFEEDToolBox/ViewModels/TradePlanExecutionViewModel.cs +1 -1
Modified XFEEDToolBox/XFEEDToolBox.csproj +1 -1