using System.Text.Json;
using XFEEDToolBox.Core.Models;
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 AutomaticTradeEstimate? Estimate{get;init;}public int CompletedRounds{get;init;}public AutomaticSupplyChoice? SupplySelection{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,
Func<PlayerState> player,Func<CancellationToken,Task> beforeInput,string journalDirectory,
Func<CancellationToken,Task>? recoverToSameMode=null,Func<Station,IReadOnlyList<CommodityQuote>,CancellationToken,Task>? publishQuotes=null,
Func<CancellationToken,Task>? validateFlight=null,
Func<AutomaticSupplyRequest,CancellationToken,Task<AutomaticSupplyChoice?>>? selectSource=null,Func<bool>? sessionRestartPending=null)
{
public event Action<AutomaticTradeProgress>? Progress;
public int MaximumCycles{get;init;}
private readonly Dictionary<long,IReadOnlyList<CommodityQuote>> quotes=[];
private string phase="preflight",next="",message="";
private IDisposable? runningMenuRecovery;
private int menuRecoveryActive;
private long menuRecoveryGeneration;
private string? active;
private int completed;
private int completedRounds;
private decimal profit;
private AutomaticTradeEstimate? estimate;
private PlayerState initial=new();
private TradeProfitLedger? routeLedger;
private bool recoveringHistoricalSale;
private readonly string root=configuration.WorkingDirectory;
private Station source=configuration.Source;
private AutomaticSupplyChoice? supplySelection;
private readonly Dictionary<long,TradeSourceThreat> sourceThreats=[];
private sealed record SelectedSource(int Schema,long Destination,ulong? Goal,AutomaticSupplyChoice Choice);
private async Task WriteAsync(string path,object value,CancellationToken token)
{await File.WriteAllTextAsync(path+".tmp",JsonSerializer.Serialize(value,ToolboxSettings.JsonOptions),token).ConfigureAwait(false);File.Move(path+".tmp",path,true);}
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){Estimate=estimate,CompletedRounds=completedRounds,SupplySelection=supplySelection});
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,
completedBatches=completed,completedRounds,activeJourney=active,current.SystemName,current.StationName,current.Docked,current.CargoCount,
current.CurrentShipId,current.Ship,current.CargoCapacity,netProfit=profit,sourceMarketId=source.MarketId,sourceName=source.DisplayName,
supplySelection},token).ConfigureAwait(false);
}
private void RefreshCompleted()
{
completed=0;profit=routeLedger?.Snapshot.NetProfit??0;
foreach(var path in Directory.EnumerateDirectories(root,"journey-*"))
{
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(result is not null&&File.Exists(Path.Combine(path,"leg.json"))
&&JsonSerializer.Deserialize<TradeLeg>(File.ReadAllText(Path.Combine(path,"leg.json")),ToolboxSettings.JsonOptions) is {} leg
&&HistoricalTradeCompletion.IsRecovered(path,leg,player())){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();}
}
completedRounds=AutomaticTradeRounds.Refresh(configuration with{Source=source},player());
}
private async Task CheckAsync(CancellationToken token)
{
token.ThrowIfCancellationRequested();if(sessionRestartPending?.Invoke()==true)throw new FlightSessionRestartRequiredException();var current=player();
if(!current.InGame||current.CommanderId!=initial.CommanderId||current.CurrentShipId!=initial.CurrentShipId||current.CargoCapacity!=initial.CargoCapacity
||NavigationSafety.UnhealthyHull(current))throw new InvalidOperationException("本轮运行期间飞船、货舱、会话或安全状态改变,已交还控制。");
if(NavigationSafety.HasExternalThreat(current))throw new FlightThreatPauseException();
await beforeInput(token).ConfigureAwait(false);
}
private async Task<bool> WaitForSafetyAsync(Station destination,CancellationToken token)
{
var originalPhase=phase;RevokeMenuRecovery();await driver.ReleaseAllAsync().ConfigureAwait(false);
await StateAsync("safety-pause","实际威胁或近期受损已暂停驾驶输入;等待威胁结束及连续健康授权,稳定满 10 秒后接续原航程。",destination,token);
var safety=new AutomaticTradeSafetyGate(initial){RequireDocked=false};
while(true)
{
token.ThrowIfCancellationRequested();if(sessionRestartPending?.Invoke()==true)throw new FlightSessionRestartRequiredException();var current=player();
var ready=false;
// Observe first without arming any input while threat/flight remains.
safety.RequireSameIdentity(current);
if(current.InGame&&!NavigationSafety.HasExternalThreat(current)&&!NavigationSafety.UnhealthyHull(current))
{
try{await beforeInput(token).ConfigureAwait(false);ready=true;}
catch(FlightTelemetryUnavailableException){/* Actual damage must stabilize before authorization. */}
catch(FlightHealthPauseException){/* The shared health guard still forbids inputs. */}
}
if(safety.Observe(current,DateTimeOffset.UtcNow,ready))break;
await Task.Delay(500,token).ConfigureAwait(false);
}
await RefreshThreatsAsync(token);
var recovered=false;var dock=player();await StateAsync("recovering-safety","威胁已结束,连续健康授权稳定,正在核对原回执与驾驶状态。",token:token);
if(dock.Docked&&dock.MarketId is >0)
{
await driver.ServiceDockedAsync(new(dock.MarketId.Value,initial.CargoCapacity),CheckAsync,token).ConfigureAwait(false);
if(recoverToSameMode is not null){await RecoverAsync(token).ConfigureAwait(false);recovered=true;}
}
await driver.PreflightAsync(token).ConfigureAwait(false);BeginMenuRecovery(token);
await StateAsync(originalPhase,dock.Docked?"安全维修与连续健康状态已确认,继续原订单。":"连续健康监测已确认损伤停止,接续原航程;下一次停靠时核对维修。",token:token);
return recovered;
}
private async Task RefreshThreatsAsync(CancellationToken token)
{
foreach(var incident in TradeSourceThreatJournal.Read(journalDirectory,initial,DateTimeOffset.UtcNow))
if(incident.MarketId!=configuration.Destination.MarketId)
{sourceThreats.TryGetValue(incident.MarketId,out var prior);sourceThreats[incident.MarketId]=TradeSourceThreat.Merge(prior,incident);}
if(sourceThreats.Count>0)await WriteAsync(Path.Combine(root,"source-threats.json"),sourceThreats.Values.ToArray(),token);
}
private async Task RecoverAsync(CancellationToken token)
{
if(recoverToSameMode is null)throw new InvalidOperationException("原始返回主菜单恢复未就绪,当前现场已保留。");
var previousPhase=phase;RevokeMenuRecovery();
await StateAsync("recovering-session","正在通过原始主菜单恢复同一游戏模式",token:token);
// Failed docking handoff revokes the old input context. Release it
// and require a newly verified healthy context before menu recovery;
// the health authorizer retains real damage and emergency blocks.
await driver.ReleaseAllAsync().ConfigureAwait(false);
await beforeInput(token).ConfigureAwait(false);
await recoverToSameMode(token).ConfigureAwait(false);var restored=player();
if(restored.CommanderId!=initial.CommanderId||restored.CurrentShipId!=initial.CurrentShipId||restored.CargoCapacity!=initial.CargoCapacity)
throw new InvalidOperationException("重进后的身份与本轮飞船不一致。");
initial=restored;await driver.PreflightAsync(token).ConfigureAwait(false);
await StateAsync(previousPhase,"原游戏会话已恢复,继续当前跑商。",token:token);
BeginMenuRecovery(token);
}
private void RevokeMenuRecovery()
{
Volatile.Write(ref menuRecoveryActive,0);Interlocked.Increment(ref menuRecoveryGeneration);
var previous=runningMenuRecovery;runningMenuRecovery=null;previous?.Dispose();
}
private void BeginMenuRecovery(CancellationToken token)
{
RevokeMenuRecovery();token.ThrowIfCancellationRequested();
var generation=Interlocked.Increment(ref menuRecoveryGeneration);Volatile.Write(ref menuRecoveryActive,1);
bool Allowed()=>Volatile.Read(ref menuRecoveryActive)==1&&Interlocked.Read(ref menuRecoveryGeneration)==generation&&!token.IsCancellationRequested
&&Volatile.Read(ref phase) is not ("recovering-session" or "requires-recovery" or "reconciling-order" or "stopped");
runningMenuRecovery=((ITradeRunMenuRecovery)driver).EnterRunningMenuRecovery(Allowed,token);
if(!Allowed())RevokeMenuRecovery();
}
private async Task TravelAsync(Station destination,CancellationToken token)
{
Planning.ShipPadPolicy.RequireFits(destination,player().Ship);
if(player().Docked&&player().MarketId==destination.MarketId){RefreshCompleted();return;}
await StateAsync("travelling","正在前往 "+destination.DisplayName,destination,token);
for(var recoveries=0;;)
{
try{await driver.ExecuteAsync(new(TradeActionKind.Travel,new(){From=player().MarketId==source.MarketId?source:configuration.Destination,To=destination},null),CheckAsync,token).ConfigureAwait(false);RefreshCompleted();return;}
catch(DockingHandoffException)when(recoveries<3&&recoverToSameMode is not null){recoveries++;await RecoverAsync(token).ConfigureAwait(false);}
catch(StationDepartureRecoveryException)when(recoveries<3&&recoverToSameMode is not null){recoveries++;await RecoverAsync(token).ConfigureAwait(false);}
catch(Exception error)when(FlightSafetyPauseException.Present(error)&&!token.IsCancellationRequested)
{
var recovered=await WaitForSafetyAsync(destination,token).ConfigureAwait(false);
if(FlightSafetyPauseException.RequiresDepartureSessionRecovery(error,player().Docked,recovered))
{
if(recoveries>=3||recoverToSameMode is null)throw;
recoveries++;await RecoverAsync(token).ConfigureAwait(false);
}
}
catch(Exception error)when(NavigationRecovery.CanRetry(error,token))
{await StateAsync("travelling","正在衔接下一段航程,自动继续",destination,token);await Task.Delay(100,token).ConfigureAwait(false);}
}
}
private async Task<IReadOnlyList<CommodityQuote>> QuotesAsync(Station station,CancellationToken token)
{
await StateAsync("reading-market","正在读取 "+station.DisplayName+" 的实时行情",station,token);
var rows=await driver.ReadDockedQuotesAsync(station.MarketId,token).ConfigureAwait(false);quotes[station.MarketId]=rows;
await WriteAsync(Path.Combine(root,"quotes-"+station.MarketId+".json"),rows,token);
if(publishQuotes is not null)await publishQuotes(station,rows,token).ConfigureAwait(false);
return rows;
}
private bool FreshQuotes(Station station)=>quotes.TryGetValue(station.MarketId,out var rows)&&rows.Count>0&&rows.All(q=>DateTimeOffset.UtcNow-q.CollectedAt<TimeSpan.FromMinutes(30));
private IReadOnlyList<ConfirmedTradeReceipt> ConfirmedSupplyTrades()
{
var receipts=new List<ConfirmedTradeReceipt>();
foreach(var file in Directory.EnumerateDirectories(root,"journey-*").Select(p=>Path.Combine(p,"journey.json")).Where(File.Exists))
{
var checkpoint=TradeJourneyCheckpoints.Load(file);
if(checkpoint.Commander!=initial.CommanderId||checkpoint.ShipId!=initial.CurrentShipId)continue;
if(checkpoint.Bought is{} bought)receipts.Add(bought);if(checkpoint.Sold is{} sold)receipts.Add(sold);
}
return receipts;
}
private async Task<bool> SelectSourceAsync(CancellationToken token)
{
if(!configuration.UsesDynamicSources||selectSource is null)return true;
if(active is not null)throw new InvalidOperationException("原采购或交付批次尚未完成,未替换其货源或订单。");
driver.RequireSourceSelectionReady();await CheckAsync(token);
await StateAsync("selecting-source","正在按供应量、实际货舱和完整循环时间重新比较全部 CG 货源",token:token);
var wallet=await driver.RefreshWalletAsync(token);var current=player();
var access=await StationAccessWaiter.ReadKnownFinesAsync(journalDirectory,current.CommanderId,token);
RequireSameSelectionContext(current);await CheckAsync(token);driver.RequireSourceSelectionReady();
var excluded=sourceThreats.Values.Where(i=>i.AvoidUntil>DateTimeOffset.UtcNow).Select(i=>i.MarketId).ToHashSet();
excluded.UnionWith(access.Restrictions.Keys.Where(market=>market!=configuration.Destination.MarketId));
await WriteAsync(Path.Combine(root,"source-access-latest.json"),new{at=DateTimeOffset.UtcNow,current.CommanderId,current.CurrentShipId,current.LoadGameAt,
journalFilesRead=access.JournalFilesRead,knownUnpaidFines=access.Restrictions.Values,excludedSourceMarkets=excluded,originalOrdersUnchanged=true},token);
var services=current.MarketId is >0?await driver.ReadServiceBudgetAsync(current.MarketId.Value,token):0;
var receipts=ConfirmedSupplyTrades();
var choice=await selectSource(new(configuration with{Source=source},current,wallet.Credits,
configuration.PurchaseReserve(wallet.Credits,current.Rebuy,services),AutomaticSupplyPlanner.DeductConfirmedTrades(quotes,receipts))
{ConfirmedTrades=receipts,ExcludedSourceMarkets=excluded},token).ConfigureAwait(false);
RequireSameSelectionContext(current);await CheckAsync(token);driver.RequireSourceSelectionReady();
if(choice is null)
{
await StateAsync("waiting-market","当前合格货源的供应、需求或资金不足,保持停靠等待新报价;未沿用已耗尽的货源。",token:token);
await Task.Delay(TimeSpan.FromSeconds(30),token).ConfigureAwait(false);return false;
}
if(excluded.Contains(choice.Source.MarketId)||choice.Source.MarketId==configuration.Destination.MarketId||choice.Delivery.To.MarketId!=configuration.Destination.MarketId
||!configuration.Commodities.Contains(choice.Order.Commodity,StringComparer.Ordinal)||choice.Order.Quantity>current.FreeCargo)
throw new InvalidDataException("新货源、CG交付站或实际空舱与规划结果不一致。");
Planning.ShipPadPolicy.RequireFits(choice.Source,current.Ship);
source=choice.Source;supplySelection=choice;estimate=new(choice.EstimatedNetProfit,choice.EstimatedMinutes,choice.Basis);
await WriteAsync(Path.Combine(root,"selected-source.json"),new SelectedSource(1,configuration.Destination.MarketId,configuration.CommunityGoalId,choice),token);
await StateAsync("source-selected",$"已选择 {source.DisplayName} · {choice.Basis} 预计 {choice.CreditsPerHour:N0} Cr/h。",source,token);
return true;
}
private void RequireSameSelectionContext(PlayerState expected)
{
var current=driver.RequireSourceSelectionReady();
if(current.CommanderId!=expected.CommanderId||current.CurrentShipId!=expected.CurrentShipId||current.LoadGameAt!=expected.LoadGameAt
||current.Docked!=expected.Docked||current.MarketId!=expected.MarketId)
throw new InvalidDataException("比较货源期间实际飞船、会话或停靠站点已改变,未采用旧访问资格。");
}
private async Task<bool> ReselectRestrictedArrivalAsync(Station arrived,CancellationToken token)
{
if(!configuration.UsesDynamicSources||selectSource is null||arrived.MarketId==configuration.Destination.MarketId)return false;
var current=driver.RequireSourceSelectionReady();await CheckAsync(token);
if(!current.Docked||current.MarketId!=arrived.MarketId)throw new InvalidDataException("尚未确认抵达预期货源,未读取或替换交易市场。");
var access=await StationAccessWaiter.ReadKnownFinesAsync(journalDirectory,current.CommanderId,token);
RequireSameSelectionContext(current);await CheckAsync(token);
if(access.FineAt(arrived.MarketId)<=0)return false;
await StateAsync("source-access-restricted",$"{arrived.DisplayName} 已确认未清罚款 {access.FineAt(arrived.MarketId):N0} Cr,尚未报价或采购,正在比较其他合格货源。",arrived,token);
await SelectSourceAsync(token);return true;
}
private NativeCommunityGoalClient GoalClient()=>new(channel,configuration.Destination.MarketId,configuration.CommunityGoalId);
private string? ManualSettlement(string directory,TradeLeg leg)
{
var checkpointPath=Path.Combine(directory,"journey.json");var resultPath=Path.Combine(directory,"result.json");
if(!File.Exists(checkpointPath)||!File.Exists(resultPath))return null;
var checkpoint=TradeJourneyCheckpoints.Load(checkpointPath);
var result=JsonSerializer.Deserialize<NativeTradeJourneyResult>(File.ReadAllText(resultPath),ToolboxSettings.JsonOptions);
if(result is null||checkpoint.Bought is not null||checkpoint.Commander!=initial.CommanderId)return null;
var matches=new List<string>();var order=leg.Orders.Single();
foreach(var marker in Directory.EnumerateFiles(root,"*.externally-settled.json",SearchOption.AllDirectories))
{
var pending=marker.Replace(".externally-settled.json",".pending.json",StringComparison.Ordinal);
if(!File.Exists(pending)||!ExternalTradeSettlement.IsSettled(pending,initial))continue;
using var proof=JsonDocument.Parse(File.ReadAllText(marker));using var original=JsonDocument.Parse(File.ReadAllText(pending));
var row=proof.RootElement;var request=original.RootElement;var intent=request.GetProperty("intent");
if(!request.TryGetProperty("created",out var at)||!at.TryGetDateTimeOffset(out var created)
||created<checkpoint.StartedAt||created>result.Finished)continue;
if(row.GetProperty("shipId").GetInt64()==checkpoint.ShipId&&row.GetProperty("commodity").GetString()==order.Commodity
&&row.GetProperty("quantity").GetInt32()==order.Quantity&&row.GetProperty("unitPurchasePrice").GetDecimal()==order.BuyPrice
&&intent.GetProperty("market").GetInt64()==leg.From.MarketId&&row.GetProperty("saleProof").GetProperty("MarketID").GetInt64()==leg.To.MarketId)
matches.Add(marker);
}
return matches.Count==1?matches[0]:null;
}
private async Task<CommunityGoalInfo> GoalAsync(CancellationToken token)
{
var client=GoalClient();var path=Path.Combine(root,"membership");
return await client.EnsureTradeJoinedAsync(path,CheckAsync,token).ConfigureAwait(false);
}
private async Task CompleteAsync(string directory,TradeLeg leg,CancellationToken token)
{
Planning.ShipPadPolicy.RequireFits(leg.From,player().Ship);Planning.ShipPadPolicy.RequireFits(leg.To,player().Ship);
estimate=supplySelection is {} selection&&selection.Source.MarketId==leg.From.MarketId&&selection.Order==leg.Orders.Single()
?new(selection.EstimatedNetProfit,selection.EstimatedMinutes,selection.Basis):AutomaticTradeEstimate.For(leg,root,initial.CurrentShipId);
active=directory;await StateAsync("cargo-journey","正在采购、运输并出售本批货物",leg.To,token);
long? historicalCredits=null;
if(HistoricalTradeCompletion.ReadCandidate(directory,leg,player()) is not null)
{
recoveringHistoricalSale=true;
try
{
await StateAsync("recovering-confirmed-sale","原批次已经实际售罄,正在核对目的站维护和活动贡献;不重发买卖订单。",leg.To,token);
await TravelAsync(leg.To,token);
await driver.ServiceDockedAsync(new(leg.To.MarketId,initial.CargoCapacity),CheckAsync,token).ConfigureAwait(false);
historicalCredits=(await driver.RefreshWalletAsync(token)).Credits;
}
finally{recoveringHistoricalSale=false;}
}
else for(var recoveries=0;;)
{
var journey=new NativeTradeJourney(driver,player,CheckAsync,directory){JournalDirectory=journalDirectory};
journey.Status+=text=>Progress?.Invoke(new(phase,text,next,completed,profit,active){Estimate=estimate,CompletedRounds=completedRounds,SupplySelection=supplySelection});
try{await journey.RunAsync(leg,token).ConfigureAwait(false);break;}
catch(NativeTradeJourneyIncompleteException error)when(error.InnerException is DockingHandoffException&&recoveries<3&&recoverToSameMode is not null){recoveries++;await RecoverAsync(token).ConfigureAwait(false);}
catch(NativeTradeJourneyIncompleteException error)when(error.InnerException is StationDepartureRecoveryException&&recoveries<3&&recoverToSameMode is not null){recoveries++;await RecoverAsync(token).ConfigureAwait(false);}
catch(NativeTradeJourneyIncompleteException error)when(FlightSafetyPauseException.Present(error)&&!token.IsCancellationRequested)
{
var recovered=await WaitForSafetyAsync(leg.To,token).ConfigureAwait(false);
if(FlightSafetyPauseException.RequiresDepartureSessionRecovery(error,player().Docked,recovered))
{
if(recoveries>=3||recoverToSameMode is null)throw;
recoveries++;await RecoverAsync(token).ConfigureAwait(false);
}
}
catch(NativeTradeJourneyIncompleteException error)when(error.InnerException is {} cause&&NavigationRecovery.CanRetry(cause,token))
{await StateAsync("cargo-journey","正在衔接下一段航程,保留原货物继续",leg.To,token);await Task.Delay(100,token).ConfigureAwait(false);}
}
var membership=Path.Combine(directory,"membership-before.json");
if(File.Exists(membership))
{
using var before=JsonDocument.Parse(await File.ReadAllTextAsync(membership,token));var row=before.RootElement;
var id=row.GetProperty("goalId").GetUInt64();var baseline=row.GetProperty("contribution").GetInt64();var until=DateTimeOffset.UtcNow.AddSeconds(35);
while(true)
{
var read=await GoalClient().ReadAsync(true,token).ConfigureAwait(false);
var goal=CommunityGoalCatalogue.RequireDestinationTrade(read.Goals,leg.To.MarketId,id,DateTimeOffset.UtcNow,true);
if(goal.Contribution>=baseline+leg.Quantity){await WriteAsync(Path.Combine(directory,"cg-contribution-confirmed.json"),read,token);break;}
if(DateTimeOffset.UtcNow>=until)throw new InvalidDataException("实际CG贡献尚未确认本批交货;销售回执已保留。");
await Task.Delay(250,token).ConfigureAwait(false);
}
}
if(historicalCredits is {} historicalWallet)await HistoricalTradeCompletion.SealAsync(directory,leg,player(),historicalWallet,token);
await WriteAsync(Path.Combine(directory,"settled.json"),new{at=DateTimeOffset.UtcNow,passed=true,historicalSaleRecovered=historicalCredits is not null},token);
active=null;RefreshCompleted();await StateAsync("cycle-completed","本批已完成,继续下一轮",token:token);
}
public async Task RunAsync(CancellationToken token)
{
configuration.Validate();Directory.CreateDirectory(root);
using var lease=new FileStream(Path.Combine(root,"workflow.lock"),FileMode.OpenOrCreate,FileAccess.ReadWrite,FileShare.None);
using var expenses=new TradeProfitLedger(Path.Combine(root,"route-ledger"));routeLedger=expenses;
void RecordOutsideJourney(ConfirmedDockedServiceReceipt receipt)
{
if(active is not null&&!recoveringHistoricalSale)return;
var entry=receipt.ToCashEntry();
// The durable operation key is the accounting authority. Publish
// only its new debit, before any later travel or service failure
// can write a state snapshot. Active batches keep their own fees;
// refreshing their completion here could count them twice or early.
if(expenses.Record(entry))profit-=entry.Amount;
}
driver.ServiceConfirmed+=RecordOutsideJourney;
initial=player();
try
{
var selectedFile=Path.Combine(root,"selected-source.json");
if(configuration.UsesDynamicSources&&selectSource is not null&&File.Exists(selectedFile))
{
var saved=JsonSerializer.Deserialize<SelectedSource>(await File.ReadAllTextAsync(selectedFile,token),ToolboxSettings.JsonOptions);
if(saved is null||saved.Schema!=1||saved.Destination!=configuration.Destination.MarketId||saved.Goal!=configuration.CommunityGoalId
||saved.Choice.Source.MarketId==configuration.Destination.MarketId)throw new InvalidDataException("已保存的CG货源与本次交付活动不一致。");
source=saved.Choice.Source;
}
var threatsPath=Path.Combine(root,"source-threats.json");
if(File.Exists(threatsPath))foreach(var incident in JsonSerializer.Deserialize<TradeSourceThreat[]>(await File.ReadAllTextAsync(threatsPath,token),ToolboxSettings.JsonOptions)??[])
sourceThreats[incident.MarketId]=incident;
await RefreshThreatsAsync(token);
RefreshCompleted();await StateAsync("preflight","正在核对当前飞船和交易接口",token:token);
if(!initial.InGame||initial.CurrentShipId is null||initial.CargoCapacity<=0||initial.Rebuy<=0||!initial.CargoReliable)
throw new InvalidOperationException("当前飞船、实际货舱、重购费或货物观测尚未就绪。");
Planning.ShipPadPolicy.RequireFits(source,initial.Ship);Planning.ShipPadPolicy.RequireFits(configuration.Destination,initial.Ship);
var inventory=await channel.CallAsync(new{op="commerce.inventory"},token).ConfigureAwait(false);
await NativeUnsubmittedTradeIntents.ReconcileAsync(Path.Combine(root,"operations"),channel,initial,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);
BeginMenuRecovery(token);
if(initial.Docked&&initial.MarketId is{} resumedThreatMarket&&sourceThreats.TryGetValue(resumedThreatMarket,out var dockIncident)&&dockIncident.AvoidUntil>DateTimeOffset.UtcNow
||!initial.Docked&&TradeSourceThreatJournal.Read(journalDirectory,initial,DateTimeOffset.UtcNow).Count>0)
await WaitForSafetyAsync(source,token).ConfigureAwait(false);
if(player().Docked&&player().MarketId is {} currentMarket&¤tMarket!=source.MarketId&¤tMarket!=configuration.Destination.MarketId)
await driver.ServiceDockedAsync(new(currentMarket,initial.CargoCapacity),CheckAsync,token).ConfigureAwait(false);
await WriteAsync(Path.Combine(root,"current-ship.json"),new{at=DateTimeOffset.UtcNow,initial.CommanderId,initial.CurrentShipId,initial.Ship,initial.CargoCapacity,
initial.Rebuy,reserve=Math.Max(configuration.ReserveCredits,initial.Rebuy)},token);
var pending=Directory.EnumerateDirectories(root,"journey-*").Where(p=>File.Exists(Path.Combine(p,"leg.json"))&&!File.Exists(Path.Combine(p,"settled.json"))
&&!File.Exists(Path.Combine(p,"cg-contribution-confirmed.json"))).ToArray();
foreach(var path in pending)
{
using var document=JsonDocument.Parse(await File.ReadAllTextAsync(Path.Combine(path,"leg.json"),token));
var leg=JsonSerializer.Deserialize<TradeLeg>(document.RootElement.GetRawText(),ToolboxSettings.JsonOptions)!;
var manual=ManualSettlement(path,leg);
if(manual is not null){await WriteAsync(Path.Combine(path,"external-completion.json"),new{at=DateTimeOffset.UtcNow,manual=true,proof=manual,nativeJourneyPassed=false},token);continue;}
if(File.Exists(Path.Combine(path,"external-completion.json")))continue;
await CompleteAsync(path,leg,token).ConfigureAwait(false);
}
if(player().CargoCount!=0)throw new InvalidOperationException("当前已有货物,但没有唯一可恢复的订单,未新增采购。");
// Recovered launch failures can leave the same ship docked and damaged.
// Resolve all prior orders first, then finish station services before another departure.
if(player().Docked&&player().MarketId is {} resumedMarket)
await driver.ServiceDockedAsync(new(resumedMarket,initial.CargoCapacity),CheckAsync,token).ConfigureAwait(false);
foreach(var file in Directory.EnumerateFiles(root,"quotes-*.json"))
{
var rows=JsonSerializer.Deserialize<CommodityQuote[]>(await File.ReadAllTextAsync(file,token),ToolboxSettings.JsonOptions)??[];
if(rows.Length>0&&rows.Select(q=>q.MarketId).Distinct().Count()==1)quotes[rows[0].MarketId]=rows;
}
if(configuration.Mode==AutomaticTradeMode.CommunityGoal)
{await TravelAsync(configuration.Destination,token);await GoalAsync(token);await QuotesAsync(configuration.Destination,token);await driver.ServiceDockedAsync(new(configuration.Destination.MarketId,initial.CargoCapacity),CheckAsync,token);}
var cycles=0;
while(!token.IsCancellationRequested)
{
try{await CheckAsync(token);}
catch(FlightSafetyPauseException){await WaitForSafetyAsync(player().MarketId==configuration.Destination.MarketId?configuration.Destination:source,token);continue;}
var atDestination=player().Docked&&player().MarketId==configuration.Destination.MarketId;
// Complete any configured return trade to this batch's home
// before comparing suppliers for a new outbound batch.
if(atDestination&&!configuration.ReturnTrade&&!await SelectSourceAsync(token))continue;
var from=atDestination?configuration.Destination:source;var to=atDestination?source:configuration.Destination;
if(atDestination&&!configuration.ReturnTrade){await TravelAsync(source,token);continue;}
if(!FreshQuotes(to))
{await TravelAsync(to,token);await QuotesAsync(to,token);await driver.ServiceDockedAsync(new(to.MarketId,initial.CargoCapacity),CheckAsync,token);continue;}
await TravelAsync(from,token);
if(await ReselectRestrictedArrivalAsync(from,token))continue;
var live=await QuotesAsync(from,token);
if(from.MarketId!=configuration.Destination.MarketId&&configuration.UsesDynamicSources&&selectSource is not null)
{
// Replace the cached source stock with the actual docked
// market before choosing an irreversible purchase.
if(!await SelectSourceAsync(token))continue;
if(source.MarketId!=from.MarketId){await TravelAsync(source,token);continue;}
}
var wallet=await driver.RefreshWalletAsync(token);
var serviceBudget=await driver.ReadServiceBudgetAsync(from.MarketId,token);
var reserve=configuration.PurchaseReserve(wallet.Credits,player().Rebuy,serviceBudget);
var filter=configuration.Mode==AutomaticTradeMode.CommunityGoal&&to.MarketId==configuration.Destination.MarketId?configuration.Commodities:null;
if(configuration.Mode==AutomaticTradeMode.CommunityGoal&&to.MarketId==configuration.Destination.MarketId&&filter is not {Length:>0})
throw new InvalidOperationException("请为此贸易CG指定接受的商品;不会猜测CG范围或倍增已提高的报价。");
TradeOrder order;
try{order=AutomaticTradePlanner.Select(live,quotes[to.MarketId],player().FreeCargo,wallet.Credits,reserve,filter);}
catch(InvalidOperationException)when(from.MarketId==configuration.Destination.MarketId)
{await driver.ServiceDockedAsync(new(from.MarketId,initial.CargoCapacity),CheckAsync,token);await TravelAsync(source,token);continue;}
catch(InvalidOperationException)
{
await driver.ServiceDockedAsync(new(from.MarketId,initial.CargoCapacity),CheckAsync,token);
await StateAsync("waiting-market","当前没有满足资金、库存和利润条件的采购,保持停靠并等待行情刷新。",from,token);
await Task.Delay(TimeSpan.FromSeconds(30),token).ConfigureAwait(false);continue;
}
// Service and wallet reads can take time after the comparison.
// Recheck known access before creating any procurement batch.
if(await ReselectRestrictedArrivalAsync(from,token))continue;
long? baseline=null;ulong? goalId=null;
if(configuration.Mode==AutomaticTradeMode.CommunityGoal&&to.MarketId==configuration.Destination.MarketId)
{
// Membership was confirmed on the previous destination visit.
var file=Path.Combine(root,"membership","membership-latest.json");
var membership=JsonSerializer.Deserialize<OriginalCommunityGoalRead>(await File.ReadAllTextAsync(file,token),ToolboxSettings.JsonOptions)!;
var goal=CommunityGoalCatalogue.RequireDestinationTrade(membership.Goals,to.MarketId,configuration.CommunityGoalId,DateTimeOffset.UtcNow,true);
baseline=goal.Contribution??0;goalId=goal.Id;
}
var leg=supplySelection is {} chosen&&chosen.Source.MarketId==from.MarketId&&chosen.Order==order?chosen.Delivery
:new TradeLeg{Number=1,From=from,To=to,Orders=[order]};
var directory=Path.Combine(root,"journey-"+Guid.NewGuid().ToString("N"));Directory.CreateDirectory(directory);
await WriteAsync(Path.Combine(directory,"leg.json"),leg,token);
if(goalId is not null)await WriteAsync(Path.Combine(directory,"membership-before.json"),new{goalId,contribution=baseline,at=DateTimeOffset.UtcNow},token);
await CompleteAsync(directory,leg,token);await QuotesAsync(to,token);
if(to.MarketId==configuration.Destination.MarketId&&configuration.Mode==AutomaticTradeMode.CommunityGoal)await GoalAsync(token);
if(MaximumCycles>0&&++cycles>=MaximumCycles)return;
}
token.ThrowIfCancellationRequested();
}
catch(OperationCanceledException)when(token.IsCancellationRequested){RevokeMenuRecovery();AutomaticTradeRunSession.Finish(root);await StateAsync("stopped","自动跑商已取消,原始订单和恢复记录已保留。");}
catch(Exception error)
{
RevokeMenuRecovery();
try{await RefreshThreatsAsync(CancellationToken.None);}catch(Exception recordError)when(recordError is IOException or UnauthorizedAccessException){error.Data["ThreatRecordFailure"]=recordError.Message;}
await WriteAsync(Path.Combine(root,"failure-"+DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()+".json"),new{at=DateTimeOffset.UtcNow,phase,error=error.ToString()},CancellationToken.None);
await StateAsync(NativeOrderReconciliationRequiredException.Present(error)?"reconciling-order":"requires-recovery",error.Message);throw;
}
finally{RevokeMenuRecovery();driver.ServiceConfirmed-=RecordOutsideJourney;try{await driver.ReleaseAllAsync().ConfigureAwait(false);}finally{routeLedger=null;}}
}
}
using System.Text.Json;
using XFEEDToolBox.Core.Models;
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 AutomaticTradeEstimate? Estimate{get;init;}public int CompletedRounds{get;init;}public AutomaticSupplyChoice? SupplySelection{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,
Func<PlayerState> player,Func<CancellationToken,Task> beforeInput,string journalDirectory,
Func<CancellationToken,Task>? recoverToSameMode=null,Func<Station,IReadOnlyList<CommodityQuote>,CancellationToken,Task>? publishQuotes=null,
Func<CancellationToken,Task>? validateFlight=null,
Func<AutomaticSupplyRequest,CancellationToken,Task<AutomaticSupplyChoice?>>? selectSource=null,Func<bool>? sessionRestartPending=null)
{
public event Action<AutomaticTradeProgress>? Progress;
public int MaximumCycles{get;init;}
private readonly Dictionary<long,IReadOnlyList<CommodityQuote>> quotes=[];
private string phase="preflight",next="",message="";
private IDisposable? runningMenuRecovery;
private int menuRecoveryActive;
private long menuRecoveryGeneration;
private string? active;
private int completed;
private int completedRounds;
private decimal profit;
private AutomaticTradeEstimate? estimate;
private PlayerState initial=new();
private TradeProfitLedger? routeLedger;
private bool recoveringHistoricalSale;
private readonly string root=configuration.WorkingDirectory;
private Station source=configuration.Source;
private AutomaticSupplyChoice? supplySelection;
private readonly Dictionary<long,TradeSourceThreat> sourceThreats=[];
private sealed record SelectedSource(int Schema,long Destination,ulong? Goal,AutomaticSupplyChoice Choice);
private async Task WriteAsync(string path,object value,CancellationToken token)
{await File.WriteAllTextAsync(path+".tmp",JsonSerializer.Serialize(value,ToolboxSettings.JsonOptions),token).ConfigureAwait(false);File.Move(path+".tmp",path,true);}
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){Estimate=estimate,CompletedRounds=completedRounds,SupplySelection=supplySelection});
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,
completedBatches=completed,completedRounds,activeJourney=active,current.SystemName,current.StationName,current.Docked,current.CargoCount,
current.CurrentShipId,current.Ship,current.CargoCapacity,netProfit=profit,sourceMarketId=source.MarketId,sourceName=source.DisplayName,
supplySelection},token).ConfigureAwait(false);
}
private void RefreshCompleted()
{
completed=0;profit=routeLedger?.Snapshot.NetProfit??0;
foreach(var path in Directory.EnumerateDirectories(root,"journey-*"))
{
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(result is not null&&File.Exists(Path.Combine(path,"leg.json"))
&&JsonSerializer.Deserialize<TradeLeg>(File.ReadAllText(Path.Combine(path,"leg.json")),ToolboxSettings.JsonOptions) is {} leg
&&HistoricalTradeCompletion.IsRecovered(path,leg,player())){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();}
}
completedRounds=AutomaticTradeRounds.Refresh(configuration with{Source=source},player());
}
private async Task CheckAsync(CancellationToken token)
{
token.ThrowIfCancellationRequested();if(sessionRestartPending?.Invoke()==true)throw new FlightSessionRestartRequiredException();var current=player();
if(!current.InGame||current.CommanderId!=initial.CommanderId||current.CurrentShipId!=initial.CurrentShipId||current.CargoCapacity!=initial.CargoCapacity
||NavigationSafety.UnhealthyHull(current))throw new InvalidOperationException("本轮运行期间飞船、货舱、会话或安全状态改变,已交还控制。");
if(NavigationSafety.HasExternalThreat(current))throw new FlightThreatPauseException();
await beforeInput(token).ConfigureAwait(false);
}
private async Task<bool> WaitForSafetyAsync(Station destination,CancellationToken token)
{
var originalPhase=phase;RevokeMenuRecovery();await driver.ReleaseAllAsync().ConfigureAwait(false);
await StateAsync("safety-pause","实际威胁或近期受损已暂停驾驶输入;等待威胁结束及连续健康授权,稳定满 10 秒后接续原航程。",destination,token);
var safety=new AutomaticTradeSafetyGate(initial){RequireDocked=false};
while(true)
{
token.ThrowIfCancellationRequested();if(sessionRestartPending?.Invoke()==true)throw new FlightSessionRestartRequiredException();var current=player();
var ready=false;
// Observe first without arming any input while threat/flight remains.
safety.RequireSameIdentity(current);
if(current.InGame&&!NavigationSafety.HasExternalThreat(current)&&!NavigationSafety.UnhealthyHull(current))
{
try{await beforeInput(token).ConfigureAwait(false);ready=true;}
catch(FlightTelemetryUnavailableException){/* Actual damage must stabilize before authorization. */}
catch(FlightHealthPauseException){/* The shared health guard still forbids inputs. */}
}
if(safety.Observe(current,DateTimeOffset.UtcNow,ready))break;
await Task.Delay(500,token).ConfigureAwait(false);
}
await RefreshThreatsAsync(token);
var recovered=false;var dock=player();await StateAsync("recovering-safety","威胁已结束,连续健康授权稳定,正在核对原回执与驾驶状态。",token:token);
if(dock.Docked&&dock.MarketId is >0)
{
await driver.ServiceDockedAsync(new(dock.MarketId.Value,initial.CargoCapacity),CheckAsync,token).ConfigureAwait(false);
if(recoverToSameMode is not null){await RecoverAsync(token).ConfigureAwait(false);recovered=true;}
}
await driver.PreflightAsync(token).ConfigureAwait(false);BeginMenuRecovery(token);
await StateAsync(originalPhase,dock.Docked?"安全维修与连续健康状态已确认,继续原订单。":"连续健康监测已确认损伤停止,接续原航程;下一次停靠时核对维修。",token:token);
return recovered;
}
private async Task RefreshThreatsAsync(CancellationToken token)
{
foreach(var incident in TradeSourceThreatJournal.Read(journalDirectory,initial,DateTimeOffset.UtcNow))
if(incident.MarketId!=configuration.Destination.MarketId)
{sourceThreats.TryGetValue(incident.MarketId,out var prior);sourceThreats[incident.MarketId]=TradeSourceThreat.Merge(prior,incident);}
if(sourceThreats.Count>0)await WriteAsync(Path.Combine(root,"source-threats.json"),sourceThreats.Values.ToArray(),token);
}
private async Task RecoverAsync(CancellationToken token)
{
if(recoverToSameMode is null)throw new InvalidOperationException("原始返回主菜单恢复未就绪,当前现场已保留。");
var previousPhase=phase;RevokeMenuRecovery();
await StateAsync("recovering-session","正在通过原始主菜单恢复同一游戏模式",token:token);
// Failed docking handoff revokes the old input context. Release it
// and require a newly verified healthy context before menu recovery;
// the health authorizer retains real damage and emergency blocks.
await driver.ReleaseAllAsync().ConfigureAwait(false);
await beforeInput(token).ConfigureAwait(false);
await recoverToSameMode(token).ConfigureAwait(false);var restored=player();
if(restored.CommanderId!=initial.CommanderId||restored.CurrentShipId!=initial.CurrentShipId||restored.CargoCapacity!=initial.CargoCapacity)
throw new InvalidOperationException("重进后的身份与本轮飞船不一致。");
initial=restored;await driver.PreflightAsync(token).ConfigureAwait(false);
await StateAsync(previousPhase,"原游戏会话已恢复,继续当前跑商。",token:token);
BeginMenuRecovery(token);
}
private void RevokeMenuRecovery()
{
Volatile.Write(ref menuRecoveryActive,0);Interlocked.Increment(ref menuRecoveryGeneration);
var previous=runningMenuRecovery;runningMenuRecovery=null;previous?.Dispose();
}
private void BeginMenuRecovery(CancellationToken token)
{
RevokeMenuRecovery();token.ThrowIfCancellationRequested();
var generation=Interlocked.Increment(ref menuRecoveryGeneration);Volatile.Write(ref menuRecoveryActive,1);
bool Allowed()=>Volatile.Read(ref menuRecoveryActive)==1&&Interlocked.Read(ref menuRecoveryGeneration)==generation&&!token.IsCancellationRequested
&&Volatile.Read(ref phase) is not ("recovering-session" or "requires-recovery" or "reconciling-order" or "stopped");
runningMenuRecovery=((ITradeRunMenuRecovery)driver).EnterRunningMenuRecovery(Allowed,token);
if(!Allowed())RevokeMenuRecovery();
}
private async Task TravelAsync(Station destination,CancellationToken token)
{
Planning.ShipPadPolicy.RequireFits(destination,player().Ship);
if(player().Docked&&player().MarketId==destination.MarketId){RefreshCompleted();return;}
await StateAsync("travelling","正在前往 "+destination.DisplayName,destination,token);
for(var recoveries=0;;)
{
try{await driver.ExecuteAsync(new(TradeActionKind.Travel,new(){From=player().MarketId==source.MarketId?source:configuration.Destination,To=destination},null),CheckAsync,token).ConfigureAwait(false);RefreshCompleted();return;}
catch(DockingHandoffException)when(recoveries<3&&recoverToSameMode is not null){recoveries++;await RecoverAsync(token).ConfigureAwait(false);}
catch(StationDepartureRecoveryException)when(recoveries<3&&recoverToSameMode is not null){recoveries++;await RecoverAsync(token).ConfigureAwait(false);}
catch(Exception error)when(FlightSafetyPauseException.Present(error)&&!token.IsCancellationRequested)
{
var recovered=await WaitForSafetyAsync(destination,token).ConfigureAwait(false);
if(FlightSafetyPauseException.RequiresDepartureSessionRecovery(error,player().Docked,recovered))
{
if(recoveries>=3||recoverToSameMode is null)throw;
recoveries++;await RecoverAsync(token).ConfigureAwait(false);
}
}
catch(Exception error)when(NavigationRecovery.CanRetry(error,token))
{await StateAsync("travelling","正在衔接下一段航程,自动继续",destination,token);await Task.Delay(100,token).ConfigureAwait(false);}
}
}
private async Task<IReadOnlyList<CommodityQuote>> QuotesAsync(Station station,CancellationToken token)
{
await StateAsync("reading-market","正在读取 "+station.DisplayName+" 的实时行情",station,token);
var rows=await driver.ReadDockedQuotesAsync(station.MarketId,token).ConfigureAwait(false);quotes[station.MarketId]=rows;
await WriteAsync(Path.Combine(root,"quotes-"+station.MarketId+".json"),rows,token);
if(publishQuotes is not null)await publishQuotes(station,rows,token).ConfigureAwait(false);
return rows;
}
private bool FreshQuotes(Station station)=>quotes.TryGetValue(station.MarketId,out var rows)&&rows.Count>0&&rows.All(q=>DateTimeOffset.UtcNow-q.CollectedAt<TimeSpan.FromMinutes(30));
private IReadOnlyList<ConfirmedTradeReceipt> ConfirmedSupplyTrades()
{
var receipts=new List<ConfirmedTradeReceipt>();
foreach(var file in Directory.EnumerateDirectories(root,"journey-*").Select(p=>Path.Combine(p,"journey.json")).Where(File.Exists))
{
var checkpoint=TradeJourneyCheckpoints.Load(file);
if(checkpoint.Commander!=initial.CommanderId||checkpoint.ShipId!=initial.CurrentShipId)continue;
if(checkpoint.Bought is{} bought)receipts.Add(bought);if(checkpoint.Sold is{} sold)receipts.Add(sold);
}
return receipts;
}
private async Task<bool> SelectSourceAsync(CancellationToken token)
{
if(!configuration.UsesDynamicSources||selectSource is null)return true;
if(active is not null)throw new InvalidOperationException("原采购或交付批次尚未完成,未替换其货源或订单。");
driver.RequireSourceSelectionReady();await CheckAsync(token);
await StateAsync("selecting-source","正在按供应量、实际货舱和完整循环时间重新比较全部 CG 货源",token:token);
var wallet=await driver.RefreshWalletAsync(token);var current=player();
var access=await StationAccessWaiter.ReadKnownFinesAsync(journalDirectory,current.CommanderId,token);
RequireSameSelectionContext(current);await CheckAsync(token);driver.RequireSourceSelectionReady();
var excluded=sourceThreats.Values.Where(i=>i.AvoidUntil>DateTimeOffset.UtcNow).Select(i=>i.MarketId).ToHashSet();
excluded.UnionWith(access.Restrictions.Keys.Where(market=>market!=configuration.Destination.MarketId));
await WriteAsync(Path.Combine(root,"source-access-latest.json"),new{at=DateTimeOffset.UtcNow,current.CommanderId,current.CurrentShipId,current.LoadGameAt,
journalFilesRead=access.JournalFilesRead,knownUnpaidFines=access.Restrictions.Values,excludedSourceMarkets=excluded,originalOrdersUnchanged=true},token);
var services=current.MarketId is >0?await driver.ReadServiceBudgetAsync(current.MarketId.Value,token):0;
var receipts=ConfirmedSupplyTrades();
var choice=await selectSource(new(configuration with{Source=source},current,wallet.Credits,
configuration.PurchaseReserve(wallet.Credits,current.Rebuy,services),AutomaticSupplyPlanner.DeductConfirmedTrades(quotes,receipts))
{ConfirmedTrades=receipts,ExcludedSourceMarkets=excluded},token).ConfigureAwait(false);
RequireSameSelectionContext(current);await CheckAsync(token);driver.RequireSourceSelectionReady();
if(choice is null)
{
await StateAsync("waiting-market","当前合格货源的供应、需求或资金不足,保持停靠等待新报价;未沿用已耗尽的货源。",token:token);
await Task.Delay(TimeSpan.FromSeconds(30),token).ConfigureAwait(false);return false;
}
if(excluded.Contains(choice.Source.MarketId)||choice.Source.MarketId==configuration.Destination.MarketId||choice.Delivery.To.MarketId!=configuration.Destination.MarketId
||!configuration.Commodities.Contains(choice.Order.Commodity,StringComparer.Ordinal)||choice.Order.Quantity>current.FreeCargo)
throw new InvalidDataException("新货源、CG交付站或实际空舱与规划结果不一致。");
Planning.ShipPadPolicy.RequireFits(choice.Source,current.Ship);
source=choice.Source;supplySelection=choice;estimate=new(choice.EstimatedNetProfit,choice.EstimatedMinutes,choice.Basis);
await WriteAsync(Path.Combine(root,"selected-source.json"),new SelectedSource(1,configuration.Destination.MarketId,configuration.CommunityGoalId,choice),token);
await StateAsync("source-selected",$"已选择 {source.DisplayName} · {choice.Basis} 预计 {choice.CreditsPerHour:N0} Cr/h。",source,token);
return true;
}
private void RequireSameSelectionContext(PlayerState expected)
{
var current=driver.RequireSourceSelectionReady();
if(current.CommanderId!=expected.CommanderId||current.CurrentShipId!=expected.CurrentShipId||current.LoadGameAt!=expected.LoadGameAt
||current.Docked!=expected.Docked||current.MarketId!=expected.MarketId)
throw new InvalidDataException("比较货源期间实际飞船、会话或停靠站点已改变,未采用旧访问资格。");
}
private async Task<bool> ReselectRestrictedArrivalAsync(Station arrived,CancellationToken token)
{
if(!configuration.UsesDynamicSources||selectSource is null||arrived.MarketId==configuration.Destination.MarketId)return false;
var current=driver.RequireSourceSelectionReady();await CheckAsync(token);
if(!current.Docked||current.MarketId!=arrived.MarketId)throw new InvalidDataException("尚未确认抵达预期货源,未读取或替换交易市场。");
var access=await StationAccessWaiter.ReadKnownFinesAsync(journalDirectory,current.CommanderId,token);
RequireSameSelectionContext(current);await CheckAsync(token);
if(access.FineAt(arrived.MarketId)<=0)return false;
await StateAsync("source-access-restricted",$"{arrived.DisplayName} 已确认未清罚款 {access.FineAt(arrived.MarketId):N0} Cr,尚未报价或采购,正在比较其他合格货源。",arrived,token);
await SelectSourceAsync(token);return true;
}
private NativeCommunityGoalClient GoalClient()=>new(channel,configuration.Destination.MarketId,configuration.CommunityGoalId);
private string? ManualSettlement(string directory,TradeLeg leg)
{
var checkpointPath=Path.Combine(directory,"journey.json");var resultPath=Path.Combine(directory,"result.json");
if(!File.Exists(checkpointPath)||!File.Exists(resultPath))return null;
var checkpoint=TradeJourneyCheckpoints.Load(checkpointPath);
var result=JsonSerializer.Deserialize<NativeTradeJourneyResult>(File.ReadAllText(resultPath),ToolboxSettings.JsonOptions);
if(result is null||checkpoint.Bought is not null||checkpoint.Commander!=initial.CommanderId)return null;
var matches=new List<string>();var order=leg.Orders.Single();
foreach(var marker in Directory.EnumerateFiles(root,"*.externally-settled.json",SearchOption.AllDirectories))
{
var pending=marker.Replace(".externally-settled.json",".pending.json",StringComparison.Ordinal);
if(!File.Exists(pending)||!ExternalTradeSettlement.IsSettled(pending,initial))continue;
using var proof=JsonDocument.Parse(File.ReadAllText(marker));using var original=JsonDocument.Parse(File.ReadAllText(pending));
var row=proof.RootElement;var request=original.RootElement;var intent=request.GetProperty("intent");
if(!request.TryGetProperty("created",out var at)||!at.TryGetDateTimeOffset(out var created)
||created<checkpoint.StartedAt||created>result.Finished)continue;
if(row.GetProperty("shipId").GetInt64()==checkpoint.ShipId&&row.GetProperty("commodity").GetString()==order.Commodity
&&row.GetProperty("quantity").GetInt32()==order.Quantity&&row.GetProperty("unitPurchasePrice").GetDecimal()==order.BuyPrice
&&intent.GetProperty("market").GetInt64()==leg.From.MarketId&&row.GetProperty("saleProof").GetProperty("MarketID").GetInt64()==leg.To.MarketId)
matches.Add(marker);
}
return matches.Count==1?matches[0]:null;
}
private async Task<CommunityGoalInfo> GoalAsync(CancellationToken token)
{
var client=GoalClient();var path=Path.Combine(root,"membership");
return await client.EnsureTradeJoinedAsync(path,CheckAsync,token).ConfigureAwait(false);
}
private async Task CompleteAsync(string directory,TradeLeg leg,CancellationToken token)
{
Planning.ShipPadPolicy.RequireFits(leg.From,player().Ship);Planning.ShipPadPolicy.RequireFits(leg.To,player().Ship);
estimate=supplySelection is {} selection&&selection.Source.MarketId==leg.From.MarketId&&selection.Order==leg.Orders.Single()
?new(selection.EstimatedNetProfit,selection.EstimatedMinutes,selection.Basis):AutomaticTradeEstimate.For(leg,root,initial.CurrentShipId);
active=directory;await StateAsync("cargo-journey","正在采购、运输并出售本批货物",leg.To,token);
long? historicalCredits=null;
if(HistoricalTradeCompletion.ReadCandidate(directory,leg,player()) is not null)
{
recoveringHistoricalSale=true;
try
{
await StateAsync("recovering-confirmed-sale","原批次已经实际售罄,正在核对目的站维护和活动贡献;不重发买卖订单。",leg.To,token);
await TravelAsync(leg.To,token);
await driver.ServiceDockedAsync(new(leg.To.MarketId,initial.CargoCapacity),CheckAsync,token).ConfigureAwait(false);
historicalCredits=(await driver.RefreshWalletAsync(token)).Credits;
}
finally{recoveringHistoricalSale=false;}
}
else for(var recoveries=0;;)
{
var journey=new NativeTradeJourney(driver,player,CheckAsync,directory){JournalDirectory=journalDirectory};
journey.Status+=text=>Progress?.Invoke(new(phase,text,next,completed,profit,active){Estimate=estimate,CompletedRounds=completedRounds,SupplySelection=supplySelection});
try{await journey.RunAsync(leg,token).ConfigureAwait(false);break;}
catch(NativeTradeJourneyIncompleteException error)when(error.InnerException is DockingHandoffException&&recoveries<3&&recoverToSameMode is not null){recoveries++;await RecoverAsync(token).ConfigureAwait(false);}
catch(NativeTradeJourneyIncompleteException error)when(error.InnerException is StationDepartureRecoveryException&&recoveries<3&&recoverToSameMode is not null){recoveries++;await RecoverAsync(token).ConfigureAwait(false);}
catch(NativeTradeJourneyIncompleteException error)when(FlightSafetyPauseException.Present(error)&&!token.IsCancellationRequested)
{
var recovered=await WaitForSafetyAsync(leg.To,token).ConfigureAwait(false);
if(FlightSafetyPauseException.RequiresDepartureSessionRecovery(error,player().Docked,recovered))
{
if(recoveries>=3||recoverToSameMode is null)throw;
recoveries++;await RecoverAsync(token).ConfigureAwait(false);
}
}
catch(NativeTradeJourneyIncompleteException error)when(error.InnerException is {} cause&&NavigationRecovery.CanRetry(cause,token))
{await StateAsync("cargo-journey","正在衔接下一段航程,保留原货物继续",leg.To,token);await Task.Delay(100,token).ConfigureAwait(false);}
}
var membership=Path.Combine(directory,"membership-before.json");
if(File.Exists(membership))
{
using var before=JsonDocument.Parse(await File.ReadAllTextAsync(membership,token));var row=before.RootElement;
var id=row.GetProperty("goalId").GetUInt64();var baseline=row.GetProperty("contribution").GetInt64();var until=DateTimeOffset.UtcNow.AddSeconds(35);
while(true)
{
var read=await GoalClient().ReadAsync(true,token).ConfigureAwait(false);
var goal=CommunityGoalCatalogue.RequireDestinationTrade(read.Goals,leg.To.MarketId,id,DateTimeOffset.UtcNow,true);
if(goal.Contribution>=baseline+leg.Quantity){await WriteAsync(Path.Combine(directory,"cg-contribution-confirmed.json"),read,token);break;}
if(DateTimeOffset.UtcNow>=until)throw new InvalidDataException("实际CG贡献尚未确认本批交货;销售回执已保留。");
await Task.Delay(250,token).ConfigureAwait(false);
}
}
if(historicalCredits is {} historicalWallet)await HistoricalTradeCompletion.SealAsync(directory,leg,player(),historicalWallet,token);
await WriteAsync(Path.Combine(directory,"settled.json"),new{at=DateTimeOffset.UtcNow,passed=true,historicalSaleRecovered=historicalCredits is not null},token);
active=null;RefreshCompleted();await StateAsync("cycle-completed","本批已完成,继续下一轮",token:token);
}
public async Task RunAsync(CancellationToken token)
{
configuration.Validate();Directory.CreateDirectory(root);
using var lease=new FileStream(Path.Combine(root,"workflow.lock"),FileMode.OpenOrCreate,FileAccess.ReadWrite,FileShare.None);
using var expenses=new TradeProfitLedger(Path.Combine(root,"route-ledger"));routeLedger=expenses;
void RecordOutsideJourney(ConfirmedDockedServiceReceipt receipt)
{
if(active is not null&&!recoveringHistoricalSale)return;
var entry=receipt.ToCashEntry();
// The durable operation key is the accounting authority. Publish
// only its new debit, before any later travel or service failure
// can write a state snapshot. Active batches keep their own fees;
// refreshing their completion here could count them twice or early.
if(expenses.Record(entry))profit-=entry.Amount;
}
driver.ServiceConfirmed+=RecordOutsideJourney;
initial=player();
try
{
var selectedFile=Path.Combine(root,"selected-source.json");
if(configuration.UsesDynamicSources&&selectSource is not null&&File.Exists(selectedFile))
{
var saved=JsonSerializer.Deserialize<SelectedSource>(await File.ReadAllTextAsync(selectedFile,token),ToolboxSettings.JsonOptions);
if(saved is null||saved.Schema!=1||saved.Destination!=configuration.Destination.MarketId||saved.Goal!=configuration.CommunityGoalId
||saved.Choice.Source.MarketId==configuration.Destination.MarketId)throw new InvalidDataException("已保存的CG货源与本次交付活动不一致。");
source=saved.Choice.Source;
}
var threatsPath=Path.Combine(root,"source-threats.json");
if(File.Exists(threatsPath))foreach(var incident in JsonSerializer.Deserialize<TradeSourceThreat[]>(await File.ReadAllTextAsync(threatsPath,token),ToolboxSettings.JsonOptions)??[])
sourceThreats[incident.MarketId]=incident;
await RefreshThreatsAsync(token);
RefreshCompleted();await StateAsync("preflight","正在核对当前飞船和交易接口",token:token);
if(!initial.InGame||initial.CurrentShipId is null||initial.CargoCapacity<=0||initial.Rebuy<=0||!initial.CargoReliable)
throw new InvalidOperationException("当前飞船、实际货舱、重购费或货物观测尚未就绪。");
Planning.ShipPadPolicy.RequireFits(source,initial.Ship);Planning.ShipPadPolicy.RequireFits(configuration.Destination,initial.Ship);
var inventory=await channel.CallAsync(new{op="commerce.inventory"},token).ConfigureAwait(false);
await NativeUnsubmittedTradeIntents.ReconcileAsync(Path.Combine(root,"operations"),channel,initial,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);
BeginMenuRecovery(token);
if(initial.Docked&&initial.MarketId is{} resumedThreatMarket&&sourceThreats.TryGetValue(resumedThreatMarket,out var dockIncident)&&dockIncident.AvoidUntil>DateTimeOffset.UtcNow
||!initial.Docked&&TradeSourceThreatJournal.Read(journalDirectory,initial,DateTimeOffset.UtcNow).Count>0)
await WaitForSafetyAsync(source,token).ConfigureAwait(false);
if(player().Docked&&player().MarketId is {} currentMarket&¤tMarket!=source.MarketId&¤tMarket!=configuration.Destination.MarketId)
await driver.ServiceDockedAsync(new(currentMarket,initial.CargoCapacity),CheckAsync,token).ConfigureAwait(false);
await WriteAsync(Path.Combine(root,"current-ship.json"),new{at=DateTimeOffset.UtcNow,initial.CommanderId,initial.CurrentShipId,initial.Ship,initial.CargoCapacity,
initial.Rebuy,reserve=Math.Max(configuration.ReserveCredits,initial.Rebuy)},token);
var pending=Directory.EnumerateDirectories(root,"journey-*").Where(p=>File.Exists(Path.Combine(p,"leg.json"))&&!File.Exists(Path.Combine(p,"settled.json"))
&&!File.Exists(Path.Combine(p,"cg-contribution-confirmed.json"))).ToArray();
foreach(var path in pending)
{
using var document=JsonDocument.Parse(await File.ReadAllTextAsync(Path.Combine(path,"leg.json"),token));
var leg=JsonSerializer.Deserialize<TradeLeg>(document.RootElement.GetRawText(),ToolboxSettings.JsonOptions)!;
var manual=ManualSettlement(path,leg);
if(manual is not null){await WriteAsync(Path.Combine(path,"external-completion.json"),new{at=DateTimeOffset.UtcNow,manual=true,proof=manual,nativeJourneyPassed=false},token);continue;}
if(File.Exists(Path.Combine(path,"external-completion.json")))continue;
await CompleteAsync(path,leg,token).ConfigureAwait(false);
}
if(player().CargoCount!=0)throw new InvalidOperationException("当前已有货物,但没有唯一可恢复的订单,未新增采购。");
// Recovered launch failures can leave the same ship docked and damaged.
// Resolve all prior orders first, then finish station services before another departure.
if(player().Docked&&player().MarketId is {} resumedMarket)
await driver.ServiceDockedAsync(new(resumedMarket,initial.CargoCapacity),CheckAsync,token).ConfigureAwait(false);
foreach(var file in Directory.EnumerateFiles(root,"quotes-*.json"))
{
var rows=JsonSerializer.Deserialize<CommodityQuote[]>(await File.ReadAllTextAsync(file,token),ToolboxSettings.JsonOptions)??[];
if(rows.Length>0&&rows.Select(q=>q.MarketId).Distinct().Count()==1)quotes[rows[0].MarketId]=rows;
}
if(configuration.Mode==AutomaticTradeMode.CommunityGoal)
{await TravelAsync(configuration.Destination,token);await GoalAsync(token);await QuotesAsync(configuration.Destination,token);await driver.ServiceDockedAsync(new(configuration.Destination.MarketId,initial.CargoCapacity),CheckAsync,token);}
var cycles=0;
while(!token.IsCancellationRequested)
{
try{await CheckAsync(token);}
catch(FlightSafetyPauseException){await WaitForSafetyAsync(player().MarketId==configuration.Destination.MarketId?configuration.Destination:source,token);continue;}
var atDestination=player().Docked&&player().MarketId==configuration.Destination.MarketId;
// Complete any configured return trade to this batch's home
// before comparing suppliers for a new outbound batch.
if(atDestination&&!configuration.ReturnTrade&&!await SelectSourceAsync(token))continue;
var from=atDestination?configuration.Destination:source;var to=atDestination?source:configuration.Destination;
if(atDestination&&!configuration.ReturnTrade){await TravelAsync(source,token);continue;}
if(!FreshQuotes(to))
{await TravelAsync(to,token);await QuotesAsync(to,token);await driver.ServiceDockedAsync(new(to.MarketId,initial.CargoCapacity),CheckAsync,token);continue;}
await TravelAsync(from,token);
if(await ReselectRestrictedArrivalAsync(from,token))continue;
var live=await QuotesAsync(from,token);
if(from.MarketId!=configuration.Destination.MarketId&&configuration.UsesDynamicSources&&selectSource is not null)
{
// Replace the cached source stock with the actual docked
// market before choosing an irreversible purchase.
if(!await SelectSourceAsync(token))continue;
if(source.MarketId!=from.MarketId){await TravelAsync(source,token);continue;}
}
var wallet=await driver.RefreshWalletAsync(token);
var serviceBudget=await driver.ReadServiceBudgetAsync(from.MarketId,token);
var reserve=configuration.PurchaseReserve(wallet.Credits,player().Rebuy,serviceBudget);
var filter=configuration.Mode==AutomaticTradeMode.CommunityGoal&&to.MarketId==configuration.Destination.MarketId?configuration.Commodities:null;
if(configuration.Mode==AutomaticTradeMode.CommunityGoal&&to.MarketId==configuration.Destination.MarketId&&filter is not {Length:>0})
throw new InvalidOperationException("请为此贸易CG指定接受的商品;不会猜测CG范围或倍增已提高的报价。");
TradeOrder order;
try{order=AutomaticTradePlanner.Select(live,quotes[to.MarketId],player().FreeCargo,wallet.Credits,reserve,filter);}
catch(InvalidOperationException)when(from.MarketId==configuration.Destination.MarketId)
{await driver.ServiceDockedAsync(new(from.MarketId,initial.CargoCapacity),CheckAsync,token);await TravelAsync(source,token);continue;}
catch(InvalidOperationException)
{
await driver.ServiceDockedAsync(new(from.MarketId,initial.CargoCapacity),CheckAsync,token);
await StateAsync("waiting-market","当前没有满足资金、库存和利润条件的采购,保持停靠并等待行情刷新。",from,token);
await Task.Delay(TimeSpan.FromSeconds(30),token).ConfigureAwait(false);continue;
}
// Service and wallet reads can take time after the comparison.
// Recheck known access before creating any procurement batch.
if(await ReselectRestrictedArrivalAsync(from,token))continue;
long? baseline=null;ulong? goalId=null;
if(configuration.Mode==AutomaticTradeMode.CommunityGoal&&to.MarketId==configuration.Destination.MarketId)
{
// Membership was confirmed on the previous destination visit.
var file=Path.Combine(root,"membership","membership-latest.json");
var membership=JsonSerializer.Deserialize<OriginalCommunityGoalRead>(await File.ReadAllTextAsync(file,token),ToolboxSettings.JsonOptions)!;
var goal=CommunityGoalCatalogue.RequireDestinationTrade(membership.Goals,to.MarketId,configuration.CommunityGoalId,DateTimeOffset.UtcNow,true);
baseline=goal.Contribution??0;goalId=goal.Id;
}
var leg=supplySelection is {} chosen&&chosen.Source.MarketId==from.MarketId&&chosen.Order==order?chosen.Delivery
:new TradeLeg{Number=1,From=from,To=to,Orders=[order]};
var directory=Path.Combine(root,"journey-"+Guid.NewGuid().ToString("N"));Directory.CreateDirectory(directory);
await WriteAsync(Path.Combine(directory,"leg.json"),leg,token);
if(goalId is not null)await WriteAsync(Path.Combine(directory,"membership-before.json"),new{goalId,contribution=baseline,at=DateTimeOffset.UtcNow},token);
await CompleteAsync(directory,leg,token);await QuotesAsync(to,token);
if(to.MarketId==configuration.Destination.MarketId&&configuration.Mode==AutomaticTradeMode.CommunityGoal)await GoalAsync(token);
if(MaximumCycles>0&&++cycles>=MaximumCycles)return;
}
token.ThrowIfCancellationRequested();
}
catch(OperationCanceledException)when(token.IsCancellationRequested){RevokeMenuRecovery();AutomaticTradeRunSession.Finish(root);await StateAsync("stopped","自动跑商已取消,原始订单和恢复记录已保留。");}
catch(Exception error)
{
RevokeMenuRecovery();
try{await RefreshThreatsAsync(CancellationToken.None);}catch(Exception recordError)when(recordError is IOException or UnauthorizedAccessException){error.Data["ThreatRecordFailure"]=recordError.Message;}
await WriteAsync(Path.Combine(root,"failure-"+DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()+".json"),new{at=DateTimeOffset.UtcNow,phase,error=error.ToString()},CancellationToken.None);
await StateAsync(NativeOrderReconciliationRequiredException.Present(error)?"reconciling-order":"requires-recovery",error.Message);throw;
}
finally{RevokeMenuRecovery();driver.ServiceConfirmed-=RecordOutsideJourney;try{await driver.ReleaseAllAsync().ConfigureAwait(false);}finally{routeLedger=null;}}
}
}