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