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 beforeInput,CancellationToken token); Task ExecuteAsync(TradeAction action, Func beforeInput, CancellationToken token); Task ReleaseAllAsync(); } /// Only true while a native flight controller is applying its own fresh thermal and threat interlocks. public interface ITradeFlightSafetyScope { bool FlightSafetyActive { get; } } public sealed class TradeRunner { private readonly Func player; private readonly Func quote; private readonly Channel events = Channel.CreateUnbounded(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? Progress; public TradeRunner(Func player, Func 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)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(); var serviceFailure=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var confirmedOrders=new HashSet(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? 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 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("等待目的站停靠超时。"); } } }