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

XFEEDToolBox

【WinUI】XFE精英危险工具箱

公开
关注 0 Fork 0 Star 0
UTF-8
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&&currentMarket!=source.MarketId&&currentMarket!=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;}}
    }
}