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

XFEEDToolBox

【WinUI】XFE精英危险工具箱

公开
关注 0 Fork 0 Star 0
UTF-8
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Text.Json;
using XFEEDToolBox.Core.Data;
using XFEEDToolBox.Core.Models;
using XFEEDToolBox.Core.Native;
using XFEEDToolBox.Core.Navigation;

namespace XFEEDToolBox.Core.Execution;

public sealed class NativeTradeSdkUpgradeRequiredException(string message):NotSupportedException(message);

public interface IOriginalCommerceChannel
{
    string Identity{get;}
    DateTimeOffset? ProcessStartedAt=>null;
    Task<JsonElement> CallAsync(object request,CancellationToken token);
}

public sealed class NativeCommerceChannel:IOriginalCommerceChannel
{
    private readonly int pid;private readonly long started;private readonly NavigationPipeClient pipe;
    public string Identity=>$"{pid}:{started}";
    public DateTimeOffset? ProcessStartedAt=>new DateTimeOffset(started,TimeSpan.Zero);
    public NativeCommerceChannel(int processId)
    {
        using var process=Process.GetProcessById(processId);pid=processId;started=process.StartTime.ToUniversalTime().Ticks;pipe=new(pid);
    }
    public Task<JsonElement> CallAsync(object request,CancellationToken token)
    {
        using var process=Process.GetProcessById(pid);
        if(process.ProcessName!="EliteDangerous64"||process.StartTime.ToUniversalTime().Ticks!=started)throw new InvalidOperationException("游戏实例已改变,已停止原生交易。");
        return pipe.CallAsync(request,token);
    }
}

public sealed record NativeWalletObservation(string CommanderId,long ShipId,DateTimeOffset SessionStartedAt,long Credits,DateTimeOffset ObservedAt)
{
    public PlayerState Project(PlayerState value)=>Credits>=0&&value.InGame&&value.CommanderId==CommanderId&&value.CurrentShipId==ShipId&&value.LoadGameAt==SessionStartedAt
        &&ObservedAt>=SessionStartedAt&&ObservedAt<=DateTimeOffset.UtcNow.AddSeconds(1)&&(value.CreditsAt is null||ObservedAt>=value.CreditsAt)
        ?value with{Credits=Credits,CreditsReliable=true,CreditsAt=ObservedAt}:value;
}

