using System.Threading.Channels;
using XFEEDToolBox.Core.Data;
using XFEEDToolBox.Core.Models;
using XFEEDToolBox.Core.Navigation;
namespace XFEEDToolBox.Core.Execution;
public enum RunState { Idle, Running, Paused, Completed, Stopped, Failed }
public enum TradeActionKind { Buy, Travel, Sell }
public sealed record TradeAction(TradeActionKind Kind, TradeLeg Leg, TradeOrder? Order);
public sealed record DockedServiceRequest(long MarketId,int ReservedCargo);
public sealed record RunProgress(RunState State, string Message, int Leg, int TotalLegs, decimal ActualProfit, DateTimeOffset At)
{
public TradeProfitSnapshot Accounting { get; init; } = TradeProfitSnapshot.Empty;
public bool IsSimulation { get; init; }
}
public interface ITradeCommandDriver
{
Task PreflightAsync(CancellationToken token);
// Confirm refuel/repair/rearm against the current station's available
// services. Cargo consumables must leave ReservedCargo tonnes free.
Task ServiceDockedAsync(DockedServiceRequest request,Func<CancellationToken,Task> beforeInput,CancellationToken token);
Task ExecuteAsync(TradeAction action, Func<CancellationToken, Task> beforeInput, CancellationToken token);
Task ReleaseAllAsync();
}
/// <summary>Only true while a native flight controller is applying its own fresh thermal and threat interlocks.</summary>
public interface ITradeFlightSafetyScope
{
bool FlightSafetyActive { get; }
}
public sealed class TradeRunner
{
private readonly Func<PlayerState> player;
private readonly Func<long, string, CommodityQuote?> quote;
private readonly Channel<ObservedJournalEvent> events = Channel.CreateUnbounded<ObservedJournalEvent>(new() { SingleReader = true });
private readonly object sync = new();
private CancellationTokenSource? runStop;
private TaskCompletionSource resume = CompletedGate();
private RunState state;
private int currentLeg, totalLegs;
private decimal actualProfit;
private TradeProfitLedger? ledger;
private readonly string? accountingDirectory;
private string journalRun = "";
private bool journalServices;
private Exception? accountingError;
private bool active;
private ExecutionMode activeMode;
private PlayerState? preservedCargo;
private ITradeCommandDriver? activeDriver;
private IDisposable? menuRecovery;
private bool menuRecoveryStarting;
private bool menuRecoveryStopped;
private long menuRecoveryGeneration;
public RunState State { get { lock (sync) return state; } }
public event Action<RunProgress>? Progress;
public TradeRunner(Func<PlayerState> player, Func<long, string, CommodityQuote?> quote, string? accountingDirectory = null)
{ this.player = player; this.quote = quote; this.accountingDirectory = accountingDirectory; }
public void Observe(ObservedJournalEvent e)
{
if (!Volatile.Read(ref active)) return;
if (journalServices && ledger is not null)
{
var kind = e.Data.Text("event") switch
{
"RefuelAll" or "RefuelPartial" => TradeCashKind.Fuel,
"Repair" or "RepairAll" => TradeCashKind.Repair,
"BuyAmmo" => TradeCashKind.Rearm,
"BuyDrones" => TradeCashKind.OtherExpense,
_ => (TradeCashKind?)null
};
if (kind is not null)
{
try
{
var amount = e.Data.Number("Cost");
if (amount is null || !double.IsFinite(amount.Value) || amount < 0) throw new InvalidDataException("维护日志缺少实际扣款金额。");
if (amount > 0 && ledger.Record(new($"journal:{journalRun}:{e.Sequence}",kind.Value,(decimal)amount.Value,
e.Data.Long("MarketID",player().MarketId??0),e.ObservedAt))) Report(State,"已计入停靠服务费用。");
}
catch (Exception error) when (error is IOException or InvalidDataException or OverflowException)
{ Interlocked.CompareExchange(ref accountingError,error,null); Stop(); }
}
}
events.Writer.TryWrite(e);
}
public void ObservePlayer(PlayerState p)
{
if (activeMode == ExecutionMode.Simulation) return;
if(NavigationSafety.UnhealthyHull(p)){Stop();return;}
if (State == RunState.Running && MustPauseForDanger(p)) Pause("检测到战斗、拦截或未接管的危险状态,执行已暂停。");
if (State is RunState.Running or RunState.Paused && !p.InGame) Stop();
}
private static TaskCompletionSource CompletedGate() { var t = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); t.SetResult(); return t; }
private void Report(RunState next, string message)
{
// A delayed status/receipt must not undo a user's Pause. Resume is
// the only path that deliberately changes Paused back to Running.
lock (sync){if(next==RunState.Running&&state==RunState.Paused)next=RunState.Paused;state=next;}
if(next==RunState.Running)EnsureMenuRecovery();else RevokeMenuRecovery();
lock(sync)next=state;
var accounting = ledger?.Snapshot ?? TradeProfitSnapshot.Empty;
Progress?.Invoke(new(next, message, currentLeg, totalLegs, activeMode == ExecutionMode.Simulation ? actualProfit : accounting.NetProfit, DateTimeOffset.UtcNow)
{ Accounting = accounting, IsSimulation = activeMode == ExecutionMode.Simulation });
}
public void Pause(string reason = "用户暂停")
{
lock (sync)
{
if (state != RunState.Running) return;
resume = new(TaskCreationOptions.RunContinuationsAsynchronously);
state = RunState.Paused;
}
RevokeMenuRecovery();
Report(RunState.Paused, reason);
}
public void Resume()
{
lock (sync)
{
if (state != RunState.Paused) return;
if (MustPauseForDanger(player())) throw new InvalidOperationException("危险状态尚未解除。");
state = RunState.Running;
resume.TrySetResult();
}
Report(RunState.Running, "继续执行;下一次操作前重新检查。");
}
public void Stop()
{
lock(sync)menuRecoveryStopped=true;
RevokeMenuRecovery();
lock (sync) { runStop?.Cancel(); resume.TrySetResult(); }
}
private bool MenuRecoveryAllowed(long generation)
{
lock(sync)return generation==menuRecoveryGeneration&&!menuRecoveryStopped&&active&&state==RunState.Running
&&activeMode==ExecutionMode.NativeBridge&&runStop is {IsCancellationRequested:false};
}
private void RevokeMenuRecovery()
{
IDisposable? previous;
lock(sync){menuRecoveryGeneration++;menuRecoveryStarting=false;previous=menuRecovery;menuRecovery=null;}
previous?.Dispose();
}
private void EnsureMenuRecovery()
{
ITradeRunMenuRecovery provider;CancellationToken token;long generation;
lock(sync)
{
if(!active||menuRecoveryStopped||state!=RunState.Running||activeMode!=ExecutionMode.NativeBridge||runStop is not {IsCancellationRequested:false}
||activeDriver is not ITradeRunMenuRecovery available||menuRecovery is not null||menuRecoveryStarting)return;
provider=available;token=runStop.Token;menuRecoveryStarting=true;generation=++menuRecoveryGeneration;
}
IDisposable created;
try{created=provider.EnterRunningMenuRecovery(()=>MenuRecoveryAllowed(generation),token);}
catch{Stop();throw;}
var keep=false;
lock(sync)
{
if(MenuRecoveryAllowed(generation)&&menuRecoveryStarting)
{menuRecovery=created;menuRecoveryStarting=false;keep=true;}
}
if(!keep)created.Dispose();
}
private async Task CheckpointAsync(CancellationToken token)
{
Task wait;
lock (sync) wait = resume.Task;
await wait.WaitAsync(token);
token.ThrowIfCancellationRequested();
ValidatePreservedCargo();
if (activeMode != ExecutionMode.Simulation && MustPauseForDanger(player())) { Pause("危险状态触发暂停。"); await CheckpointAsync(token); }
}
private bool MustPauseForDanger(PlayerState value)=>value.InDanger
&& !(activeMode==ExecutionMode.NativeBridge&&activeDriver is ITradeFlightSafetyScope {FlightSafetyActive:true}
&&!NavigationSafety.HasExternalThreat(value)&&!NavigationSafety.UnhealthyHull(value));
private void ValidatePreservedCargo()
{
if(preservedCargo is not {} baseline)return;
var current=player();
if(!current.CargoReliable||current.CommanderId!=baseline.CommanderId||current.CurrentShipId!=baseline.CurrentShipId
||current.LoadGameAt!=baseline.LoadGameAt||current.CargoCapacity!=baseline.CargoCapacity||current.Inventory.Any(item=>item.Count<0)
||current.Inventory.Sum(item=>item.Count)!=current.CargoCount)
throw new InvalidOperationException("现有货物、飞船或会话已改变或无法核实,请重新计算路线。");
foreach(var item in baseline.Inventory.Where(item=>item.Count>0).GroupBy(item=>(item.Key,item.Stolen,item.MissionId)))
if(current.Inventory.Where(value=>value.Key==item.Key.Key&&value.Stolen==item.Key.Stolen&&value.MissionId==item.Key.MissionId).Sum(value=>value.Count)<item.Sum(value=>value.Count))
throw new InvalidOperationException("原有货物已改变;不会将原有、任务或赃物库存计入本次交易。");
}
public async Task RunAsync(RoutePlan plan, ExecutionMode mode, ITradeCommandDriver? driver = null, CancellationToken token = default,TradeAcceptancePolicy? acceptance=null,TradeProfitLedger? accounting=null)
{
lock (sync)
{
if (active) throw new InvalidOperationException("已有执行任务,请先停止。");
active = true;
activeMode = mode;
activeDriver = driver;
menuRecoveryStopped=false;
runStop = CancellationTokenSource.CreateLinkedTokenSource(token);
resume = CompletedGate();
}
using var cancellation = runStop;
var ct = cancellation.Token;
Task? dockedServices=null;
var dockedOperations=new List<Task>();
var serviceFailure=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var confirmedOrders=new HashSet<string>(StringComparer.Ordinal);
var serviceDriver=driver as IConfirmedDockedServiceDriver;
void OnServiceConfirmed(ConfirmedDockedServiceReceipt receipt)
{
if(ledger!.Record(receipt.ToCashEntry()))Report(State,"已计入停靠服务费用。");
}
async Task ObserveServices(Task task)
{
try{await task;}catch(Exception error){serviceFailure.TrySetException(error);}
}
async Task AlongsideServices(Task operation)
{
dockedOperations.Add(operation);
if(await Task.WhenAny(operation,serviceFailure.Task)==serviceFailure.Task)await serviceFailure.Task;
await operation;
}
void StartServices(long marketId)
{
if(mode!=ExecutionMode.NativeBridge||dockedServices is not null)return;
dockedServices=driver!.ServiceDockedAsync(new(marketId,plan.Request.UsableCargoCapacity),CheckpointAsync,ct);
_=ObserveServices(dockedServices);
}
async Task ExecuteTrade(TradeAction action)
{
async Task BeforeInput(CancellationToken t){await CheckpointAsync(t);acceptance?.ValidateAction(action,player());}
if(mode==ExecutionMode.NativeBridge&&driver is IConfirmedTradeCommandDriver original)
{
var requestedAt=DateTimeOffset.UtcNow;
var pending=original.ExecuteTradeAsync(action,BeforeInput,ct);
await AlongsideServices(pending);
var receipt=await pending;
var change=receipt.Validate(action,player(),requestedAt);
if(!confirmedOrders.Add(receipt.OperationKey))throw new InvalidOperationException("检测到重复交易回执,已停止后续操作。");
_=change;
ledger!.RecordTrade(receipt);
Report(State,action.Kind==TradeActionKind.Buy?"买入已确认;货物成本计入在途库存。":"卖出已确认,净利润已更新。");
return;
}
if(mode==ExecutionMode.NativeBridge)await AlongsideServices(driver!.ExecuteAsync(action,BeforeInput,ct));
await AlongsideServices(WaitTradeAsync(action.Kind==TradeActionKind.Buy?"MarketBuy":"MarketSell",
action.Kind==TradeActionKind.Buy?action.Leg.From.MarketId:action.Leg.To.MarketId,action.Order!,ct));
ValidatePreservedCargo();
}
actualProfit = 0; currentLeg = 0; totalLegs = plan.Legs.Count;
while (events.Reader.TryRead(out _)) { }
try
{
accountingError=null;journalRun=Guid.NewGuid().ToString("N");journalServices=false;ledger=null;preservedCargo=null;
if(mode!=ExecutionMode.Simulation)
{
ledger=accounting??new TradeProfitLedger(accountingDirectory is null?null:Path.Combine(accountingDirectory,journalRun));
journalServices=mode==ExecutionMode.JournalGuide||serviceDriver is null;
if(mode==ExecutionMode.NativeBridge&&serviceDriver is not null)serviceDriver.ServiceConfirmed+=OnServiceConfirmed;
}
ValidatePlan(plan);
if(acceptance is not null&&mode!=ExecutionMode.NativeBridge)throw new InvalidOperationException("跑商验收必须使用真实交易驱动,模拟或日志领航不能算通过。");
acceptance?.ValidatePlan(plan,player());
if (mode != ExecutionMode.Simulation)
{
var p = player();
if(NavigationSafety.UnhealthyHull(p))throw new InvalidOperationException("船体已失效,无法开始跑商。");
if (!p.InGame || !p.Docked || p.MarketId != plan.Legs[0].From.MarketId) throw new InvalidOperationException("请在计划的起始站点停靠后启动。");
if(p.CargoCount!=plan.Request.OccupiedCargo)throw new InvalidOperationException("当前已有货物数量与计划不一致,请重新计算。");
if (p.FreeCargo < plan.Request.UsableCargoCapacity) throw new InvalidOperationException("当前可用货舱少于计划值,请重新计算。");
if(plan.Request.OccupiedCargo>0)
{
if(!p.CargoReliable||p.CargoCapacity!=plan.Request.CargoCapacity||string.IsNullOrWhiteSpace(p.CommanderId)||p.CurrentShipId is null||p.LoadGameAt is null
||p.Inventory.Any(item=>item.Count<0)||p.Inventory.Sum(item=>item.Count)!=p.CargoCount)
throw new InvalidOperationException("原有货物清单或飞船身份尚未完整核实,不能执行带货计划。");
if(mode==ExecutionMode.NativeBridge)throw new InvalidOperationException("当前原生交易仍要求空货舱;带货计划可用于日志领航,请先处理原有货物再启动自动执行。");
preservedCargo=p;
}
if (!p.CreditsReliable || p.Credits is null || p.Credits < plan.Request.Credits) throw new InvalidOperationException("现金不足或 Journal 余额不完整,请重新登录游戏刷新并计算。");
foreach(var destination in plan.Legs.SelectMany(leg=>new[]{leg.From,leg.To}))
Planning.ShipPadPolicy.RequireFits(destination,p.Ship,plan.Request.RequiredPad);
if (mode == ExecutionMode.NativeBridge)
{
if (driver == null) throw new InvalidOperationException("未连接已验证的原生命令适配器。");
await driver.PreflightAsync(ct);
}
}
Report(RunState.Running, mode == ExecutionMode.Simulation ? "开始模拟演练 · 不发送游戏输入" : "开始执行 · 每一步均核对实际游戏结果");
foreach (var leg in plan.Legs)
{
currentLeg = leg.Number;
if (mode == ExecutionMode.Simulation)
{
await CheckpointAsync(ct);
Report(RunState.Running, $"模拟:{leg.CargoSummary} · {leg.From.Name} → {leg.To.Name}");
await Task.Delay(800, ct);
actualProfit += leg.NetProfit;
continue;
}
await CheckpointAsync(ct);
Planning.ShipPadPolicy.RequireFits(leg.From,player().Ship,plan.Request.RequiredPad);Planning.ShipPadPolicy.RequireFits(leg.To,player().Ship,plan.Request.RequiredPad);
ValidateMarket(plan, leg);
StartServices(leg.From.MarketId);
for (var orderIndex = 0; orderIndex < leg.Orders.Count; orderIndex++)
{
var order = leg.Orders[orderIndex];
await CheckpointAsync(ct);
ValidateMarket(plan, leg, leg.Orders.Skip(orderIndex).ToArray());
Report(RunState.Running, $"买入 {order.Summary},等待实际交易确认");
var action = new TradeAction(TradeActionKind.Buy, leg, order);
acceptance?.ValidateAction(action,player());
await ExecuteTrade(action);
}
// The two docked work streams run concurrently. Only departure
// joins them; never allow a pending service to outlive this stop.
if(dockedServices is not null){await dockedServices;dockedServices=null;}
await CheckpointAsync(ct);
Report(RunState.Running, $"前往 {leg.To.DisplayName},等待 Docked 确认");
var travel=new TradeAction(TradeActionKind.Travel,leg,null);
acceptance?.ValidateAction(travel,player());
if (mode == ExecutionMode.NativeBridge) await driver!.ExecuteAsync(travel,async t=>{await CheckpointAsync(t);acceptance?.ValidateAction(travel,player());},ct);
await WaitDockedAsync(leg.To.MarketId, ct);
StartServices(leg.To.MarketId);
foreach (var order in leg.Orders)
{
await CheckpointAsync(ct);
ValidateSale(plan, leg, order);
Report(RunState.Running, $"卖出 {order.Summary},等待实际交易确认");
var sale=new TradeAction(TradeActionKind.Sell,leg,order);
acceptance?.ValidateAction(sale,player());
await ExecuteTrade(sale);
}
// Each receipt updates realized profit and the remaining cargo cost.
}
if(dockedServices is not null)await dockedServices;
ValidatePreservedCargo();
Report(RunState.Completed, mode == ExecutionMode.Simulation ? "模拟完成;收益为规划估计值。" : "路线完成;净利润已扣除实际买入成本、燃油、维修与补给费用。");
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { Report(accountingError is null?RunState.Stopped:RunState.Failed, accountingError?.Message??"执行已停止。"); }
catch (Exception e) { Report(RunState.Failed, e.Message); }
finally
{
RevokeMenuRecovery();
cancellation.Cancel();
if(dockedServices is not null)try{await dockedServices;}catch(Exception){/* The run result already reports the original failure. */}
try{await Task.WhenAll(dockedOperations);}catch(Exception){/* Drain cancelled sibling operations before releasing the driver. */}
if(serviceFailure.Task.IsFaulted)_=serviceFailure.Task.Exception;
journalServices=false;
if(serviceDriver is not null)serviceDriver.ServiceConfirmed-=OnServiceConfirmed;
if (driver != null) await driver.ReleaseAllAsync();
lock (sync) { active = false; activeDriver = null; runStop = null; }
if(accounting is null)ledger?.Dispose();
while (events.Reader.TryRead(out _)) { }
}
}
private static void ValidatePlan(RoutePlan plan)
{
plan.Request.Validate();
if (plan.Legs.Count == 0) throw new ArgumentException("计划没有可执行航段。");
if (plan.Legs[0].From.MarketId != plan.Request.StartMarketId) throw new ArgumentException("计划起点不一致。");
decimal cash = plan.Request.Credits;
for (var i = 0; i < plan.Legs.Count; i++)
{
var leg = plan.Legs[i];
if (leg.Number != i + 1 || i > 0 && plan.Legs[i - 1].To.MarketId != leg.From.MarketId || leg.From.MarketId == leg.To.MarketId) throw new ArgumentException("航段次序或站点不连续。");
if (leg.Orders.Any(o => o.Quantity <= 0 || o.BuyPrice <= 0 || o.SellPrice <= o.BuyPrice) || leg.Quantity > plan.Request.UsableCargoCapacity || leg.Orders.Select(o => o.Commodity).Distinct().Count() != leg.Orders.Count) throw new ArgumentException("装载清单无效。");
if (cash - leg.Orders.Sum(o => o.Cost) - leg.OperatingCost < plan.Request.ReserveCredits) throw new ArgumentException("计划会动用保留资金。");
cash += leg.NetProfit;
}
}
private void ValidateMarket(RoutePlan plan, TradeLeg leg, IReadOnlyList<TradeOrder>? pending = null)
{
pending ??= leg.Orders;
var p = player();
if (!p.InGame || !p.Docked || p.MarketId != leg.From.MarketId) throw new InvalidOperationException("当前停靠站点与买入站点不一致,执行已停止。");
foreach (var o in pending)
{
var from = quote(leg.From.MarketId, o.Commodity); var to = quote(leg.To.MarketId, o.Commodity);
CheckQuote(from, plan); CheckQuote(to, plan);
if (from!.BuyPrice > o.BuyPrice || to!.SellPrice < o.SellPrice || from.Supply < o.Quantity || to.Demand < o.Quantity) throw new InvalidOperationException("价格或供需已变化,请重新计算路线。");
}
if (p.Credits is null || !p.CreditsReliable || p.Credits - pending.Sum(x => x.Cost) < plan.Request.ReserveCredits) throw new InvalidOperationException("余额变化或无法核实,请重新计算。");
if (p.FreeCargo < pending.Sum(x => x.Quantity)) throw new InvalidOperationException("剩余可用货舱不足,请重新计算。");
}
private static void CheckQuote(CommodityQuote? q, RoutePlan plan)
{
if (q == null || q.Illegal || q.Source is DataSource.Demonstration or DataSource.Capture || q.CollectedAt < DateTimeOffset.UtcNow.AddMinutes(-plan.Request.MaxMarketAgeMinutes) || q.CollectedAt > DateTimeOffset.UtcNow.AddMinutes(5)) throw new InvalidOperationException("缺少可用的新鲜行情;演示和历史抓包不能用于真实执行。");
}
private void ValidateSale(RoutePlan plan, TradeLeg leg, TradeOrder o)
{
var p = player();
if (!p.Docked || p.MarketId != leg.To.MarketId) throw new InvalidOperationException("当前站点不是计划卖出站点。");
var q = quote(leg.To.MarketId, o.Commodity); CheckQuote(q, plan);
if (q!.SellPrice < o.SellPrice || q.Demand < o.Quantity) throw new InvalidOperationException("到站卖价或需求下降,请停止后重新规划。");
var original=preservedCargo?.Inventory.Where(x=>x.Key==o.Commodity&&x.Stolen==0&&x.MissionId is null).Sum(x=>x.Count)??0;
if (p.Inventory.Where(x => x.Key == o.Commodity && x.Stolen == 0 && x.MissionId == null).Sum(x => x.Count) < o.Quantity+original) throw new InvalidOperationException("本次新增的可交易货物不足;原有、任务货物和赃物不计入。");
}
private async Task<decimal> WaitTradeAsync(string expected, long marketId, TradeOrder order, CancellationToken token)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(token);
timeout.CancelAfter(TimeSpan.FromMinutes(10));
var remaining = order.Quantity; decimal total = 0;
try
{
while (remaining > 0)
{
await CheckpointAsync(token);
var observed = await events.Reader.ReadAsync(timeout.Token);
var e = observed.Data; var name = e.Text("event");
if (name is "Died" or "Shutdown") throw new InvalidOperationException("游戏会话已结束。");
if (name is not ("MarketBuy" or "MarketSell")) continue;
if (name != expected || CommodityCatalog.Key(e.Text("Type")) != order.Commodity || e.Long("MarketID", marketId) != marketId || e.Long("Count") > remaining || e.Long("Count") <= 0) throw new InvalidOperationException("Journal 出现非计划交易,执行已停止。");
var amount = e.Number(expected == "MarketBuy" ? "TotalCost" : "TotalSale");
if (amount is null) throw new InvalidOperationException("交易缺少实际金额,无法确认。");
total += (decimal)amount.Value;
ledger!.Record(new($"journal:{journalRun}:{observed.Sequence}",expected=="MarketBuy"?TradeCashKind.Purchase:TradeCashKind.Sale,
(decimal)amount.Value,marketId,observed.ObservedAt,order.Commodity,checked((int)e.Long("Count"))));
Report(State,expected=="MarketBuy"?"买入已确认;货物成本计入在途库存。":"卖出已确认,净利润已更新。");
var expectedAmount = (decimal)e.Long("Count") * (expected == "MarketBuy" ? order.BuyPrice : order.SellPrice);
if (expected == "MarketBuy" && (decimal)amount.Value > expectedAmount || expected == "MarketSell" && (decimal)amount.Value < expectedAmount) throw new InvalidOperationException("实际成交价劣于计划,已停止后续操作。");
remaining -= (int)e.Long("Count");
}
}
catch (OperationCanceledException) when (!token.IsCancellationRequested) { throw new TimeoutException("等待交易确认超时,执行已停止。"); }
return total;
}
private async Task WaitDockedAsync(long marketId, CancellationToken token)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(token); timeout.CancelAfter(TimeSpan.FromMinutes(45));
try
{
while (true)
{
await CheckpointAsync(token);
var e = (await events.Reader.ReadAsync(timeout.Token)).Data;
if (e.Text("event") is "Died" or "Shutdown") throw new InvalidOperationException("游戏会话已结束。");
if (e.Text("event") == "Docked")
{
if (e.Long("MarketID") != marketId) throw new InvalidOperationException("停靠了计划外站点,执行已停止。");
return;
}
if (e.Text("event") is "MarketBuy" or "MarketSell") throw new InvalidOperationException("航行阶段检测到非计划交易。");
}
}
catch (OperationCanceledException) when (!token.IsCancellationRequested) { throw new TimeoutException("等待目的站停靠超时。"); }
}
}
using System.Threading.Channels;
using XFEEDToolBox.Core.Data;
using XFEEDToolBox.Core.Models;
using XFEEDToolBox.Core.Navigation;
namespace XFEEDToolBox.Core.Execution;
public enum RunState { Idle, Running, Paused, Completed, Stopped, Failed }
public enum TradeActionKind { Buy, Travel, Sell }
public sealed record TradeAction(TradeActionKind Kind, TradeLeg Leg, TradeOrder? Order);
public sealed record DockedServiceRequest(long MarketId,int ReservedCargo);
public sealed record RunProgress(RunState State, string Message, int Leg, int TotalLegs, decimal ActualProfit, DateTimeOffset At)
{
public TradeProfitSnapshot Accounting { get; init; } = TradeProfitSnapshot.Empty;
public bool IsSimulation { get; init; }
}
public interface ITradeCommandDriver
{
Task PreflightAsync(CancellationToken token);
// Confirm refuel/repair/rearm against the current station's available
// services. Cargo consumables must leave ReservedCargo tonnes free.
Task ServiceDockedAsync(DockedServiceRequest request,Func<CancellationToken,Task> beforeInput,CancellationToken token);
Task ExecuteAsync(TradeAction action, Func<CancellationToken, Task> beforeInput, CancellationToken token);
Task ReleaseAllAsync();
}
/// <summary>Only true while a native flight controller is applying its own fresh thermal and threat interlocks.</summary>
public interface ITradeFlightSafetyScope
{
bool FlightSafetyActive { get; }
}
public sealed class TradeRunner
{
private readonly Func<PlayerState> player;
private readonly Func<long, string, CommodityQuote?> quote;
private readonly Channel<ObservedJournalEvent> events = Channel.CreateUnbounded<ObservedJournalEvent>(new() { SingleReader = true });
private readonly object sync = new();
private CancellationTokenSource? runStop;
private TaskCompletionSource resume = CompletedGate();
private RunState state;
private int currentLeg, totalLegs;
private decimal actualProfit;
private TradeProfitLedger? ledger;
private readonly string? accountingDirectory;
private string journalRun = "";
private bool journalServices;
private Exception? accountingError;
private bool active;
private ExecutionMode activeMode;
private PlayerState? preservedCargo;
private ITradeCommandDriver? activeDriver;
private IDisposable? menuRecovery;
private bool menuRecoveryStarting;
private bool menuRecoveryStopped;
private long menuRecoveryGeneration;
public RunState State { get { lock (sync) return state; } }
public event Action<RunProgress>? Progress;
public TradeRunner(Func<PlayerState> player, Func<long, string, CommodityQuote?> quote, string? accountingDirectory = null)
{ this.player = player; this.quote = quote; this.accountingDirectory = accountingDirectory; }
public void Observe(ObservedJournalEvent e)
{
if (!Volatile.Read(ref active)) return;
if (journalServices && ledger is not null)
{
var kind = e.Data.Text("event") switch
{
"RefuelAll" or "RefuelPartial" => TradeCashKind.Fuel,
"Repair" or "RepairAll" => TradeCashKind.Repair,
"BuyAmmo" => TradeCashKind.Rearm,
"BuyDrones" => TradeCashKind.OtherExpense,
_ => (TradeCashKind?)null
};
if (kind is not null)
{
try
{
var amount = e.Data.Number("Cost");
if (amount is null || !double.IsFinite(amount.Value) || amount < 0) throw new InvalidDataException("维护日志缺少实际扣款金额。");
if (amount > 0 && ledger.Record(new($"journal:{journalRun}:{e.Sequence}",kind.Value,(decimal)amount.Value,
e.Data.Long("MarketID",player().MarketId??0),e.ObservedAt))) Report(State,"已计入停靠服务费用。");
}
catch (Exception error) when (error is IOException or InvalidDataException or OverflowException)
{ Interlocked.CompareExchange(ref accountingError,error,null); Stop(); }
}
}
events.Writer.TryWrite(e);
}
public void ObservePlayer(PlayerState p)
{
if (activeMode == ExecutionMode.Simulation) return;
if(NavigationSafety.UnhealthyHull(p)){Stop();return;}
if (State == RunState.Running && MustPauseForDanger(p)) Pause("检测到战斗、拦截或未接管的危险状态,执行已暂停。");
if (State is RunState.Running or RunState.Paused && !p.InGame) Stop();
}
private static TaskCompletionSource CompletedGate() { var t = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); t.SetResult(); return t; }
private void Report(RunState next, string message)
{
// A delayed status/receipt must not undo a user's Pause. Resume is
// the only path that deliberately changes Paused back to Running.
lock (sync){if(next==RunState.Running&&state==RunState.Paused)next=RunState.Paused;state=next;}
if(next==RunState.Running)EnsureMenuRecovery();else RevokeMenuRecovery();
lock(sync)next=state;
var accounting = ledger?.Snapshot ?? TradeProfitSnapshot.Empty;
Progress?.Invoke(new(next, message, currentLeg, totalLegs, activeMode == ExecutionMode.Simulation ? actualProfit : accounting.NetProfit, DateTimeOffset.UtcNow)
{ Accounting = accounting, IsSimulation = activeMode == ExecutionMode.Simulation });
}
public void Pause(string reason = "用户暂停")
{
lock (sync)
{
if (state != RunState.Running) return;
resume = new(TaskCreationOptions.RunContinuationsAsynchronously);
state = RunState.Paused;
}
RevokeMenuRecovery();
Report(RunState.Paused, reason);
}
public void Resume()
{
lock (sync)
{
if (state != RunState.Paused) return;
if (MustPauseForDanger(player())) throw new InvalidOperationException("危险状态尚未解除。");
state = RunState.Running;
resume.TrySetResult();
}
Report(RunState.Running, "继续执行;下一次操作前重新检查。");
}
public void Stop()
{
lock(sync)menuRecoveryStopped=true;
RevokeMenuRecovery();
lock (sync) { runStop?.Cancel(); resume.TrySetResult(); }
}
private bool MenuRecoveryAllowed(long generation)
{
lock(sync)return generation==menuRecoveryGeneration&&!menuRecoveryStopped&&active&&state==RunState.Running
&&activeMode==ExecutionMode.NativeBridge&&runStop is {IsCancellationRequested:false};
}
private void RevokeMenuRecovery()
{
IDisposable? previous;
lock(sync){menuRecoveryGeneration++;menuRecoveryStarting=false;previous=menuRecovery;menuRecovery=null;}
previous?.Dispose();
}
private void EnsureMenuRecovery()
{
ITradeRunMenuRecovery provider;CancellationToken token;long generation;
lock(sync)
{
if(!active||menuRecoveryStopped||state!=RunState.Running||activeMode!=ExecutionMode.NativeBridge||runStop is not {IsCancellationRequested:false}
||activeDriver is not ITradeRunMenuRecovery available||menuRecovery is not null||menuRecoveryStarting)return;
provider=available;token=runStop.Token;menuRecoveryStarting=true;generation=++menuRecoveryGeneration;
}
IDisposable created;
try{created=provider.EnterRunningMenuRecovery(()=>MenuRecoveryAllowed(generation),token);}
catch{Stop();throw;}
var keep=false;
lock(sync)
{
if(MenuRecoveryAllowed(generation)&&menuRecoveryStarting)
{menuRecovery=created;menuRecoveryStarting=false;keep=true;}
}
if(!keep)created.Dispose();
}
private async Task CheckpointAsync(CancellationToken token)
{
Task wait;
lock (sync) wait = resume.Task;
await wait.WaitAsync(token);
token.ThrowIfCancellationRequested();
ValidatePreservedCargo();
if (activeMode != ExecutionMode.Simulation && MustPauseForDanger(player())) { Pause("危险状态触发暂停。"); await CheckpointAsync(token); }
}
private bool MustPauseForDanger(PlayerState value)=>value.InDanger
&& !(activeMode==ExecutionMode.NativeBridge&&activeDriver is ITradeFlightSafetyScope {FlightSafetyActive:true}
&&!NavigationSafety.HasExternalThreat(value)&&!NavigationSafety.UnhealthyHull(value));
private void ValidatePreservedCargo()
{
if(preservedCargo is not {} baseline)return;
var current=player();
if(!current.CargoReliable||current.CommanderId!=baseline.CommanderId||current.CurrentShipId!=baseline.CurrentShipId
||current.LoadGameAt!=baseline.LoadGameAt||current.CargoCapacity!=baseline.CargoCapacity||current.Inventory.Any(item=>item.Count<0)
||current.Inventory.Sum(item=>item.Count)!=current.CargoCount)
throw new InvalidOperationException("现有货物、飞船或会话已改变或无法核实,请重新计算路线。");
foreach(var item in baseline.Inventory.Where(item=>item.Count>0).GroupBy(item=>(item.Key,item.Stolen,item.MissionId)))
if(current.Inventory.Where(value=>value.Key==item.Key.Key&&value.Stolen==item.Key.Stolen&&value.MissionId==item.Key.MissionId).Sum(value=>value.Count)<item.Sum(value=>value.Count))
throw new InvalidOperationException("原有货物已改变;不会将原有、任务或赃物库存计入本次交易。");
}
public async Task RunAsync(RoutePlan plan, ExecutionMode mode, ITradeCommandDriver? driver = null, CancellationToken token = default,TradeAcceptancePolicy? acceptance=null,TradeProfitLedger? accounting=null)
{
lock (sync)
{
if (active) throw new InvalidOperationException("已有执行任务,请先停止。");
active = true;
activeMode = mode;
activeDriver = driver;
menuRecoveryStopped=false;
runStop = CancellationTokenSource.CreateLinkedTokenSource(token);
resume = CompletedGate();
}
using var cancellation = runStop;
var ct = cancellation.Token;
Task? dockedServices=null;
var dockedOperations=new List<Task>();
var serviceFailure=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var confirmedOrders=new HashSet<string>(StringComparer.Ordinal);
var serviceDriver=driver as IConfirmedDockedServiceDriver;
void OnServiceConfirmed(ConfirmedDockedServiceReceipt receipt)
{
if(ledger!.Record(receipt.ToCashEntry()))Report(State,"已计入停靠服务费用。");
}
async Task ObserveServices(Task task)
{
try{await task;}catch(Exception error){serviceFailure.TrySetException(error);}
}
async Task AlongsideServices(Task operation)
{
dockedOperations.Add(operation);
if(await Task.WhenAny(operation,serviceFailure.Task)==serviceFailure.Task)await serviceFailure.Task;
await operation;
}
void StartServices(long marketId)
{
if(mode!=ExecutionMode.NativeBridge||dockedServices is not null)return;
dockedServices=driver!.ServiceDockedAsync(new(marketId,plan.Request.UsableCargoCapacity),CheckpointAsync,ct);
_=ObserveServices(dockedServices);
}
async Task ExecuteTrade(TradeAction action)
{
async Task BeforeInput(CancellationToken t){await CheckpointAsync(t);acceptance?.ValidateAction(action,player());}
if(mode==ExecutionMode.NativeBridge&&driver is IConfirmedTradeCommandDriver original)
{
var requestedAt=DateTimeOffset.UtcNow;
var pending=original.ExecuteTradeAsync(action,BeforeInput,ct);
await AlongsideServices(pending);
var receipt=await pending;
var change=receipt.Validate(action,player(),requestedAt);
if(!confirmedOrders.Add(receipt.OperationKey))throw new InvalidOperationException("检测到重复交易回执,已停止后续操作。");
_=change;
ledger!.RecordTrade(receipt);
Report(State,action.Kind==TradeActionKind.Buy?"买入已确认;货物成本计入在途库存。":"卖出已确认,净利润已更新。");
return;
}
if(mode==ExecutionMode.NativeBridge)await AlongsideServices(driver!.ExecuteAsync(action,BeforeInput,ct));
await AlongsideServices(WaitTradeAsync(action.Kind==TradeActionKind.Buy?"MarketBuy":"MarketSell",
action.Kind==TradeActionKind.Buy?action.Leg.From.MarketId:action.Leg.To.MarketId,action.Order!,ct));
ValidatePreservedCargo();
}
actualProfit = 0; currentLeg = 0; totalLegs = plan.Legs.Count;
while (events.Reader.TryRead(out _)) { }
try
{
accountingError=null;journalRun=Guid.NewGuid().ToString("N");journalServices=false;ledger=null;preservedCargo=null;
if(mode!=ExecutionMode.Simulation)
{
ledger=accounting??new TradeProfitLedger(accountingDirectory is null?null:Path.Combine(accountingDirectory,journalRun));
journalServices=mode==ExecutionMode.JournalGuide||serviceDriver is null;
if(mode==ExecutionMode.NativeBridge&&serviceDriver is not null)serviceDriver.ServiceConfirmed+=OnServiceConfirmed;
}
ValidatePlan(plan);
if(acceptance is not null&&mode!=ExecutionMode.NativeBridge)throw new InvalidOperationException("跑商验收必须使用真实交易驱动,模拟或日志领航不能算通过。");
acceptance?.ValidatePlan(plan,player());
if (mode != ExecutionMode.Simulation)
{
var p = player();
if(NavigationSafety.UnhealthyHull(p))throw new InvalidOperationException("船体已失效,无法开始跑商。");
if (!p.InGame || !p.Docked || p.MarketId != plan.Legs[0].From.MarketId) throw new InvalidOperationException("请在计划的起始站点停靠后启动。");
if(p.CargoCount!=plan.Request.OccupiedCargo)throw new InvalidOperationException("当前已有货物数量与计划不一致,请重新计算。");
if (p.FreeCargo < plan.Request.UsableCargoCapacity) throw new InvalidOperationException("当前可用货舱少于计划值,请重新计算。");
if(plan.Request.OccupiedCargo>0)
{
if(!p.CargoReliable||p.CargoCapacity!=plan.Request.CargoCapacity||string.IsNullOrWhiteSpace(p.CommanderId)||p.CurrentShipId is null||p.LoadGameAt is null
||p.Inventory.Any(item=>item.Count<0)||p.Inventory.Sum(item=>item.Count)!=p.CargoCount)
throw new InvalidOperationException("原有货物清单或飞船身份尚未完整核实,不能执行带货计划。");
if(mode==ExecutionMode.NativeBridge)throw new InvalidOperationException("当前原生交易仍要求空货舱;带货计划可用于日志领航,请先处理原有货物再启动自动执行。");
preservedCargo=p;
}
if (!p.CreditsReliable || p.Credits is null || p.Credits < plan.Request.Credits) throw new InvalidOperationException("现金不足或 Journal 余额不完整,请重新登录游戏刷新并计算。");
foreach(var destination in plan.Legs.SelectMany(leg=>new[]{leg.From,leg.To}))
Planning.ShipPadPolicy.RequireFits(destination,p.Ship,plan.Request.RequiredPad);
if (mode == ExecutionMode.NativeBridge)
{
if (driver == null) throw new InvalidOperationException("未连接已验证的原生命令适配器。");
await driver.PreflightAsync(ct);
}
}
Report(RunState.Running, mode == ExecutionMode.Simulation ? "开始模拟演练 · 不发送游戏输入" : "开始执行 · 每一步均核对实际游戏结果");
foreach (var leg in plan.Legs)
{
currentLeg = leg.Number;
if (mode == ExecutionMode.Simulation)
{
await CheckpointAsync(ct);
Report(RunState.Running, $"模拟:{leg.CargoSummary} · {leg.From.Name} → {leg.To.Name}");
await Task.Delay(800, ct);
actualProfit += leg.NetProfit;
continue;
}
await CheckpointAsync(ct);
Planning.ShipPadPolicy.RequireFits(leg.From,player().Ship,plan.Request.RequiredPad);Planning.ShipPadPolicy.RequireFits(leg.To,player().Ship,plan.Request.RequiredPad);
ValidateMarket(plan, leg);
StartServices(leg.From.MarketId);
for (var orderIndex = 0; orderIndex < leg.Orders.Count; orderIndex++)
{
var order = leg.Orders[orderIndex];
await CheckpointAsync(ct);
ValidateMarket(plan, leg, leg.Orders.Skip(orderIndex).ToArray());
Report(RunState.Running, $"买入 {order.Summary},等待实际交易确认");
var action = new TradeAction(TradeActionKind.Buy, leg, order);
acceptance?.ValidateAction(action,player());
await ExecuteTrade(action);
}
// The two docked work streams run concurrently. Only departure
// joins them; never allow a pending service to outlive this stop.
if(dockedServices is not null){await dockedServices;dockedServices=null;}
await CheckpointAsync(ct);
Report(RunState.Running, $"前往 {leg.To.DisplayName},等待 Docked 确认");
var travel=new TradeAction(TradeActionKind.Travel,leg,null);
acceptance?.ValidateAction(travel,player());
if (mode == ExecutionMode.NativeBridge) await driver!.ExecuteAsync(travel,async t=>{await CheckpointAsync(t);acceptance?.ValidateAction(travel,player());},ct);
await WaitDockedAsync(leg.To.MarketId, ct);
StartServices(leg.To.MarketId);
foreach (var order in leg.Orders)
{
await CheckpointAsync(ct);
ValidateSale(plan, leg, order);
Report(RunState.Running, $"卖出 {order.Summary},等待实际交易确认");
var sale=new TradeAction(TradeActionKind.Sell,leg,order);
acceptance?.ValidateAction(sale,player());
await ExecuteTrade(sale);
}
// Each receipt updates realized profit and the remaining cargo cost.
}
if(dockedServices is not null)await dockedServices;
ValidatePreservedCargo();
Report(RunState.Completed, mode == ExecutionMode.Simulation ? "模拟完成;收益为规划估计值。" : "路线完成;净利润已扣除实际买入成本、燃油、维修与补给费用。");
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { Report(accountingError is null?RunState.Stopped:RunState.Failed, accountingError?.Message??"执行已停止。"); }
catch (Exception e) { Report(RunState.Failed, e.Message); }
finally
{
RevokeMenuRecovery();
cancellation.Cancel();
if(dockedServices is not null)try{await dockedServices;}catch(Exception){/* The run result already reports the original failure. */}
try{await Task.WhenAll(dockedOperations);}catch(Exception){/* Drain cancelled sibling operations before releasing the driver. */}
if(serviceFailure.Task.IsFaulted)_=serviceFailure.Task.Exception;
journalServices=false;
if(serviceDriver is not null)serviceDriver.ServiceConfirmed-=OnServiceConfirmed;
if (driver != null) await driver.ReleaseAllAsync();
lock (sync) { active = false; activeDriver = null; runStop = null; }
if(accounting is null)ledger?.Dispose();
while (events.Reader.TryRead(out _)) { }
}
}
private static void ValidatePlan(RoutePlan plan)
{
plan.Request.Validate();
if (plan.Legs.Count == 0) throw new ArgumentException("计划没有可执行航段。");
if (plan.Legs[0].From.MarketId != plan.Request.StartMarketId) throw new ArgumentException("计划起点不一致。");
decimal cash = plan.Request.Credits;
for (var i = 0; i < plan.Legs.Count; i++)
{
var leg = plan.Legs[i];
if (leg.Number != i + 1 || i > 0 && plan.Legs[i - 1].To.MarketId != leg.From.MarketId || leg.From.MarketId == leg.To.MarketId) throw new ArgumentException("航段次序或站点不连续。");
if (leg.Orders.Any(o => o.Quantity <= 0 || o.BuyPrice <= 0 || o.SellPrice <= o.BuyPrice) || leg.Quantity > plan.Request.UsableCargoCapacity || leg.Orders.Select(o => o.Commodity).Distinct().Count() != leg.Orders.Count) throw new ArgumentException("装载清单无效。");
if (cash - leg.Orders.Sum(o => o.Cost) - leg.OperatingCost < plan.Request.ReserveCredits) throw new ArgumentException("计划会动用保留资金。");
cash += leg.NetProfit;
}
}
private void ValidateMarket(RoutePlan plan, TradeLeg leg, IReadOnlyList<TradeOrder>? pending = null)
{
pending ??= leg.Orders;
var p = player();
if (!p.InGame || !p.Docked || p.MarketId != leg.From.MarketId) throw new InvalidOperationException("当前停靠站点与买入站点不一致,执行已停止。");
foreach (var o in pending)
{
var from = quote(leg.From.MarketId, o.Commodity); var to = quote(leg.To.MarketId, o.Commodity);
CheckQuote(from, plan); CheckQuote(to, plan);
if (from!.BuyPrice > o.BuyPrice || to!.SellPrice < o.SellPrice || from.Supply < o.Quantity || to.Demand < o.Quantity) throw new InvalidOperationException("价格或供需已变化,请重新计算路线。");
}
if (p.Credits is null || !p.CreditsReliable || p.Credits - pending.Sum(x => x.Cost) < plan.Request.ReserveCredits) throw new InvalidOperationException("余额变化或无法核实,请重新计算。");
if (p.FreeCargo < pending.Sum(x => x.Quantity)) throw new InvalidOperationException("剩余可用货舱不足,请重新计算。");
}
private static void CheckQuote(CommodityQuote? q, RoutePlan plan)
{
if (q == null || q.Illegal || q.Source is DataSource.Demonstration or DataSource.Capture || q.CollectedAt < DateTimeOffset.UtcNow.AddMinutes(-plan.Request.MaxMarketAgeMinutes) || q.CollectedAt > DateTimeOffset.UtcNow.AddMinutes(5)) throw new InvalidOperationException("缺少可用的新鲜行情;演示和历史抓包不能用于真实执行。");
}
private void ValidateSale(RoutePlan plan, TradeLeg leg, TradeOrder o)
{
var p = player();
if (!p.Docked || p.MarketId != leg.To.MarketId) throw new InvalidOperationException("当前站点不是计划卖出站点。");
var q = quote(leg.To.MarketId, o.Commodity); CheckQuote(q, plan);
if (q!.SellPrice < o.SellPrice || q.Demand < o.Quantity) throw new InvalidOperationException("到站卖价或需求下降,请停止后重新规划。");
var original=preservedCargo?.Inventory.Where(x=>x.Key==o.Commodity&&x.Stolen==0&&x.MissionId is null).Sum(x=>x.Count)??0;
if (p.Inventory.Where(x => x.Key == o.Commodity && x.Stolen == 0 && x.MissionId == null).Sum(x => x.Count) < o.Quantity+original) throw new InvalidOperationException("本次新增的可交易货物不足;原有、任务货物和赃物不计入。");
}
private async Task<decimal> WaitTradeAsync(string expected, long marketId, TradeOrder order, CancellationToken token)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(token);
timeout.CancelAfter(TimeSpan.FromMinutes(10));
var remaining = order.Quantity; decimal total = 0;
try
{
while (remaining > 0)
{
await CheckpointAsync(token);
var observed = await events.Reader.ReadAsync(timeout.Token);
var e = observed.Data; var name = e.Text("event");
if (name is "Died" or "Shutdown") throw new InvalidOperationException("游戏会话已结束。");
if (name is not ("MarketBuy" or "MarketSell")) continue;
if (name != expected || CommodityCatalog.Key(e.Text("Type")) != order.Commodity || e.Long("MarketID", marketId) != marketId || e.Long("Count") > remaining || e.Long("Count") <= 0) throw new InvalidOperationException("Journal 出现非计划交易,执行已停止。");
var amount = e.Number(expected == "MarketBuy" ? "TotalCost" : "TotalSale");
if (amount is null) throw new InvalidOperationException("交易缺少实际金额,无法确认。");
total += (decimal)amount.Value;
ledger!.Record(new($"journal:{journalRun}:{observed.Sequence}",expected=="MarketBuy"?TradeCashKind.Purchase:TradeCashKind.Sale,
(decimal)amount.Value,marketId,observed.ObservedAt,order.Commodity,checked((int)e.Long("Count"))));
Report(State,expected=="MarketBuy"?"买入已确认;货物成本计入在途库存。":"卖出已确认,净利润已更新。");
var expectedAmount = (decimal)e.Long("Count") * (expected == "MarketBuy" ? order.BuyPrice : order.SellPrice);
if (expected == "MarketBuy" && (decimal)amount.Value > expectedAmount || expected == "MarketSell" && (decimal)amount.Value < expectedAmount) throw new InvalidOperationException("实际成交价劣于计划,已停止后续操作。");
remaining -= (int)e.Long("Count");
}
}
catch (OperationCanceledException) when (!token.IsCancellationRequested) { throw new TimeoutException("等待交易确认超时,执行已停止。"); }
return total;
}
private async Task WaitDockedAsync(long marketId, CancellationToken token)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(token); timeout.CancelAfter(TimeSpan.FromMinutes(45));
try
{
while (true)
{
await CheckpointAsync(token);
var e = (await events.Reader.ReadAsync(timeout.Token)).Data;
if (e.Text("event") is "Died" or "Shutdown") throw new InvalidOperationException("游戏会话已结束。");
if (e.Text("event") == "Docked")
{
if (e.Long("MarketID") != marketId) throw new InvalidOperationException("停靠了计划外站点,执行已停止。");
return;
}
if (e.Text("event") is "MarketBuy" or "MarketSell") throw new InvalidOperationException("航行阶段检测到非计划交易。");
}
}
catch (OperationCanceledException) when (!token.IsCancellationRequested) { throw new TimeoutException("等待目的站停靠超时。"); }
}
}