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

XFEEDToolBox

【WinUI】XFE精英危险工具箱

公开
关注 0 Fork 0 Star 0
UTF-8
using System.Threading.Channels;
using XFEEDToolBox.Core.Data;
using XFEEDToolBox.Core.Models;
using XFEEDToolBox.Core.Navigation;

namespace XFEEDToolBox.Core.Execution;

public enum RunState { Idle, Running, Paused, Completed, Stopped, Failed }
public enum TradeActionKind { Buy, Travel, Sell }
public sealed record TradeAction(TradeActionKind Kind, TradeLeg Leg, TradeOrder? Order);
public sealed record DockedServiceRequest(long MarketId,int ReservedCargo);
public sealed record RunProgress(RunState State, string Message, int Leg, int TotalLegs, decimal ActualProfit, DateTimeOffset At)
{
    public TradeProfitSnapshot Accounting { get; init; } = TradeProfitSnapshot.Empty;
    public bool IsSimulation { get; init; }
}

public interface ITradeCommandDriver
{
    Task PreflightAsync(CancellationToken token);
    // Confirm refuel/repair/rearm against the current station's available
    // services. Cargo consumables must leave ReservedCargo tonnes free.
    Task ServiceDockedAsync(DockedServiceRequest request,Func<CancellationToken,Task> beforeInput,CancellationToken token);
    Task ExecuteAsync(TradeAction action, Func<CancellationToken, Task> beforeInput, CancellationToken token);
    Task ReleaseAllAsync();
}

/// <summary>Only true while a native flight controller is applying its own fresh thermal and threat interlocks.</summary>
public interface ITradeFlightSafetyScope
{
    bool FlightSafetyActive { get; }
}

public sealed class TradeRunner
{
    private readonly Func<PlayerState> player;
    private readonly Func<long, string, CommodityQuote?> quote;
    private readonly Channel<ObservedJournalEvent> events = Channel.CreateUnbounded<ObservedJournalEvent>(new() { SingleReader = true });
    private readonly object sync = new();
    private CancellationTokenSource? runStop;
    private TaskCompletionSource resume = CompletedGate();
    private RunState state;
    private int currentLeg, totalLegs;
    private decimal actualProfit;
    private TradeProfitLedger? ledger;
    private readonly string? accountingDirectory;
    private string journalRun = "";
    private bool journalServices;
    private Exception? accountingError;
    private bool active;
    private ExecutionMode activeMode;
    private PlayerState? preservedCargo;
    private ITradeCommandDriver? activeDriver;
    private IDisposable? menuRecovery;
    private bool menuRecoveryStarting;
    private bool menuRecoveryStopped;
    private long menuRecoveryGeneration;
    public RunState State { get { lock (sync) return state; } }
    public event Action<RunProgress>? Progress;

    public TradeRunner(Func<PlayerState> player, Func<long, string, CommodityQuote?> quote, string? accountingDirectory = null)
    { this.player = player; this.quote = quote; this.accountingDirectory = accountingDirectory; }
    public void Observe(ObservedJournalEvent e)
    {
        if (!Volatile.Read(ref active)) return;
        if (journalServices && ledger is not null)
        {
            var kind = e.Data.Text("event") switch
            {
                "RefuelAll" or "RefuelPartial" => TradeCashKind.Fuel,
                "Repair" or "RepairAll" => TradeCashKind.Repair,
                "BuyAmmo" => TradeCashKind.Rearm,
                "BuyDrones" => TradeCashKind.OtherExpense,
                _ => (TradeCashKind?)null
            };
            if (kind is not null)
            {
                try
                {
                    var amount = e.Data.Number("Cost");
                    if (amount is null || !double.IsFinite(amount.Value) || amount < 0) throw new InvalidDataException("维护日志缺少实际扣款金额。");
                    if (amount > 0 && ledger.Record(new($"journal:{journalRun}:{e.Sequence}",kind.Value,(decimal)amount.Value,
                        e.Data.Long("MarketID",player().MarketId??0),e.ObservedAt))) Report(State,"已计入停靠服务费用。");
                }
                catch (Exception error) when (error is IOException or InvalidDataException or OverflowException)
                { Interlocked.CompareExchange(ref accountingError,error,null); Stop(); }
            }
        }
        events.Writer.TryWrite(e);
    }
    public void ObservePlayer(PlayerState p)
    {
        if (activeMode == ExecutionMode.Simulation) return;
        if(NavigationSafety.UnhealthyHull(p)){Stop();return;}
        if (State == RunState.Running && MustPauseForDanger(p)) Pause("检测到战斗、拦截或未接管的危险状态,执行已暂停。");
        if (State is RunState.Running or RunState.Paused && !p.InGame) Stop();
    }