/// <summary>Original server transactions; docked jobs share one wallet gate.</summary>
public sealed class NativeTradeCommandDriver:IConfirmedTradeCommandDriver,IConfirmedDockedServiceDriver,ITradeFlightSafetyScope,ITradeRunMenuRecovery
{
    private static readonly ConcurrentDictionary<string,SemaphoreSlim> WalletGates=new();
    private static readonly ConcurrentDictionary<string,SemaphoreSlim> ServiceGates=new();
    private readonly IOriginalCommerceChannel channel;private readonly SemaphoreSlim gate,serviceGate;
    private readonly Func<PlayerState> player;private readonly string directory;
    private readonly Func<Station,Func<CancellationToken,Task>,CancellationToken,Task> travel;
    private readonly Func<CancellationToken,Task> ensureProtection;
    private readonly Func<Task> release;
    private readonly NativeTradeBatchPolicy? batchPolicy;
    private PlayerState? session;private bool uncertain,dockedReadiness,serviceQuoteSnapshots,marketClientRefresh;
    private const long MaximumServiceCost=uint.MaxValue;
    private JsonElement? cachedServices;private long cachedServiceMarket,cachedServiceAt;
    private JsonElement? serviceReadiness;
    public event Action<NativeWalletObservation>? WalletObserved;
    public event Action<ConfirmedDockedServiceReceipt>? ServiceConfirmed;
    public event Action<ConfirmedTradeReceipt>? TradeConfirmed;
    public event Action<string>? Status;
    public void ObserveJourneyService(ConfirmedDockedServiceReceipt receipt)
    {
        ValidateSession(receipt.MarketId);
        if(receipt.Action!="refuel")
            throw new InvalidDataException("航程补油回执无效,未修改交易账本。");
        _=receipt.ToCashEntry();
        ServiceConfirmed?.Invoke(receipt);
    }
    public string? JournalDirectory{get;init;}
    public Func<bool>? FlightSafetyActiveProvider{get;init;}
    public bool FlightSafetyActive=>FlightSafetyActiveProvider?.Invoke()==true;
    public Func<Func<bool>,CancellationToken,IDisposable>? RunningMenuRecoveryFactory{get;init;}
    public IDisposable EnterRunningMenuRecovery(Func<bool> isRunning,CancellationToken token)
        =>RunningMenuRecoveryFactory?.Invoke(isRunning,token)??EmptyTradeRunMenuRecovery.Instance;
    public NativeTradeCommandDriver(IOriginalCommerceChannel channel,Func<PlayerState> player,string operationDirectory,
        Func<Station,Func<CancellationToken,Task>,CancellationToken,Task> travel,Func<CancellationToken,Task> ensureProtection,Func<Task>? release=null,NativeTradeBatchPolicy? batchPolicy=null)
    {
        this.channel=channel;this.player=player;directory=operationDirectory;this.travel=travel;this.ensureProtection=ensureProtection;this.release=release??(()=>Task.CompletedTask);this.batchPolicy=batchPolicy;
        gate=WalletGates.GetOrAdd(channel.Identity,_=>new(1,1));Directory.CreateDirectory(directory);
        serviceGate=ServiceGates.GetOrAdd(channel.Identity,_=>new(1,1));
    }
    public async Task PreflightAsync(CancellationToken token)
    {
        var hello=await channel.CallAsync(new{op="describe"},token);
        if(hello.GetProperty("schema").GetInt32()!=1||hello.GetProperty("name").GetString()!="XFEEDToolBox.Navigation"||!hello.GetProperty("verified").GetBoolean()
            ||hello.GetProperty("gameSha256").GetString()!=NativeSystemLocationReader.GameSha256)throw new InvalidDataException("当前游戏构建的交易接口尚未验证。");
        dockedReadiness=hello.TryGetProperty("commerceDockedReadiness",out var readiness)&&readiness.ValueKind==JsonValueKind.True;
        serviceQuoteSnapshots=hello.TryGetProperty("serviceQuoteSnapshots",out var snapshots)&&snapshots.ValueKind==JsonValueKind.True;
        marketClientRefresh=hello.TryGetProperty("commerceClientProfileRefresh",out var refresh)&&refresh.ValueKind==JsonValueKind.True;
        if(batchPolicy is not null&&(!hello.TryGetProperty("formalCommodityTrading",out var batches)||batches.ValueKind!=JsonValueKind.True))
            throw new NativeTradeSdkUpgradeRequiredException("游戏当前加载的是旧验收接口;通用跑商需要加载新版原生插件。");
        if(batchPolicy is not null&&!marketClientRefresh)throw new NativeTradeSdkUpgradeRequiredException("当前插件缺少服务器库存与客户端同步核对,尚未启动批量采购。");
        if(batchPolicy is not null&&(!hello.TryGetProperty("marketWalletOptional",out var walletSchema)||walletSchema.ValueKind!=JsonValueKind.True))
            throw new NativeTradeSdkUpgradeRequiredException("原生采购入口仍要求行情中不存在的钱包字段,需要加载修正后的原生插件。");
        session=player();ValidateSession();await ensureProtection(token);
        // This may be a flight-only transit stop. Fine/access eligibility is
        // checked at the exact buy/sell market below, not for leaving a port.
        await NativeUnsubmittedTradeIntents.ReconcileAsync(directory,channel,session,token);
        foreach(var file in Directory.EnumerateFiles(directory,"*.pending.json"))
        {
            var oldStatus=file.Replace(".pending.json",".status.json",StringComparison.Ordinal);
            if(File.Exists(oldStatus))
            {
                using var state=JsonDocument.Parse(await File.ReadAllTextAsync(oldStatus,token));
                if(NativeOrderReconciliation.IsServerFailure(state.RootElement))
                {using var oldIntent=JsonDocument.Parse(await File.ReadAllTextAsync(file,token));if(oldIntent.RootElement.GetProperty("channel").GetString()==channel.Identity)throw new NativeOrderReconciliationRequiredException(state.RootElement);}
            }
            if(File.Exists(file.Replace(".pending.json",".confirmed.json",StringComparison.Ordinal))||ExternalTradeSettlement.IsSettled(file,session)||NativeOrderReconciliation.IsResolved(file,session))continue;
            await NativeOrderReconciliation.WaitForResolutionAsync(file,channel,player,JournalDirectory,text=>Status?.Invoke(text),token);
            if(!NativeOrderReconciliation.IsResolved(file,player()))throw new InvalidOperationException("存在尚未核对的原生订单,正在等待实际结果;不会重复下单。");
        }
        await gate.WaitAsync(token);try{PublishWallet(await channel.CallAsync(new{op="commerce.inventory"},token));}finally{gate.Release();}
    }
    private PlayerState ValidateSession(long? market=null)
    {
        var current=player();var initial=session??throw new InvalidOperationException("请先核对原生交易会话。");
        if(uncertain||!current.InGame||current.CommanderId!=initial.CommanderId||current.CurrentShipId!=initial.CurrentShipId||current.LoadGameAt!=initial.LoadGameAt
            ||current.CurrentShipId is null||current.LoadGameAt is null||NavigationSafety.UnhealthyHull(current)
            ||market is not null&&(!current.Docked||current.MarketId!=market))throw new InvalidOperationException("当前船舶、停靠、会话或订单状态不适合继续交易。");
        if(NavigationSafety.HasExternalThreat(current))throw new FlightThreatPauseException();
        return current;
    }
    /// <summary>A dynamic source may change only before an order, with verified empty cargo and no unresolved original operation.</summary>
    public PlayerState RequireSourceSelectionReady()
    {
        var current=ValidateSession();
        if(!current.CargoReliable||current.CargoCount!=0)throw new InvalidOperationException("已有或未确认货物必须先按原订单核对,不能替换货源。");
        foreach(var pending in Directory.EnumerateFiles(directory,"*.pending.json"))
            if(!File.Exists(pending.Replace(".pending.json",".confirmed.json",StringComparison.Ordinal))
                &&!ExternalTradeSettlement.IsSettled(pending,current)&&!NativeOrderReconciliation.IsResolved(pending,current))
                throw new InvalidOperationException("存在尚未核对的原生订单,未替换货源,也不会新增采购。");
        return current;
    }
    private void PublishWallet(JsonElement value)
    {
        var current=ValidateSession();if(value.TryGetProperty("verified",out var verified)&&!verified.GetBoolean())throw new InvalidDataException("原生余额未经验证。");
        var credits=value.GetProperty("credits").GetInt64();if(credits<0)throw new InvalidDataException("原生余额无效。");
        WalletObserved?.Invoke(new(current.CommanderId,current.CurrentShipId!.Value,current.LoadGameAt!.Value,credits,DateTimeOffset.UtcNow));
    }
    private async Task<JsonElement> WaitAsync(ulong generation,CancellationToken token)
    {
        var until=DateTimeOffset.UtcNow.AddSeconds(35);
        while(true)
        {
            JsonElement value;
            try{value=await channel.CallAsync(new{op="commerce.market.status",generation,maximum=50},token);}
            catch(Exception error)when(NavigationRecovery.CanRetry(error,token)&&DateTimeOffset.UtcNow<until)
            {await Task.Delay(80,token);continue;}
            if(value.GetProperty("state").GetInt32()>=3)return value;
            if(DateTimeOffset.UtcNow>=until)throw new TimeoutException("等待游戏原生回执超时。");await Task.Delay(80,token);
        }
    }
    private static void RequireConfirmed(JsonElement value)
    {
        if(value.GetProperty("state").GetInt32()!=3)
        {
            if(NativeOrderReconciliation.IsServerFailure(value))throw new NativeOrderReconciliationRequiredException(value);
            var stage=value.TryGetProperty("failureStage",out var failure)?failure.GetString():null;
            var code=value.TryGetProperty("error",out var error)?error.ToString():"unknown";
            throw new InvalidOperationException("原生请求尚未完整核对"+(stage is null?"。":"("+stage+")。")+"原始错误码 "+code+";不会将客户端未更新当作未成交,也不会重复提交。");
        }
    }
    private async Task WaitForDockedAsync(long market,bool services,CancellationToken token)
    {
        if(!dockedReadiness)return;
        using var timeout=CancellationTokenSource.CreateLinkedTokenSource(token);timeout.CancelAfter(TimeSpan.FromSeconds(10));
        JsonElement status=default;
        try
        {
            while(true)
            {
                ValidateSession(market);await ensureProtection(timeout.Token);
                status=await channel.CallAsync(new{op="commerce.docked.status",marketId=market},timeout.Token);
                if(services)serviceReadiness=status.Clone();
                var actual=status.GetProperty("actualMarketId").GetUInt64();
                if(actual!=0&&actual!=(ulong)market)throw new InvalidDataException("原生停靠市场与预期不符,已停止交易。");
                if(status.GetProperty(services?"serviceReady":"ready").GetBoolean())return;
                await Task.Delay(80,timeout.Token);
            }
        }
        catch(OperationCanceledException)when(!token.IsCancellationRequested)
        {
            throw new TimeoutException("等待原生停靠服务就绪超时:"+(status.ValueKind==JsonValueKind.Undefined?"无状态":status.GetRawText()));
        }
    }
    private async Task<JsonElement> PricesAsync(long market,CancellationToken token)
    {
        using var deadline=CancellationTokenSource.CreateLinkedTokenSource(token);deadline.CancelAfter(NativeServiceSnapshotUnavailableException.ReadinessBudget);
        var watch=Stopwatch.StartNew();var attempts=0;JsonElement? lastResult=null;var diagnosticPath="";
        try
        {
            while(true)
            {
                token.ThrowIfCancellationRequested();ValidateSession(market);await ensureProtection(deadline.Token);
                await WaitForDockedAsync(market,true,deadline.Token);ValidateSession(market);
                var queued=await channel.CallAsync(new{op="station.services.inspect",marketId=market},deadline.Token);attempts++;
                var result=await WaitAsync(queued.GetProperty("generation").GetUInt64(),deadline.Token);lastResult=result;
                deadline.Token.ThrowIfCancellationRequested();ValidateSession(market);
                if(result.GetProperty("state").GetInt32()==3)
                {
                    if(attempts>1)Status?.Invoke("站内服务只读报价已恢复就绪,正在核对维护费用。");
                    return result;
                }
                var retry=NativeServiceSnapshotUnavailableException.IsUnsubmittedReadFailure(queued,result,market);
                diagnosticPath=await RecordServiceReadinessAsync(market,attempts,watch.Elapsed,result,retry,"read-failed",token);
                if(!retry)
                {
                    if(result.TryGetProperty("mutationsSent",out var mutations)&&mutations.TryGetInt32(out var sent)&&sent>0
                        ||result.TryGetProperty("reconciliationRequired",out var required)&&required.ValueKind==JsonValueKind.True)uncertain=true;
                    RequireConfirmed(result);
                }
                if(attempts>=NativeServiceSnapshotUnavailableException.MaximumAttempts)
                    throw new NativeServiceSnapshotUnavailableException(attempts,diagnosticPath);
                Status?.Invoke("正在等待站内服务只读报价恢复新鲜;尚未提交维护订单。");
                await Task.Delay(250,deadline.Token);
            }
        }
        catch(OperationCanceledException error)when(!token.IsCancellationRequested)
        {
            diagnosticPath=await RecordServiceReadinessAsync(market,attempts,watch.Elapsed,lastResult,false,"readiness-budget-exhausted",CancellationToken.None);
            throw new NativeServiceSnapshotUnavailableException(attempts,diagnosticPath,error);
        }
    }
    private async Task<string> RecordServiceReadinessAsync(long market,int attempts,TimeSpan elapsed,JsonElement? result,bool safeToRepeatRead,string stage,CancellationToken token)
    {
        var generation=result is {} row&&row.TryGetProperty("generation",out var value)?value.ToString():"pending";
        var path=Path.Combine(directory,"service-readiness-"+market+"-"+generation+".json");
        var current=player();
        await File.WriteAllTextAsync(path,JsonSerializer.Serialize(new{at=DateTimeOffset.UtcNow,operation="station.services.inspect",readOnly=true,stage,attempts,elapsedSeconds=elapsed.TotalSeconds,
            maximumAttempts=NativeServiceSnapshotUnavailableException.MaximumAttempts,budgetSeconds=NativeServiceSnapshotUnavailableException.ReadinessBudget.TotalSeconds,
            market,current.CommanderId,current.CurrentShipId,current.LoadGameAt,current.Docked,current.MarketId,
            dockedServiceStatus=serviceReadiness,originalReadResult=result,safeToRepeatRead,monetaryRequestReplayed=false,
            cacheVersion=(long?)null,refreshReady=(bool?)null,cacheFieldsSource="The current native readiness protocol does not expose cacheVersion or refreshReady; serviceReady proves only docking/service binding."},ToolboxSettings.JsonOptions),token);
        return path;
    }
    private async Task<JsonElement> MarketAsync(long market,CancellationToken token)
    {
        // A market snapshot supplies inventory and prices, not a wallet.
        // Transactions still require their own server receipt and original
        // client callbacks; a quote must not invent a profile response.
        JsonElement result;
        for(var attempt=0;;attempt++)
        {
            ValidateSession(market);await ensureProtection(token);await WaitForDockedAsync(market,false,token);
            var queued=await channel.CallAsync(new{op="commerce.market.read",marketId=market,refreshClient=false},token);
            result=await WaitAsync(queued.GetProperty("generation").GetUInt64(),token);
            if(result.GetProperty("state").GetInt32()==3)break;
            await File.WriteAllTextAsync(Path.Combine(directory,"market-read-failure-"+queued.GetProperty("generation").GetUInt64()+".json"),result.GetRawText(),token);
            // Only repeat this read-only snapshot after the known freshness
            // rejection. Monetary requests keep their original operation key.
            if(attempt>=3||result.GetProperty("state").GetInt32()!=4||!result.TryGetProperty("error",out var error)||error.GetInt32()!=1
                ||!result.TryGetProperty("mutationsSent",out var mutations)||mutations.GetInt32()!=0
                ||result.TryGetProperty("reconciliationRequired",out var required)&&required.GetBoolean())break;
            Status?.Invoke("正在等待原生市场观测恢复新鲜,再核对库存。");await Task.Delay(200,token);
        }
        if(NativeMarketSnapshotUnavailableException.IsFreshnessRejection(result))throw new NativeMarketSnapshotUnavailableException();
        RequireConfirmed(result);
        if(!result.TryGetProperty("cargo",out var cargo)||cargo.ValueKind!=JsonValueKind.Object)
            throw new InvalidDataException("原始市场响应缺少可核对的服务器货舱,未启动交易。");
        long count=0;
        foreach(var item in cargo.EnumerateObject())
        {
            if(!item.Value.TryGetInt32(out var quantity)||quantity<0)throw new InvalidDataException("服务器货舱数量无效。");
            count=checked(count+quantity);
        }
        var inventory=await channel.CallAsync(new{op="commerce.inventory"},token);
        if(!inventory.GetProperty("verified").GetBoolean()||inventory.GetProperty("count").GetInt32()!=count
            ||result.TryGetProperty("serverCargoCount",out var total)&&total.GetInt64()!=count
            ||result.TryGetProperty("serverCredits",out var credits)&&inventory.GetProperty("credits").GetInt64()!=credits.GetInt64())
            throw new InvalidDataException("服务器与客户端货舱或钱包尚未一致,未启动交易。");
        PublishWallet(inventory);
        return result;
    }
    public async Task<IReadOnlyList<CommodityQuote>> ReadDockedQuotesAsync(long market,CancellationToken token)
    {
        await gate.WaitAsync(token);
        try
        {
            var snapshot=await MarketAsync(market,token);var generation=snapshot.GetProperty("generation").GetUInt64();
            var rows=new List<CommodityQuote>();var at=DateTimeOffset.UtcNow;
            for(var offset=0;offset<snapshot.GetProperty("commodityCount").GetInt32();offset+=50)
            {
                var page=offset==0?snapshot:await channel.CallAsync(new{op="commerce.market.status",generation,offset,maximum=50},token);
                foreach(var row in page.GetProperty("commodities").EnumerateArray())
                {
                    long Number(string field,bool quantity=false)
                    {
                        if(!row.TryGetProperty(field,out var value)||value.ValueKind!=JsonValueKind.Number)return 0;
                        if(value.TryGetInt64(out var integer)&&integer>=0)return integer;
                        if(!value.TryGetDecimal(out var number)||number<0||number>long.MaxValue||!quantity&&decimal.Truncate(number)!=number)
                            throw new InvalidDataException($"Original market field {field} has an invalid value: {value.GetRawText()}");
                        // The server can quote fractional supply or demand.
                        // Only whole tonnes are available for an order.
                        return decimal.ToInt64(quantity?decimal.Floor(number):number);
                    }
                    var name=CommodityCatalog.Key(row.GetProperty("name").GetString()??"");
                    rows.Add(new(){MarketId=market,Commodity=name,DisplayName=name,BuyPrice=Number("buyPrice"),SellPrice=Number("sellPrice"),Supply=Number("stock",true),
                        Demand=Number("demand",true),CollectedAt=at,Source=DataSource.Journal,Illegal=row.TryGetProperty("illegal",out var illegal)&&illegal.ValueKind==JsonValueKind.True
                            ||row.TryGetProperty("legality",out var legality)&&legality.ValueKind==JsonValueKind.String&&!string.IsNullOrEmpty(legality.GetString())});
                }
            }
            return rows;
        }
        finally{gate.Release();}
    }
    public async Task<NativeWalletObservation> RefreshWalletAsync(CancellationToken token)
    {
        await gate.WaitAsync(token);
        try
        {
            var inventory=await channel.CallAsync(new{op="commerce.inventory"},token);PublishWallet(inventory);
            var current=ValidateSession();return new(current.CommanderId,current.CurrentShipId!.Value,current.LoadGameAt!.Value,inventory.GetProperty("credits").GetInt64(),DateTimeOffset.UtcNow);
        }
        finally{gate.Release();}
    }
    private async Task<JsonElement> SubmitAsync(string key,object command,object intent,CancellationToken token)
    {
        var pending=Path.Combine(directory,key+".pending.json");var confirmed=Path.Combine(directory,key+".confirmed.json");
        // Publish the durable intent only after serialization and a full write.
        // Cancellation may leave a temporary file, never an empty pending order.
        var bytes=JsonSerializer.SerializeToUtf8Bytes(new{operationKey=key,channel=channel.Identity,session!.CommanderId,session.CurrentShipId,session.LoadGameAt,intent,created=DateTimeOffset.UtcNow});
        var temporary=pending+".tmp";
        await using(var file=new FileStream(temporary,FileMode.CreateNew,FileAccess.Write,FileShare.None))
        {await file.WriteAsync(bytes,token);await file.FlushAsync(token);file.Flush(true);}
        token.ThrowIfCancellationRequested();File.Move(temporary,pending,false);
        JsonElement queued;
        try{queued=await channel.CallAsync(command,token);}
        catch(Exception error)when(error is IOException or OperationCanceledException)
        {
            // Query the operation key; never replay a monetary command after
            // a lost acknowledgement or while cancellation is being handled.
            using var reconcile=new CancellationTokenSource(TimeSpan.FromSeconds(40));
            try
            {
                queued=await channel.CallAsync(new{op="commerce.operation.status",operationKey=key},reconcile.Token);
                if(!queued.GetProperty("known").GetBoolean())
                {
                    await File.WriteAllTextAsync(confirmed,JsonSerializer.Serialize(new{submitted=false,cancelled=token.IsCancellationRequested}),reconcile.Token);
                    if(token.IsCancellationRequested)throw new OperationCanceledException(token);throw new IOException("原生订单未提交,可重新核对后继续。",error);
                }
                if(!queued.GetProperty("receiptAvailable").GetBoolean())throw new IOException("原生订单回执已改变,需要核对服务器库存。",error);
            }
            catch{uncertain=!File.Exists(confirmed);throw;}
        }
        // Once accepted, finish observing this one original order even if the
        // user cancels. No new trade, maintenance or departure is started.
        try
        {
            using var reconcile=new CancellationTokenSource(TimeSpan.FromSeconds(40));
            var receipt=await WaitAsync(queued.GetProperty("generation").GetUInt64(),reconcile.Token);
            await File.WriteAllTextAsync(Path.Combine(directory,key+".status.json"),receipt.GetRawText(),reconcile.Token);
            RequireConfirmed(receipt);
            if(!receipt.GetProperty("serverConfirmed").GetBoolean()||!receipt.GetProperty("clientApplied").GetBoolean())throw new InvalidDataException("原生订单尚未完成服务器与客户端核对。");
            await File.WriteAllTextAsync(confirmed,JsonSerializer.Serialize(new{operationKey=key,confirmedAt=DateTimeOffset.UtcNow,receipt}),reconcile.Token);
            return receipt;
        }
        catch{uncertain=true;throw;}
    }
    public async Task ServiceDockedAsync(DockedServiceRequest request,Func<CancellationToken,Task> beforeInput,CancellationToken token)
        =>await ServiceDockedAsync(request,beforeInput,false,token);

