using XFEEDToolBox.Core.Models; using XFEEDToolBox.Core.Execution; namespace XFEEDToolBox.Core.Planning; public sealed partial class TradeRoutePlanner { public TradePlanningResult CalculateCandidatesWithDiagnostics(MarketBook book,PlannerRequest request,int maxCandidates=8,CancellationToken token=default,IProgress? progress=null,IReadOnlyList? communityGoals=null) { var scope=request.CommunityGoalSupplyOnly?CommunityGoalSupplyScope.Create(book,communityGoals,request.AsOf,request.CommunityGoalCommodityFilter):null; if(request.CommunityGoalSupplyOnly&&scope?.Available!=true) { request.Validate();RequireKnownShip(request);token.ThrowIfCancellationRequested(); progress?.Report(new("CG 目录未确认",0,0,scope?.Issue??"未确认有效 CG,未回退普通跑商。")); return new([],new("cg-unavailable",scope?.Issue??"未确认有效 CG,未回退普通跑商。",book.Stations.Count,0,0,0,0,0,0,book.Truncated)); } var search=CalculateAllCandidates(book,request,maxCandidates,token,progress,scope);var candidates=search.Candidates; var start=book.Stations.FirstOrDefault(station=>station.MarketId==request.StartMarketId); var center=request.SearchCenterPosition??request.CurrentPosition??start?.Position??throw new ArgumentException("搜索中心与真实当前位置均未知。"); var compatible=book.Stations.Where(station=>station.Position is{IsValid:true}&&station.Position.DistanceTo(center)<=request.SearchRadiusLy&&IsStationCompatible(station,request,false)).Select(station=>station.MarketId).ToHashSet(); var fresh=book.Quotes.Where(quote=>compatible.Contains(quote.MarketId)&"e.CollectedAt>=request.AsOf.AddMinutes(-request.MaxMarketAgeMinutes) &"e.CollectedAt<=request.AsOf.AddMinutes(5)&&!quote.Illegal&&!quote.Rare).ToArray(); var freshStations=fresh.Select(quote=>quote.MarketId).Distinct().Count(); var sourceIds=book.Stations.Where(station=>compatible.Contains(station.MarketId)&&TradeSearchConstraints.SourceAllows(station,request)&&StationSourcePolicy.Allows(station,request.AllowedSourceKinds)).Select(station=>station.MarketId).ToHashSet(); var buyStations=fresh.Where(quote=>sourceIds.Contains(quote.MarketId)&"e.BuyPrice>0&"e.Supply>0).Select(quote=>quote.MarketId).Distinct().Count(); var sellStations=fresh.Where(quote=>quote.SellPrice>0&"e.Demand>0&&(scope is null||scope.AllowsSale(quote))).Select(quote=>quote.MarketId).Distinct().Count(); var originBuys=fresh.Count(quote=>quote.MarketId==request.StartMarketId&"e.BuyPrice>0&"e.Supply>0); var limited=book.Truncated||search.SearchPruned; var code=candidates.Count>0?"found":compatible.Count<2&&book.Stations.Count>1?"station-constraints" :fresh.Length==0?book.Quotes.Count>0?"expired-or-restricted-quotes":"no-fresh-quotes" :freshStations<2?"insufficient-fresh-markets":buyStations==0||sellStations==0?"missing-supply-demand":limited?"bounded-search":"no-candidate"; var message=candidates.Count>0?$"已找到 {candidates.Count} 个方案;使用 {freshStations} 个站点的新鲜行情。" :code=="station-constraints"?$"站点筛选后仅剩 {compatible.Count} 个合规站点;请检查大型停机坪、地面/航母及元数据条件。" :fresh.Length==0?"时效与合法货物筛选后暂无可用行情,请补全周边数据;这不表示该范围没有利润。" :freshStations<2?$"当前范围仅有 {freshStations} 个合规站点的新鲜行情,暂不足以比较跨站利润;请补全周边行情。" :buyStations==0||sellStations==0?"所选货源类型与新鲜行情中缺少有库存的买入报价或有需求的卖出报价,暂不足以生成交易方案。" :limited?$"本次有界搜索尚未找到方案;已使用 {freshStations} 个站点的新鲜行情,范围或计算预算有截断。" :$"已比较 {freshStations} 个站点的新鲜行情,当前航段、{(request.Loop?"返程环线和":"")}供需约束下的有界搜索暂未找到方案。"; if(candidates.Count==0&&freshStations>=2&&originBuys==0)message+=" 起点没有新鲜可买库存,需先空船转场。"; if(request.CommunityGoalSupplyOnly) { code=candidates.Count>0?"cg-found":scope?.Available!=true?"cg-unavailable":"cg-no-profitable-route"; message=candidates.Count>0?$"已找到 {candidates.Count} 个有效 CG 供货方案,按预计净利润 / 小时排序;额外 CG 奖励未计入。" :scope?.Issue??$"已比较范围内 {compatible.Count} 个合规站点,尚无满足活动接收商品、供需、资金与飞船约束的盈利 CG 供货路线。未回退普通跑商。"; } return new(candidates,new(code,message,book.Stations.Count,compatible.Count,freshStations,fresh.Length, buyStations,sellStations,originBuys,book.Truncated){SearchPruned=limited}); } internal sealed record Travel(double Distance, int Jumps, double Minutes, decimal Cost); private sealed record Edge(Station From, Station To, Travel Travel, double Risk, CargoCandidate[] Candidates); private sealed record SearchState(Station At, decimal Cash, List Legs, Dictionary<(long, string, bool), int> Used, Travel Deadhead) { public double Minutes => Legs.Sum(x => x.EstimatedMinutes) + Deadhead.Minutes; public decimal Profit => Legs.Sum(x => x.NetProfit) - Deadhead.Cost; } /// Legacy single-route API, retaining the explicitly requested objective. public RoutePlan Calculate(MarketBook book, PlannerRequest request, CancellationToken token = default,IReadOnlyList? communityGoals=null) { if(!request.CommunityGoalSupplyOnly&&request.StartMarketId>0&&request.SourceMarketIdFilter is null&&request.DestinationMarketIdFilter is null &&string.IsNullOrWhiteSpace(request.SourceSystemFilter)&&string.IsNullOrWhiteSpace(request.SourceStationFilter) &&string.IsNullOrWhiteSpace(request.DestinationSystemFilter)&&string.IsNullOrWhiteSpace(request.DestinationStationFilter))return CalculateInternal(book,request,1,false,token)[0]; var result=CalculateCandidatesWithDiagnostics(book,request,1,token,communityGoals:communityGoals); return result.Candidates.FirstOrDefault()??new(){Request=request,Warnings=[result.Diagnostics.Message]}; } /// Distinct routes ranked by estimated net credits per hour, including travel to their origin. private IReadOnlyList CalculateCandidatesAtSelectedStart(MarketBook book, PlannerRequest request, int maxCandidates = 8, CancellationToken token = default) { if (maxCandidates is < 1 or > 8) throw new ArgumentOutOfRangeException(nameof(maxCandidates), "候选路线数量须在 1–8 之间。"); request = request with { Objective = RouteObjective.CreditsPerHour }; var primary = CalculateInternal(book, request, maxCandidates, true, token).Where(plan => plan.Legs.Count > 0).ToArray(); if (primary.Length == 0 || primary.Length >= maxCandidates) return primary; var found = new Dictionary(StringComparer.Ordinal); foreach (var plan in primary) found.TryAdd(CandidateKey(plan.Legs), plan); var exclusions = new Queue(); var queued = new HashSet(StringComparer.Ordinal); void QueueAlternatives(IReadOnlyList plans, IReadOnlyList inherited) { // Exclude real, economically dominant orders, never split a valid // load into cosmetic quantity variants just to fill the list. if (plans.Count == 0) return; foreach (var commodity in plans[0].Legs.SelectMany(leg => leg.Orders).GroupBy(order => order.Commodity) .OrderByDescending(group => group.Sum(order => order.Profit)).ThenBy(group => group.Key, StringComparer.Ordinal).Take(3)) { var excluded = inherited.Append(commodity.Key).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToArray(); if (queued.Add(string.Join("\0", excluded))) exclusions.Enqueue(excluded); } } QueueAlternatives(primary, []); // At most three additional searches, each with the original beam, // station and cargo-node budgets. All quote and ship constraints remain. for (var pass = 0; pass < 3 && exclusions.Count > 0 && found.Count < maxCandidates; pass++) { token.ThrowIfCancellationRequested(); var excluded = exclusions.Dequeue(); var alternateBook = book with { Quotes = book.Quotes.Where(quote => !excluded.Contains(quote.Commodity, StringComparer.Ordinal)).ToArray() }; var alternate = CalculateInternal(alternateBook, request, maxCandidates, true, token).Where(plan => plan.Legs.Count > 0).ToArray(); foreach (var plan in alternate) { var alternative = plan with { IsCargoAlternative = true, ExcludedCommodities = excluded, SearchPruned = true, Warnings = [.. plan.Warnings, $"货物备选方案:本次有界搜索排除了 {string.Join("、", excluded)};未按任意数量拆分装载,不保证所有替代组合全局最优。"] }; found.TryAdd(CandidateKey(plan.Legs), alternative); } QueueAlternatives(alternate, excluded); } return found.Values.OrderByDescending(plan => plan.CreditsPerHour).ThenByDescending(plan => plan.NetProfit).ThenBy(plan => plan.Minutes) .ThenBy(plan => plan.IsCargoAlternative).ThenBy(plan => CandidateKey(plan.Legs), StringComparer.Ordinal).Take(maxCandidates).ToArray(); } private static IReadOnlyList CalculateInternal(MarketBook book, PlannerRequest request, int maxCandidates, bool rankedCandidates, CancellationToken token,PreparedNetwork? prepared=null) { request.Validate(); token.ThrowIfCancellationRequested(); var start = book.Stations.FirstOrDefault(x => x.MarketId == request.StartMarketId) ?? throw new ArgumentException("起始站点不在当前数据中。"); if (start.Position is not { IsValid: true }) throw new ArgumentException("起始星系缺少坐标,无法估算航程。"); var center=request.SearchCenterPosition??start.Position; if(start.Position.DistanceTo(center)>request.SearchRadiusLy)throw new ArgumentException("起始站点位于所选中心的搜索范围之外,请选择范围内的真实站点。"); var eligible = prepared?.Stations??book.Stations.Where(s => s.Position is { IsValid: true } && s.Position.DistanceTo(center) <= request.SearchRadiusLy && IsStationCompatible(s, request)) .OrderBy(s => s.MarketId == start.MarketId ? 0 : 1).ThenBy(s => s.Position!.DistanceTo(center)).ThenBy(s => s.MarketId).ToArray(); if (!eligible.Any(s => s.MarketId == start.MarketId)) throw new ArgumentException("起始站点不符合停机坪、安保或元数据筛选。可调整筛选或补全数据。"); var stations = eligible; var stationTruncated = book.Truncated || eligible.Length > stations.Length; var searchPruned = stationTruncated; var fresh = prepared?.Fresh??book.Quotes.Where(q => q.CollectedAt <= request.AsOf.AddMinutes(5) && q.CollectedAt >= request.AsOf.AddMinutes(-request.MaxMarketAgeMinutes) && !q.Illegal && !q.Rare) .GroupBy(q => q.MarketId).ToDictionary(g => g.Key, g => g.GroupBy(q => q.Commodity).ToDictionary(q => q.Key, q => q.OrderByDescending(x => x.CollectedAt).ThenBy(x => x.BuyPrice).ThenByDescending(x => x.SellPrice).First())); var deadhead = request.CurrentPosition is { } current && request.CurrentMarketId != start.MarketId ? EstimateTravel(current, request.CurrentSystemName, start, request) : new Travel(0, 0, 0, 0); var edges = prepared?.Edges??new Dictionary>(); if(prepared is null)foreach (var from in stations) { token.ThrowIfCancellationRequested(); var choices = new List(); fresh.TryGetValue(from.MarketId, out var sells); foreach (var to in stations) { if (to.MarketId == from.MarketId) continue; var travel = EstimateTravel(from.Position!, from.SystemName, to, request); if (travel.Distance > request.MaxLegDistanceLy) continue; var risk = Math.Max(SecurityRisk(from.Security), SecurityRisk(to.Security)); var candidates = new List(); if (sells != null && fresh.TryGetValue(to.MarketId, out var buys)) { foreach (var q in sells.Values) { if (q.BuyPrice <= 0 || q.Supply <= 0 || !buys.TryGetValue(q.Commodity, out var dest) || dest.Demand <= 0 || dest.SellPrice - q.BuyPrice < request.MinimumProfitPerTon) continue; var limit = (int)Math.Min(request.UsableCargoCapacity, Math.Min(q.Supply, dest.Demand)); if (limit > 0) candidates.Add(new(q.Commodity, q.DisplayName, q.BuyPrice, dest.SellPrice, limit)); } } choices.Add(new(from, to, travel, risk, candidates.ToArray())); } edges[from.MarketId] = choices; } var frontier = new List { new(start, request.Credits - deadhead.Cost, [], [], deadhead) }; var completed = new Dictionary(StringComparer.Ordinal); var explored = 0; var cargoTruncated = false; for (var depth = 0; depth < request.MaxLegs; depth++) { var next = new List(); foreach (var state in frontier) { token.ThrowIfCancellationRequested(); // An optimistic branching heuristic, not a proof that omitted routes are inferior. var ordered = edges[state.At.MarketId].OrderByDescending(e => e.Candidates.Select(c => (decimal)c.ProfitPerTon * c.Limit).DefaultIfEmpty(0).Sum() / (decimal)e.Travel.Minutes).ThenBy(e => e.To.MarketId); var candidates=(prepared is null?ordered.Take(16):ordered).ToList(); var home = edges[state.At.MarketId].FirstOrDefault(e => e.To.MarketId == start.MarketId); if(prepared is not null&&request.Loop&&home is null&&state.At.MarketId!=start.MarketId&&state.At.Position!.DistanceTo(start.Position)<=request.MaxLegDistanceLy) home=new(state.At,start,EstimateTravel(state.At.Position!,state.At.SystemName,start,request),Math.Max(SecurityRisk(state.At.Security),SecurityRisk(start.Security)),[]); if (home != null && !candidates.Contains(home)) candidates.Add(home); searchPruned |= candidates.Count < edges[state.At.MarketId].Count; foreach (var edge in candidates) { if (request.Loop && depth == request.MaxLegs - 1 && edge.To.MarketId != start.MarketId) continue; if (request.Loop && edge.To.Position!.DistanceTo(start.Position) > request.MaxLegDistanceLy * (request.MaxLegs - depth - 1)) continue; var cash = state.Cash - request.ReserveCredits - edge.Travel.Cost; if (cash < 0) continue; if (request.InvestmentCredits is {} cap) cash = Math.Min(cash, cap); var goods = edge.Candidates.Select(c => c with { Limit = (int)Math.Max(0, Math.Min(c.Limit, Math.Min( fresh[edge.From.MarketId][c.Commodity].Supply - state.Used.GetValueOrDefault((edge.From.MarketId, c.Commodity, false)), fresh[edge.To.MarketId][c.Commodity].Demand - state.Used.GetValueOrDefault((edge.To.MarketId, c.Commodity, true))))) }); var cargo = prepared is null?CargoOptimizer.Optimize(goods, request.UsableCargoCapacity, cash, request.CargoSearchNodes, token) :OptimizePrepared(prepared,edge,goods,cash,request,token); cargoTruncated |= !cargo.ProvenOptimal; // Allow one empty reposition and an empty final return; never invent cargo revenue. if (cargo.Orders.Count == 0 && (state.Legs.Count > 0 && state.Legs[^1].Orders.Count == 0 || depth == request.MaxLegs - 1 && (!request.Loop || edge.To.MarketId != start.MarketId))) continue; var shortageRisk = cargo.Orders.Select(o => (double)o.Quantity / Math.Max(1, fresh[edge.To.MarketId][o.Commodity].Demand) * .15).DefaultIfEmpty(0).Max(); var ageRisk = cargo.Orders.SelectMany(o => new[] { fresh[edge.From.MarketId][o.Commodity], fresh[edge.To.MarketId][o.Commodity] }) .Select(q => Math.Max(0, (request.AsOf - q.CollectedAt).TotalMinutes) / request.MaxMarketAgeMinutes * .12).DefaultIfEmpty(0).Max(); var risk = Math.Min(1, edge.Risk + shortageRisk + ageRisk); if (risk > (request.Risk switch { RiskTolerance.Conservative => .4, RiskTolerance.Balanced => .75, _ => 1 })) continue; var after = state.Cash + cargo.Profit - edge.Travel.Cost; var leg = new TradeLeg { Number = depth + 1, From = edge.From, To = edge.To, Orders = cargo.Orders, DistanceLy = edge.Travel.Distance, EstimatedJumps = edge.Travel.Jumps, EstimatedMinutes = edge.Travel.Minutes, OperatingCost = edge.Travel.Cost, RiskScore = risk, CashAfter = after, CargoOptimal = cargo.ProvenOptimal }; var used = new Dictionary<(long, string, bool), int>(state.Used); foreach (var o in cargo.Orders) { var buyKey = (edge.From.MarketId, o.Commodity, false); var sellKey = (edge.To.MarketId, o.Commodity, true); used[buyKey] = used.GetValueOrDefault(buyKey) + o.Quantity; used[sellKey] = used.GetValueOrDefault(sellKey) + o.Quantity; } var child = new SearchState(edge.To, after, [.. state.Legs, leg], used, deadhead); explored++; var complete = !request.Loop || edge.To.MarketId == start.MarketId && child.Legs.Count >= 2; if (complete && child.Profit > 0) completed[CandidateKey(child.Legs)] = child; // Candidates describe one round; repeated copies of the same loop must not crowd the list. if (!rankedCandidates || !request.Loop || !complete) next.Add(child); } } searchPruned |= next.Count > request.BeamWidth; frontier = Rank(next, request, rankedCandidates).Take(request.BeamWidth).ToList(); if (frontier.Count == 0) break; } var warnings = new List { "在已知行情、搜索半径和计算预算内寻找优选路线;不保证全银河数学全局最优。", "单位时间净利润与到货时间为估计;包含前往买入站的转场时间及费用。星系连通性、燃料与实际跳数须由游戏地图确认。", "报价与供需是快照;大宗需求惩罚、其他玩家交易和市场变化会改变实际利润。", "风险等级依据安保、行情时效及成交量占需求比评估,不代表被拦截或亏损的概率。" }; if (stationTruncated) warnings.Add("输入行情已截断,不能视为范围内全部站点。"); if (cargoTruncated) warnings.Add("部分混载求解达到预算,采用已找到的最佳可行装载。"); if (searchPruned) warnings.Add("路线分支或束宽预算已截断搜索;排序仅覆盖已找到的可行候选。"); if (request.JumpRangeIsEstimate) warnings.Add($"当前跳距来源不是经核实的满载跳距,按 {request.EstimatedJumpRangeFactor:P0} 保守系数估算为 {request.EffectiveLadenJumpRangeLy:F1} ly;实际跳数可能更多。"); if (request.OccupiedCargo > 0) warnings.Add($"已有 {request.OccupiedCargo:N0} t 货物占用货舱;本次按剩余 {request.UsableCargoCapacity:N0} t 规划,不计已有货物的假定卖出收入。"); if (request.AllowUnknownMetadata) warnings.Add("已允许元数据未知站点:停机坪、安保或到站距离可能无法核实。"); var ranked = Rank(completed.Values, request, rankedCandidates).Take(maxCandidates).ToArray(); if (ranked.Length == 0) warnings.Add($"没有找到符合筛选条件的盈利{(request.Loop ? "环线" : "路线")}。请补全行情/坐标,或调整时效、风险和距离。"); RoutePlan Plan(SearchState? state) => new() { Request = request, Legs = state?.Legs ?? [], Warnings = warnings.ToArray(), StationsConsidered = stations.Length, StatesExplored = explored, DeadheadDistanceLy = deadhead.Distance, DeadheadJumps = deadhead.Jumps, DeadheadMinutes = deadhead.Minutes, DeadheadOperatingCost = deadhead.Cost, SearchPruned = searchPruned || cargoTruncated }; return ranked.Length == 0 ? [Plan(null)] : ranked.Select(state => Plan(state)).ToArray(); } internal static Travel EstimateTravel(Coordinates from, string fromSystem, Station to, PlannerRequest request) { var distance = from.DistanceTo(to.Position!); var sameSystem = distance < .00001 || !string.IsNullOrWhiteSpace(fromSystem) && fromSystem.Equals(to.SystemName, StringComparison.OrdinalIgnoreCase); var jumps = sameSystem ? 0 : Math.Max(1, (int)Math.Ceiling(distance / request.EffectiveLadenJumpRangeLy)); // Includes the destination approach and commodity handover; no arbitrary success probability is applied. var supercruise = 1 + Math.Sqrt(Math.Max(0, to.DistanceToArrivalLs ?? request.MaxArrivalDistanceLs)) / 32; var minutes = request.DockingMinutes + jumps * request.MinutesPerJump + supercruise + (to.IsPlanetary ? 3 : 0); return new(distance, jumps, minutes, request.CostPerJump * jumps + request.CostPerMinute * (decimal)minutes); } private static IOrderedEnumerable Rank(IEnumerable states, PlannerRequest request, bool rankedCandidates) => states.OrderByDescending(state => Score(state, request, rankedCandidates)).ThenByDescending(state => state.Profit) .ThenBy(state => state.Minutes).ThenBy(RouteKey, StringComparer.Ordinal); private static string RouteKey(SearchState state) => string.Join("/", state.Legs.Select(leg => leg.From.MarketId)) + "/" + state.At.MarketId; private static string CandidateKey(IReadOnlyList legs) => string.Join("/", legs.Select(leg => $"{leg.From.MarketId}>{leg.To.MarketId}:" + string.Join(";", leg.Orders.OrderBy(order => order.Commodity, StringComparer.Ordinal) .Select(order => $"{order.Commodity.Length}:{order.Commodity}={order.Quantity}@{order.BuyPrice}>{order.SellPrice}")))); private static decimal Score(SearchState s, PlannerRequest r, bool rankedCandidates) { var metric = r.Objective switch { RouteObjective.CreditsPerHour => s.Minutes > 0 ? s.Profit * 60 / (decimal)s.Minutes : 0, RouteObjective.ReturnOnCapital => s.Profit / Math.Max(1, r.Credits - r.ReserveCredits), _ => s.Profit }; if (rankedCandidates) return metric; var penalty = r.Risk switch { RiskTolerance.Conservative => .7m, RiskTolerance.Balanced => .3m, _ => .05m }; var risk = s.Legs.Select(x => x.RiskScore).DefaultIfEmpty(0).Average(); return metric * (1 - penalty * (decimal)risk); } public static bool IsStationCompatible(Station s, PlannerRequest r,bool applyLegacyLocationFilters=true) { if (s.RequiresPermit || s.IsRestricted || applyLegacyLocationFilters&&(!r.IncludePlanetary && s.IsPlanetary || !r.IncludeCarriers && s.IsFleetCarrier)) return false; var pad=StationSourcePolicy.EffectiveMaxLandingPad(s); if(r.ActualShipType.Length>0&&pad==LandingPad.Unknown)return false; if(pad!=LandingPad.Unknown&&pad r.MaxArrivalDistanceLs) return false; if (r.Risk == RiskTolerance.Conservative && s.Security is SecurityLevel.Low or SecurityLevel.Anarchy or SecurityLevel.Unknown) return false; if (r.Risk == RiskTolerance.Balanced && s.Security == SecurityLevel.Anarchy) return false; return true; } internal static double SecurityRisk(SecurityLevel level) => level switch { SecurityLevel.High => .06, SecurityLevel.Medium => .18, SecurityLevel.Low => .48, SecurityLevel.Anarchy => .78, _ => .5 }; }