    private static TaskCompletionSource CompletedGate() { var t = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); t.SetResult(); return t; }
    private void Report(RunState next, string message)
    {
        // A delayed status/receipt must not undo a user's Pause. Resume is
        // the only path that deliberately changes Paused back to Running.
        lock (sync){if(next==RunState.Running&&state==RunState.Paused)next=RunState.Paused;state=next;}
        if(next==RunState.Running)EnsureMenuRecovery();else RevokeMenuRecovery();
        lock(sync)next=state;
        var accounting = ledger?.Snapshot ?? TradeProfitSnapshot.Empty;
        Progress?.Invoke(new(next, message, currentLeg, totalLegs, activeMode == ExecutionMode.Simulation ? actualProfit : accounting.NetProfit, DateTimeOffset.UtcNow)
        { Accounting = accounting, IsSimulation = activeMode == ExecutionMode.Simulation });
    }
    public void Pause(string reason = "用户暂停")
    {
        lock (sync)
        {
            if (state != RunState.Running) return;
            resume = new(TaskCreationOptions.RunContinuationsAsynchronously);
            state = RunState.Paused;
        }
        RevokeMenuRecovery();
        Report(RunState.Paused, reason);
    }
    public void Resume()
    {
        lock (sync)
        {
            if (state != RunState.Paused) return;
            if (MustPauseForDanger(player())) throw new InvalidOperationException("危险状态尚未解除。");
            state = RunState.Running;
            resume.TrySetResult();
        }
        Report(RunState.Running, "继续执行;下一次操作前重新检查。");
    }
    public void Stop()
    {
        lock(sync)menuRecoveryStopped=true;
        RevokeMenuRecovery();
        lock (sync) { runStop?.Cancel(); resume.TrySetResult(); }
    }
    private bool MenuRecoveryAllowed(long generation)
    {
        lock(sync)return generation==menuRecoveryGeneration&&!menuRecoveryStopped&&active&&state==RunState.Running
            &&activeMode==ExecutionMode.NativeBridge&&runStop is {IsCancellationRequested:false};
    }
    private void RevokeMenuRecovery()
    {
        IDisposable? previous;
        lock(sync){menuRecoveryGeneration++;menuRecoveryStarting=false;previous=menuRecovery;menuRecovery=null;}
        previous?.Dispose();
    }
    private void EnsureMenuRecovery()
    {
        ITradeRunMenuRecovery provider;CancellationToken token;long generation;
        lock(sync)
        {
            if(!active||menuRecoveryStopped||state!=RunState.Running||activeMode!=ExecutionMode.NativeBridge||runStop is not {IsCancellationRequested:false}
                ||activeDriver is not ITradeRunMenuRecovery available||menuRecovery is not null||menuRecoveryStarting)return;
            provider=available;token=runStop.Token;menuRecoveryStarting=true;generation=++menuRecoveryGeneration;
        }
        IDisposable created;
        try{created=provider.EnterRunningMenuRecovery(()=>MenuRecoveryAllowed(generation),token);}
        catch{Stop();throw;}
        var keep=false;
        lock(sync)
        {
            if(MenuRecoveryAllowed(generation)&&menuRecoveryStarting)
            {menuRecovery=created;menuRecoveryStarting=false;keep=true;}
        }
        if(!keep)created.Dispose();
    }
    private async Task CheckpointAsync(CancellationToken token)
    {
        Task wait;
        lock (sync) wait = resume.Task;
        await wait.WaitAsync(token);
        token.ThrowIfCancellationRequested();
        ValidatePreservedCargo();
        if (activeMode != ExecutionMode.Simulation && MustPauseForDanger(player())) { Pause("危险状态触发暂停。"); await CheckpointAsync(token); }
    }

    private bool MustPauseForDanger(PlayerState value)=>value.InDanger
        && !(activeMode==ExecutionMode.NativeBridge&&activeDriver is ITradeFlightSafetyScope {FlightSafetyActive:true}
            &&!NavigationSafety.HasExternalThreat(value)&&!NavigationSafety.UnhealthyHull(value));

    private void ValidatePreservedCargo()
    {
        if(preservedCargo is not {} baseline)return;
        var current=player();
        if(!current.CargoReliable||current.CommanderId!=baseline.CommanderId||current.CurrentShipId!=baseline.CurrentShipId
            ||current.LoadGameAt!=baseline.LoadGameAt||current.CargoCapacity!=baseline.CargoCapacity||current.Inventory.Any(item=>item.Count<0)
            ||current.Inventory.Sum(item=>item.Count)!=current.CargoCount)
            throw new InvalidOperationException("现有货物、飞船或会话已改变或无法核实,请重新计算路线。");
        foreach(var item in baseline.Inventory.Where(item=>item.Count>0).GroupBy(item=>(item.Key,item.Stolen,item.MissionId)))
            if(current.Inventory.Where(value=>value.Key==item.Key.Key&&value.Stolen==item.Key.Stolen&&value.MissionId==item.Key.MissionId).Sum(value=>value.Count)<item.Sum(value=>value.Count))
                throw new InvalidOperationException("原有货物已改变;不会将原有、任务或赃物库存计入本次交易。");
    }

    public async Task RunAsync(RoutePlan plan, ExecutionMode mode, ITradeCommandDriver? driver = null, CancellationToken token = default,TradeAcceptancePolicy? acceptance=null,TradeProfitLedger? accounting=null)
    {
        lock (sync)
        {
            if (active) throw new InvalidOperationException("已有执行任务,请先停止。");
            active = true;
            activeMode = mode;
            activeDriver = driver;
            menuRecoveryStopped=false;
            runStop = CancellationTokenSource.CreateLinkedTokenSource(token);
            resume = CompletedGate();
        }
        using var cancellation = runStop;
        var ct = cancellation.Token;
        Task? dockedServices=null;
        var dockedOperations=new List<Task>();
        var serviceFailure=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var confirmedOrders=new HashSet<string>(StringComparer.Ordinal);
        var serviceDriver=driver as IConfirmedDockedServiceDriver;
        void OnServiceConfirmed(ConfirmedDockedServiceReceipt receipt)
        {
            if(ledger!.Record(receipt.ToCashEntry()))Report(State,"已计入停靠服务费用。");
        }
        async Task ObserveServices(Task task)
        {
            try{await task;}catch(Exception error){serviceFailure.TrySetException(error);}
        }
        async Task AlongsideServices(Task operation)
        {
            dockedOperations.Add(operation);
            if(await Task.WhenAny(operation,serviceFailure.Task)==serviceFailure.Task)await serviceFailure.Task;
            await operation;
        }
        void StartServices(long marketId)
        {
            if(mode!=ExecutionMode.NativeBridge||dockedServices is not null)return;
            dockedServices=driver!.ServiceDockedAsync(new(marketId,plan.Request.UsableCargoCapacity),CheckpointAsync,ct);
            _=ObserveServices(dockedServices);
        }
        async Task ExecuteTrade(TradeAction action)
        {
            async Task BeforeInput(CancellationToken t){await CheckpointAsync(t);acceptance?.ValidateAction(action,player());}
            if(mode==ExecutionMode.NativeBridge&&driver is IConfirmedTradeCommandDriver original)
            {
                var requestedAt=DateTimeOffset.UtcNow;
                var pending=original.ExecuteTradeAsync(action,BeforeInput,ct);
                await AlongsideServices(pending);
                var receipt=await pending;
                var change=receipt.Validate(action,player(),requestedAt);
                if(!confirmedOrders.Add(receipt.OperationKey))throw new InvalidOperationException("检测到重复交易回执,已停止后续操作。");
                _=change;
                ledger!.RecordTrade(receipt);
                Report(State,action.Kind==TradeActionKind.Buy?"买入已确认;货物成本计入在途库存。":"卖出已确认,净利润已更新。");
                return;
            }
            if(mode==ExecutionMode.NativeBridge)await AlongsideServices(driver!.ExecuteAsync(action,BeforeInput,ct));
            await AlongsideServices(WaitTradeAsync(action.Kind==TradeActionKind.Buy?"MarketBuy":"MarketSell",
                action.Kind==TradeActionKind.Buy?action.Leg.From.MarketId:action.Leg.To.MarketId,action.Order!,ct));
            ValidatePreservedCargo();
        }
        actualProfit = 0; currentLeg = 0; totalLegs = plan.Legs.Count;
        while (events.Reader.TryRead(out _)) { }
        try
        {
            accountingError=null;journalRun=Guid.NewGuid().ToString("N");journalServices=false;ledger=null;preservedCargo=null;
            if(mode!=ExecutionMode.Simulation)
            {
                ledger=accounting??new TradeProfitLedger(accountingDirectory is null?null:Path.Combine(accountingDirectory,journalRun));
                journalServices=mode==ExecutionMode.JournalGuide||serviceDriver is null;
                if(mode==ExecutionMode.NativeBridge&&serviceDriver is not null)serviceDriver.ServiceConfirmed+=OnServiceConfirmed;
            }
            ValidatePlan(plan);
            if(acceptance is not null&&mode!=ExecutionMode.NativeBridge)throw new InvalidOperationException("跑商验收必须使用真实交易驱动,模拟或日志领航不能算通过。");
            acceptance?.ValidatePlan(plan,player());
            if (mode != ExecutionMode.Simulation)
            {
                var p = player();
                if(NavigationSafety.UnhealthyHull(p))throw new InvalidOperationException("船体已失效,无法开始跑商。");
                if (!p.InGame || !p.Docked || p.MarketId != plan.Legs[0].From.MarketId) throw new InvalidOperationException("请在计划的起始站点停靠后启动。");
                if(p.CargoCount!=plan.Request.OccupiedCargo)throw new InvalidOperationException("当前已有货物数量与计划不一致,请重新计算。");
                if (p.FreeCargo < plan.Request.UsableCargoCapacity) throw new InvalidOperationException("当前可用货舱少于计划值,请重新计算。");
                if(plan.Request.OccupiedCargo>0)
                {
                    if(!p.CargoReliable||p.CargoCapacity!=plan.Request.CargoCapacity||string.IsNullOrWhiteSpace(p.CommanderId)||p.CurrentShipId is null||p.LoadGameAt is null
                        ||p.Inventory.Any(item=>item.Count<0)||p.Inventory.Sum(item=>item.Count)!=p.CargoCount)
                        throw new InvalidOperationException("原有货物清单或飞船身份尚未完整核实,不能执行带货计划。");
                    if(mode==ExecutionMode.NativeBridge)throw new InvalidOperationException("当前原生交易仍要求空货舱;带货计划可用于日志领航,请先处理原有货物再启动自动执行。");
                    preservedCargo=p;
                }
                if (!p.CreditsReliable || p.Credits is null || p.Credits < plan.Request.Credits) throw new InvalidOperationException("现金不足或 Journal 余额不完整,请重新登录游戏刷新并计算。");
                foreach(var destination in plan.Legs.SelectMany(leg=>new[]{leg.From,leg.To}))
                    Planning.ShipPadPolicy.RequireFits(destination,p.Ship,plan.Request.RequiredPad);
                if (mode == ExecutionMode.NativeBridge)
                {
                    if (driver == null) throw new InvalidOperationException("未连接已验证的原生命令适配器。");
                    await driver.PreflightAsync(ct);
                }
            }
            Report(RunState.Running, mode == ExecutionMode.Simulation ? "开始模拟演练 · 不发送游戏输入" : "开始执行 · 每一步均核对实际游戏结果");
            foreach (var leg in plan.Legs)
            {
                currentLeg = leg.Number;
                if (mode == ExecutionMode.Simulation)
                {
                    await CheckpointAsync(ct);
                    Report(RunState.Running, $"模拟:{leg.CargoSummary} · {leg.From.Name} → {leg.To.Name}");
                    await Task.Delay(800, ct);
                    actualProfit += leg.NetProfit;
                    continue;
                }
                await CheckpointAsync(ct);
                Planning.ShipPadPolicy.RequireFits(leg.From,player().Ship,plan.Request.RequiredPad);Planning.ShipPadPolicy.RequireFits(leg.To,player().Ship,plan.Request.RequiredPad);
                ValidateMarket(plan, leg);
                StartServices(leg.From.MarketId);
                for (var orderIndex = 0; orderIndex < leg.Orders.Count; orderIndex++)
                {
                    var order = leg.Orders[orderIndex];
                    await CheckpointAsync(ct);
                    ValidateMarket(plan, leg, leg.Orders.Skip(orderIndex).ToArray());
                    Report(RunState.Running, $"买入 {order.Summary},等待实际交易确认");
                    var action = new TradeAction(TradeActionKind.Buy, leg, order);
                    acceptance?.ValidateAction(action,player());
                    await ExecuteTrade(action);
                }
                // The two docked work streams run concurrently. Only departure
                // joins them; never allow a pending service to outlive this stop.
                if(dockedServices is not null){await dockedServices;dockedServices=null;}
                await CheckpointAsync(ct);
                Report(RunState.Running, $"前往 {leg.To.DisplayName},等待 Docked 确认");
                var travel=new TradeAction(TradeActionKind.Travel,leg,null);
                acceptance?.ValidateAction(travel,player());
                if (mode == ExecutionMode.NativeBridge) await driver!.ExecuteAsync(travel,async t=>{await CheckpointAsync(t);acceptance?.ValidateAction(travel,player());},ct);
                await WaitDockedAsync(leg.To.MarketId, ct);
                StartServices(leg.To.MarketId);
                foreach (var order in leg.Orders)
                {
                    await CheckpointAsync(ct);
                    ValidateSale(plan, leg, order);
                    Report(RunState.Running, $"卖出 {order.Summary},等待实际交易确认");
                    var sale=new TradeAction(TradeActionKind.Sell,leg,order);
                    acceptance?.ValidateAction(sale,player());
                    await ExecuteTrade(sale);
                }
                // Each receipt updates realized profit and the remaining cargo cost.
            }
            if(dockedServices is not null)await dockedServices;
            ValidatePreservedCargo();
            Report(RunState.Completed, mode == ExecutionMode.Simulation ? "模拟完成;收益为规划估计值。" : "路线完成;净利润已扣除实际买入成本、燃油、维修与补给费用。");
        }
        catch (OperationCanceledException) when (ct.IsCancellationRequested) { Report(accountingError is null?RunState.Stopped:RunState.Failed, accountingError?.Message??"执行已停止。"); }
        catch (Exception e) { Report(RunState.Failed, e.Message); }
        finally
        {
            RevokeMenuRecovery();
            cancellation.Cancel();
            if(dockedServices is not null)try{await dockedServices;}catch(Exception){/* The run result already reports the original failure. */}
            try{await Task.WhenAll(dockedOperations);}catch(Exception){/* Drain cancelled sibling operations before releasing the driver. */}
            if(serviceFailure.Task.IsFaulted)_=serviceFailure.Task.Exception;
            journalServices=false;
            if(serviceDriver is not null)serviceDriver.ServiceConfirmed-=OnServiceConfirmed;
            if (driver != null) await driver.ReleaseAllAsync();
            lock (sync) { active = false; activeDriver = null; runStop = null; }
            if(accounting is null)ledger?.Dispose();
            while (events.Reader.TryRead(out _)) { }
        }
    }

    private static void ValidatePlan(RoutePlan plan)
    {
        plan.Request.Validate();
        if (plan.Legs.Count == 0) throw new ArgumentException("计划没有可执行航段。");
        if (plan.Legs[0].From.MarketId != plan.Request.StartMarketId) throw new ArgumentException("计划起点不一致。");
        decimal cash = plan.Request.Credits;
        for (var i = 0; i < plan.Legs.Count; i++)
        {
            var leg = plan.Legs[i];
            if (leg.Number != i + 1 || i > 0 && plan.Legs[i - 1].To.MarketId != leg.From.MarketId || leg.From.MarketId == leg.To.MarketId) throw new ArgumentException("航段次序或站点不连续。");
            if (leg.Orders.Any(o => o.Quantity <= 0 || o.BuyPrice <= 0 || o.SellPrice <= o.BuyPrice) || leg.Quantity > plan.Request.UsableCargoCapacity || leg.Orders.Select(o => o.Commodity).Distinct().Count() != leg.Orders.Count) throw new ArgumentException("装载清单无效。");
            if (cash - leg.Orders.Sum(o => o.Cost) - leg.OperatingCost < plan.Request.ReserveCredits) throw new ArgumentException("计划会动用保留资金。");
            cash += leg.NetProfit;
        }
    }

    private void ValidateMarket(RoutePlan plan, TradeLeg leg, IReadOnlyList<TradeOrder>? pending = null)
    {
        pending ??= leg.Orders;
        var p = player();
        if (!p.InGame || !p.Docked || p.MarketId != leg.From.MarketId) throw new InvalidOperationException("当前停靠站点与买入站点不一致,执行已停止。");
        foreach (var o in pending)
        {
            var from = quote(leg.From.MarketId, o.Commodity); var to = quote(leg.To.MarketId, o.Commodity);
            CheckQuote(from, plan); CheckQuote(to, plan);
            if (from!.BuyPrice > o.BuyPrice || to!.SellPrice < o.SellPrice || from.Supply < o.Quantity || to.Demand < o.Quantity) throw new InvalidOperationException("价格或供需已变化,请重新计算路线。");
        }
        if (p.Credits is null || !p.CreditsReliable || p.Credits - pending.Sum(x => x.Cost) < plan.Request.ReserveCredits) throw new InvalidOperationException("余额变化或无法核实,请重新计算。");
        if (p.FreeCargo < pending.Sum(x => x.Quantity)) throw new InvalidOperationException("剩余可用货舱不足,请重新计算。");
    }
    private static void CheckQuote(CommodityQuote? q, RoutePlan plan)
    {
        if (q == null || q.Illegal || q.Source is DataSource.Demonstration or DataSource.Capture || q.CollectedAt < DateTimeOffset.UtcNow.AddMinutes(-plan.Request.MaxMarketAgeMinutes) || q.CollectedAt > DateTimeOffset.UtcNow.AddMinutes(5)) throw new InvalidOperationException("缺少可用的新鲜行情;演示和历史抓包不能用于真实执行。");
    }
    private void ValidateSale(RoutePlan plan, TradeLeg leg, TradeOrder o)
    {
        var p = player();
        if (!p.Docked || p.MarketId != leg.To.MarketId) throw new InvalidOperationException("当前站点不是计划卖出站点。");
        var q = quote(leg.To.MarketId, o.Commodity); CheckQuote(q, plan);
        if (q!.SellPrice < o.SellPrice || q.Demand < o.Quantity) throw new InvalidOperationException("到站卖价或需求下降,请停止后重新规划。");
        var original=preservedCargo?.Inventory.Where(x=>x.Key==o.Commodity&&x.Stolen==0&&x.MissionId is null).Sum(x=>x.Count)??0;
        if (p.Inventory.Where(x => x.Key == o.Commodity && x.Stolen == 0 && x.MissionId == null).Sum(x => x.Count) < o.Quantity+original) throw new InvalidOperationException("本次新增的可交易货物不足;原有、任务货物和赃物不计入。");
    }

    private async Task<decimal> WaitTradeAsync(string expected, long marketId, TradeOrder order, CancellationToken token)
    {
        using var timeout = CancellationTokenSource.CreateLinkedTokenSource(token);
        timeout.CancelAfter(TimeSpan.FromMinutes(10));
        var remaining = order.Quantity; decimal total = 0;
        try
        {
            while (remaining > 0)
            {
                await CheckpointAsync(token);
                var observed = await events.Reader.ReadAsync(timeout.Token);
                var e = observed.Data; var name = e.Text("event");
                if (name is "Died" or "Shutdown") throw new InvalidOperationException("游戏会话已结束。");
                if (name is not ("MarketBuy" or "MarketSell")) continue;
                if (name != expected || CommodityCatalog.Key(e.Text("Type")) != order.Commodity || e.Long("MarketID", marketId) != marketId || e.Long("Count") > remaining || e.Long("Count") <= 0) throw new InvalidOperationException("Journal 出现非计划交易,执行已停止。");
                var amount = e.Number(expected == "MarketBuy" ? "TotalCost" : "TotalSale");
                if (amount is null) throw new InvalidOperationException("交易缺少实际金额,无法确认。");
                total += (decimal)amount.Value;
                ledger!.Record(new($"journal:{journalRun}:{observed.Sequence}",expected=="MarketBuy"?TradeCashKind.Purchase:TradeCashKind.Sale,
                    (decimal)amount.Value,marketId,observed.ObservedAt,order.Commodity,checked((int)e.Long("Count"))));
                Report(State,expected=="MarketBuy"?"买入已确认;货物成本计入在途库存。":"卖出已确认,净利润已更新。");
                var expectedAmount = (decimal)e.Long("Count") * (expected == "MarketBuy" ? order.BuyPrice : order.SellPrice);
                if (expected == "MarketBuy" && (decimal)amount.Value > expectedAmount || expected == "MarketSell" && (decimal)amount.Value < expectedAmount) throw new InvalidOperationException("实际成交价劣于计划,已停止后续操作。");
                remaining -= (int)e.Long("Count");
            }
        }
        catch (OperationCanceledException) when (!token.IsCancellationRequested) { throw new TimeoutException("等待交易确认超时,执行已停止。"); }
        return total;
    }

    private async Task WaitDockedAsync(long marketId, CancellationToken token)
    {
        using var timeout = CancellationTokenSource.CreateLinkedTokenSource(token); timeout.CancelAfter(TimeSpan.FromMinutes(45));
        try
        {
            while (true)
            {
                await CheckpointAsync(token);
                var e = (await events.Reader.ReadAsync(timeout.Token)).Data;
                if (e.Text("event") is "Died" or "Shutdown") throw new InvalidOperationException("游戏会话已结束。");
                if (e.Text("event") == "Docked")
                {
                    if (e.Long("MarketID") != marketId) throw new InvalidOperationException("停靠了计划外站点,执行已停止。");
                    return;
                }
                if (e.Text("event") is "MarketBuy" or "MarketSell") throw new InvalidOperationException("航行阶段检测到非计划交易。");
            }
        }
        catch (OperationCanceledException) when (!token.IsCancellationRequested) { throw new TimeoutException("等待目的站停靠超时。"); }
    }
}