    public async Task RefuelDockedAsync(DockedServiceRequest request,Func<CancellationToken,Task> beforeInput,CancellationToken token)
        =>await ServiceDockedAsync(request,beforeInput,true,token);

    private async Task ServiceDockedAsync(DockedServiceRequest request,Func<CancellationToken,Task> beforeInput,bool refuelOnly,CancellationToken token)
    {
        // Two callers must not consume the same initial maintenance quote.
        // Trading can still use the wallet between the individual services.
        await serviceGate.WaitAsync(token);
        try{await ServiceDockedCoreAsync(request,beforeInput,refuelOnly,token);}
        finally{serviceGate.Release();}
    }
    private async Task ServiceDockedCoreAsync(DockedServiceRequest request,Func<CancellationToken,Task> beforeInput,bool refuelOnly,CancellationToken token)
    {
        var initial=ValidateSession(request.MarketId);
        if(!initial.StationServicesKnown)throw new InvalidOperationException("等待当前站点确认可用的加油、维修与弹药服务。");
        var actions=(refuelOnly?new[]{"refuel"}:new[]{"refuel","repair","integrity","rearm"})
            .Where(action=>initial.StationServices.Contains(action=="integrity"?"repair":action)).ToArray();
        if(actions.Length==0)return;
        // One original quote covers this docked visit. Keep only monetary
        // operations serialized with trading; do not refetch the full quote
        // before and after every individual service.
        JsonElement prices;var quotedAt=Stopwatch.GetTimestamp();await gate.WaitAsync(token);
        try{if(serviceQuoteSnapshots&&cachedServices is {} cached&&cachedServiceMarket==request.MarketId&&Stopwatch.GetElapsedTime(cachedServiceAt)<TimeSpan.FromSeconds(25)){prices=cached;quotedAt=cachedServiceAt;}else{prices=await PricesAsync(request.MarketId,token);quotedAt=Stopwatch.GetTimestamp();}cachedServices=null;}
        finally{gate.Release();}
        quotedAt=Stopwatch.GetTimestamp();
        var quotes=actions.Select(action=>(Action:action,Cost:ServiceCost(prices,action))).ToArray();
        foreach(var quote in quotes)
            if(quote.Cost<0||quote.Cost>(batchPolicy is null?50000:MaximumServiceCost))throw new InvalidOperationException("当前维护报价超出已验证范围。");
        var serviced=false;
        foreach(var quote in quotes)
        {
            var action=quote.Action;var cost=quote.Cost;if(cost==0)continue;
            if(action is "repair" or "rearm")cost=Math.Max(cost,10);
            await gate.WaitAsync(token);
            try
            {
                // Older plugins require their current response slot to hold
                // prices. New plugins retain the exact original quote across
                // other confirmed commerce calls, for the same bounded TTL.
                if(!serviceQuoteSnapshots||Stopwatch.GetElapsedTime(quotedAt)>TimeSpan.FromSeconds(25))
                {
                    prices=await PricesAsync(request.MarketId,token);quotedAt=Stopwatch.GetTimestamp();
                    cost=ServiceCost(prices,action);
                    if(cost<0||cost>(batchPolicy is null?50000:MaximumServiceCost))throw new InvalidOperationException("当前维护报价超出已验证范围。");
                    if(cost==0)continue;if(action is "repair" or "rearm")cost=Math.Max(cost,10);
                }
                await beforeInput(token);await ensureProtection(token);ValidateSession(request.MarketId);
                var before=await channel.CallAsync(new{op="commerce.inventory"},token);PublishWallet(before);
                if(batchPolicy is not null&&(decimal)before.GetProperty("credits").GetInt64()-cost<batchPolicy.ReserveCredits)
                    throw new InvalidOperationException("补给费用将动用保留资金,已保留原任务并阻止离站。");
                var key=Guid.NewGuid().ToString("N");Status?.Invoke($"正在完成{(action=="refuel"?"加油":action=="rearm"?"弹药补给":"维修")}…");
                object command=serviceQuoteSnapshots
                    ?new{op="station.services",production=batchPolicy is not null,reserveCredits=batchPolicy is null?0:decimal.Ceiling(batchPolicy.ReserveCredits),marketId=request.MarketId,action,operationKey=key,quoteGeneration=prices.GetProperty("generation").GetUInt64()}
                    :new{op="station.services",production=batchPolicy is not null,reserveCredits=batchPolicy is null?0:decimal.Ceiling(batchPolicy.ReserveCredits),marketId=request.MarketId,action,operationKey=key};
                var receipt=await SubmitAsync(key,command,new{action,marketId=request.MarketId,cost,before},token);
                if(receipt.GetProperty("creditsBefore").GetInt64()!=before.GetProperty("credits").GetInt64()||receipt.GetProperty("credits").GetInt64()!=before.GetProperty("credits").GetInt64()-cost)throw new InvalidDataException("维护金额与实际回执不一致。");
                // Account the confirmed debit even if cancellation or the later
                // service-completion check prevents departure.
                ServiceConfirmed?.Invoke(new(key,action,request.MarketId,receipt.GetProperty("creditsBefore").GetInt64(),receipt.GetProperty("credits").GetInt64(),DateTimeOffset.UtcNow));
                PublishWallet(await channel.CallAsync(new{op="commerce.inventory"},CancellationToken.None));token.ThrowIfCancellationRequested();
                serviced=true;
            }
            finally{gate.Release();}
        }
        if(serviced)
        {
            await gate.WaitAsync(token);
            try{if(serviceQuoteSnapshots&&cachedServices is {} cached&&cachedServiceMarket==request.MarketId&&Stopwatch.GetElapsedTime(cachedServiceAt)<TimeSpan.FromSeconds(25)){prices=cached;quotedAt=cachedServiceAt;}else{prices=await PricesAsync(request.MarketId,token);quotedAt=Stopwatch.GetTimestamp();}cachedServices=null;}
            finally{gate.Release();}
        }
        foreach(var action in actions)
            if(ServiceCost(prices,action)!=0||action=="integrity"&&ServiceQuote(prices,action).GetProperty("wear").GetInt32()!=0)
                throw new InvalidDataException("服务器尚未确认维护完成,已阻止离站。");
    }
    private static JsonElement ServiceQuote(JsonElement prices,string action)
    {
        var value=prices.GetProperty("servicePrices").GetProperty(action=="refuel"?"fuel":action=="rearm"?"ammunition":"repair");
        return action=="integrity"?value.GetProperty("shipIntegrityCosts"):value;
    }
    private static long ServiceCost(JsonElement prices,string action)
        =>ServiceQuote(prices,action).GetProperty(action=="refuel"?"costToRefuel":action=="rearm"?"costToRearm":"costToRepair").GetInt64();
    public async Task<decimal> ReadServiceBudgetAsync(long market,CancellationToken token)
    {
        var state=ValidateSession(market);await gate.WaitAsync(token);
        try
        {
            var prices=await PricesAsync(market,token);cachedServices=prices;cachedServiceMarket=market;cachedServiceAt=Stopwatch.GetTimestamp();
            decimal total=0;
            foreach(var action in new[]{"refuel","repair","integrity","rearm"}.Where(a=>state.StationServices.Contains(a=="integrity"?"repair":a)))
            {var cost=ServiceCost(prices,action);if(cost<0||cost>uint.MaxValue)throw new InvalidDataException("维护报价无效。");total+=cost>0&&action is "repair" or "rearm"?Math.Max(cost,10):cost;}
            return total;
        }
        finally{gate.Release();}
    }
    public async Task<ConfirmedTradeReceipt> ExecuteTradeAsync(TradeAction action,Func<CancellationToken,Task> beforeInput,CancellationToken token)
    {
        if(action.Kind is not(TradeActionKind.Buy or TradeActionKind.Sell)||action.Order is not{} order)throw new InvalidOperationException("原始交易缺少有效订单。");
        if(batchPolicy is null&&(order.Quantity!=1||!TradeAcceptancePolicy.Allows(order.Commodity,order.BuyPrice)))throw new InvalidOperationException("当前实机验收只允许 1t 常见低价货物。");
        var buy=action.Kind==TradeActionKind.Buy;var market=buy?action.Leg.From.MarketId:action.Leg.To.MarketId;var delta=buy?order.Quantity:-order.Quantity;
        await gate.WaitAsync(token);
        try
        {
            var current=ValidateSession(market);await beforeInput(token);await ensureProtection(token);
            await StationAccessWaiter.WaitAsync(JournalDirectory,player,market,text=>Status?.Invoke(text),token);
            batchPolicy?.ValidateAction(action,current);
            var snapshot=await MarketAsync(market,token);var generation=snapshot.GetProperty("generation").GetUInt64();JsonElement? quote=null;
            for(var offset=0;offset<snapshot.GetProperty("commodityCount").GetInt32();offset+=50)
            {
                var page=offset==0?snapshot:await channel.CallAsync(new{op="commerce.market.status",generation,offset,maximum=50},token);
                foreach(var row in page.GetProperty("commodities").EnumerateArray())if(CommodityCatalog.Key(row.GetProperty("name").GetString()??"")==order.Commodity){quote=row;break;}
                if(quote is not null)break;
            }
            if(quote is null)throw new InvalidOperationException("当前站点未确认此商品的可交易报价。");
            var unitPrice=quote.Value.GetProperty(buy?"buyPrice":"sellPrice").GetInt64();
            if(unitPrice<=0||unitPrice>(batchPolicy is null?500:NativeTradeBatchPolicy.MaximumUnitPrice)||(buy?unitPrice>order.BuyPrice:unitPrice<order.SellPrice))throw new InvalidOperationException("当前实际价格劣于计划,已停止交易。");
            var before=await channel.CallAsync(new{op="commerce.inventory"},token);PublishWallet(before);await beforeInput(token);ValidateSession(market);
            var key=Guid.NewGuid().ToString("N");var id=quote.Value.GetProperty("id").GetUInt32();
            batchPolicy?.ValidateAction(action,ValidateSession(market));
            var result=await SubmitAsync(key,new{op=batchPolicy is null?"commerce.trade":"commerce.trade.formal",marketId=market,commodityId=id,quantityDelta=delta,operationKey=key},new{market,order.Commodity,delta,unitPrice,before},token);
            var confirmed=DateTimeOffset.UtcNow;var after=await channel.CallAsync(new{op="commerce.inventory"},CancellationToken.None);PublishWallet(after);
            var receipt=new ConfirmedTradeReceipt(key,market,order.Commodity,delta,unitPrice,result.GetProperty("creditsBefore").GetInt64(),result.GetProperty("credits").GetInt64(),
                result.GetProperty("cargoBefore").GetInt32(),result.GetProperty("cargoAfter").GetInt32(),current.CommanderId,current.CurrentShipId!.Value,current.LoadGameAt!.Value,confirmed,true,true);
            if(after.GetProperty("count").GetInt32()!=receipt.CargoAfter||after.GetProperty("credits").GetInt64()!=receipt.CreditsAfter)throw new InvalidDataException("原生货舱或余额与交易回执不一致。");
            // Preserve an already applied order before optional follow-up
            // reads can fail. A later resume must never buy/sell it again.
            TradeConfirmed?.Invoke(receipt);
            using var refresh=new CancellationTokenSource(TimeSpan.FromSeconds(8));
            JsonElement server;
            try{server=await MarketAsync(market,refresh.Token);}
            catch(NativeMarketSnapshotUnavailableException)when(!token.IsCancellationRequested)
            {
                await File.WriteAllTextAsync(Path.Combine(directory,"market-refresh-pending-"+key+".json"),JsonSerializer.Serialize(new{receipt,inventory=after,
                    reason="read-only-freshness",monetaryOrderAlreadyConfirmed=true,repeatMonetaryRequest=false},ToolboxSettings.JsonOptions),CancellationToken.None);
                Status?.Invoke("成交及实际货舱、钱包已确认,行情快照稍后刷新;不会重发本订单。");return receipt;
            }
            var expected=result.GetProperty("after").GetInt32();
            if((server.GetProperty("cargo").TryGetProperty(order.Commodity,out var qty)?qty.GetInt32():0)!=expected)throw new InvalidDataException("第二次服务器读取未确认交易库存。");
            while(player().CargoCount!=receipt.CargoAfter||player().Inventory.Where(x=>x.Key==order.Commodity&&x.Stolen==0&&x.MissionId is null).Sum(x=>x.Count)!=expected)await Task.Delay(100,refresh.Token);
            return receipt;
        }
        finally{gate.Release();}
    }
    public async Task ExecuteAsync(TradeAction action,Func<CancellationToken,Task> beforeInput,CancellationToken token)
    {
        if(action.Kind!=TradeActionKind.Travel){await ExecuteTradeAsync(action,beforeInput,token);return;}
        Planning.ShipPadPolicy.RequireFits(action.Leg.From,player().Ship);Planning.ShipPadPolicy.RequireFits(action.Leg.To,player().Ship);
        ValidateSession();await beforeInput(token);await ensureProtection(token);await travel(action.Leg.To,beforeInput,token);
    }
    public Task ReleaseAllAsync()=>release();
}