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();
}
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();
}