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

XFEEDToolBox

【WinUI】XFE精英危险工具箱

公开
关注 0 Fork 0 Star 0
返回提交历史

XFEstudio/XFEEDToolBox

fix: rank CG suppliers by actual cargo and full-cycle hourly profit

5ec00d5
XFE工作室室长 <mail@xfegzs.com>
提交于

代码差异

17 个文件 +449 -38
Modified README.md +4 -2
@@ -1,6 +1,8 @@
1 1 # XFE ED ToolBox
2 2
3 当前 0.4.23 修复当前星系原生 3D 合成层为零尺寸的空白问题,并将跑商改为全范围候选、并行计算、真实进度、采购来源类型筛选和按船型核对停机坪;CG 交付候选使用紫色及文字标识。详见 [0.4.23 说明](docs/INTERFACE-0.4.23.md)。当前星系仍支持真实坐标与左侧目录双向选择,实时商品保留报价详情、来源和完整采集时间;两页依据 Imagegen 设计稿实现。重复诊断会合并,跑商实际运行期间可恢复确切的游戏暂停菜单,手动暂停或停止任务后撤销恢复权限。既有范围见 [0.4.22 说明](docs/INTERFACE-0.4.22.md)及[当前星系与星系内驾驶](docs/CURRENT-SYSTEM-NAVIGATION.md)。
3 当前 0.4.24 修复 CG 重复跑商固定沿用货源的问题:按库存、需求、实际空舱和可用本金计算真实可采购吨数,将空载返程与必要转场计入 Cr/h;每轮交付后与采购前重新并行比较所有合格货源。已确认采购会扣减旧报价中的可用库存,避免反复访问已买空的站点;切换货源后继续累计已完成轮数。详见 [0.4.24 说明](docs/INTERFACE-0.4.24.md)。
4
5 0.4.23 修复当前星系原生 3D 合成层为零尺寸的空白问题,并将跑商改为全范围候选、并行计算、真实进度、采购来源类型筛选和按船型核对停机坪;CG 交付候选使用紫色及文字标识。详见 [0.4.23 说明](docs/INTERFACE-0.4.23.md)。当前星系仍支持真实坐标与左侧目录双向选择,实时商品保留报价详情、来源和完整采集时间;两页依据 Imagegen 设计稿实现。重复诊断会合并,跑商实际运行期间可恢复确切的游戏暂停菜单,手动暂停或停止任务后撤销恢复权限。既有范围见 [0.4.22 说明](docs/INTERFACE-0.4.22.md)及[当前星系与星系内驾驶](docs/CURRENT-SYSTEM-NAVIGATION.md)。
4 6
5 7 跑商默认只设投资金额,自动读取当前飞船与货舱,按整条路线的预计净利润每小时比较多个方案,并显示风险、货物和已完成轮数。高级参数由右上角编辑按钮展开。底层支持通过项目引用随常规构建自动编译并嵌入。详见 [跑商规划](docs/TRADE-PLANNER-0.4.21.md);导航历史验收见 [导航验收](docs/NAVIGATION.md)。
6 8
@@ -12,7 +14,7 @@ Elite Dangerous 的 WinUI 3 指挥官工具箱。当前版本提供实时数据
12 14
13 15 ## 运行
14 16
15 双击仓库根目录的 `Start-Toolbox.cmd`,优先启动 `dist/XFEEDToolBox-0.4.23-win-x64/XFEEDToolBox.exe`。星图与导航已合并;输入星系名称可自动补全,也可选中/双击星点聚焦,长按恒星待外圈填满后由游戏规划,确认成功后点击“开始自动导航”。航路由游戏计算,软件校验并读取。主页与飞船编辑背景采用本地真实船体的旋转科技线稿。发布包面向 Windows x64,包含 .NET 运行时与 Windows App SDK;源码构建需要 PowerShell 7、.NET 10 SDK、Windows SDK 和 Visual Studio 的 C++ x64 构建工具。`--page Navigation --maximized` 可直接打开最大化星图,`--page Refit` 打开飞船编辑。
17 双击仓库根目录的 `Start-Toolbox.cmd`,优先启动 `dist/XFEEDToolBox-0.4.24-win-x64/XFEEDToolBox.exe`。星图与导航已合并;输入星系名称可自动补全,也可选中/双击星点聚焦,长按恒星待外圈填满后由游戏规划,确认成功后点击“开始自动导航”。航路由游戏计算,软件校验并读取。主页与飞船编辑背景采用本地真实船体的旋转科技线稿。发布包面向 Windows x64,包含 .NET 运行时与 Windows App SDK;源码构建需要 PowerShell 7、.NET 10 SDK、Windows SDK 和 Visual Studio 的 C++ x64 构建工具。`--page Navigation --maximized` 可直接打开最大化星图,`--page Refit` 打开飞船编辑。
16 18
17 19 ```powershell
18 20 pwsh -File scripts/Build.ps1
Modified Start-Toolbox.cmd +8 -0
@@ -2,6 +2,14 @@
2 2 setlocal
3 3 cd /d "%~dp0"
4 4 if not defined XFE_TOOLBOX_DATA_DIR if exist "work\toolbox-local-data-dir.txt" set /p "XFE_TOOLBOX_DATA_DIR="<"work\toolbox-local-data-dir.txt"
5 if exist "dist\XFEEDToolBox-0.4.24-win-x64\XFEEDToolBox.exe" (
6 if defined XFE_TOOLBOX_DATA_DIR (
7 start "" "dist\XFEEDToolBox-0.4.24-win-x64\XFEEDToolBox.exe" %* --data-dir "%XFE_TOOLBOX_DATA_DIR%"
8 exit /b
9 )
10 start "" "dist\XFEEDToolBox-0.4.24-win-x64\XFEEDToolBox.exe" %*
11 exit /b
12 )
5 13 if exist "dist\XFEEDToolBox-0.4.23-win-x64\XFEEDToolBox.exe" (
6 14 if defined XFE_TOOLBOX_DATA_DIR (
7 15 start "" "dist\XFEEDToolBox-0.4.23-win-x64\XFEEDToolBox.exe" %* --data-dir "%XFE_TOOLBOX_DATA_DIR%"
Added XFEEDToolBox.Core/Execution/AutomaticSupplyPlanner.cs +101 -0
@@ -0,0 +1,101 @@
1 using System.Collections.Concurrent;
2 using XFEEDToolBox.Core.Models;
3 using XFEEDToolBox.Core.Planning;
4
5 namespace XFEEDToolBox.Core.Execution;
6
7 public sealed record AutomaticSupplyRequest(AutomaticTradeConfiguration Configuration,PlayerState Player,decimal Credits,decimal Reserve,
8 IReadOnlyDictionary<long,IReadOnlyList<CommodityQuote>> ObservedQuotes);
9
10 public sealed record AutomaticSupplyChoice(Station Source,TradeLeg Delivery,long Supply,long Demand,int FreeCapacity,
11 double EmptyReturnMinutes,double RepositionMinutes,decimal EstimatedNetProfit,double EstimatedMinutes)
12 {
13 public TradeOrder Order=>Delivery.Orders.Single();
14 public double LoadFactor=>FreeCapacity>0?(double)Order.Quantity/FreeCapacity:0;
15 public long StockFullLoads=>FreeCapacity>0?Supply/FreeCapacity:0;
16 public decimal CreditsPerHour=>EstimatedMinutes>0?EstimatedNetProfit*60/(decimal)EstimatedMinutes:0;
17 public string Basis=>$"按实际供需可装 {Order.Quantity:N0}/{FreeCapacity:N0} t({LoadFactor:P0});该商品库存 {Supply:N0} t。"
18 +$"循环含空载返程 {EmptyReturnMinutes:F1} 分钟、载货交付 {Delivery.EstimatedMinutes:F1} 分钟"
19 +(RepositionMinutes>0?$"及本次转场 {RepositionMinutes:F1} 分钟":"")+";不假定后续补货,到站与每轮交付后重新核对。";
20 }
21
22 /// <summary>Ranks every compatible CG supply station by a real bounded load and complete repeated-trip time.</summary>
23 public static class AutomaticSupplyPlanner
24 {
25 public static MarketBook MergeObservations(MarketBook cache,IReadOnlyDictionary<long,IReadOnlyList<CommodityQuote>> observations)
26 {
27 var latest=observations.Where(p=>p.Value.Count>0).ToDictionary(p=>p.Key,p=>p.Value.Max(q=>q.CollectedAt));
28 // A complete observed snapshot masks older cached commodities,
29 // while genuinely newer public quotes can report changed stock.
30 return cache with{Quotes=cache.Quotes.Where(q=>!latest.TryGetValue(q.MarketId,out var at)||q.CollectedAt>at)
31 .Concat(observations.Values.SelectMany(rows=>rows)).ToArray()};
32 }
33
34 public static IReadOnlyDictionary<long,IReadOnlyList<CommodityQuote>> DeductConfirmedTrades(
35 IReadOnlyDictionary<long,IReadOnlyList<CommodityQuote>> observations,IEnumerable<ConfirmedTradeReceipt> receipts)
36 {
37 var changes=receipts.Where(r=>r.ServerConfirmed&&r.ClientApplied).DistinctBy(r=>r.OperationKey)
38 .GroupBy(r=>(r.MarketId,r.Commodity)).ToDictionary(g=>g.Key,g=>g.ToArray());
39 return observations.ToDictionary(p=>p.Key,p=>(IReadOnlyList<CommodityQuote>)p.Value.Select(q=>
40 {
41 if(!changes.TryGetValue((q.MarketId,q.Commodity),out var trades))return q;
42 var newer=trades.Where(r=>r.ConfirmedAt>q.CollectedAt).ToArray();
43 var bought=newer.Where(r=>r.QuantityDelta>0).Sum(r=>(long)r.QuantityDelta);
44 var sold=newer.Where(r=>r.QuantityDelta<0).Sum(r=>-(long)r.QuantityDelta);
45 return q with{Supply=Math.Max(0,q.Supply-bought),Demand=Math.Max(0,q.Demand-sold)};
46 }).ToArray());
47 }
48
49 public static IReadOnlyList<AutomaticSupplyChoice> Calculate(MarketBook book,PlannerRequest request,Station destination,
50 IReadOnlyCollection<string> acceptedCommodities,int count=8,CancellationToken token=default,IProgress<TradePlanningProgress>? progress=null)
51 {
52 request.Validate();TradeRoutePlanner.RequireKnownShip(request);
53 if(request.CurrentPosition is not{IsValid:true}||destination.Position is not{IsValid:true})throw new InvalidDataException("CG货源比较需要当前飞船与交付站的真实星系坐标。");
54 if(!TradeRoutePlanner.IsStationCompatible(destination,request,false))throw new InvalidOperationException("CG交付站不满足实际飞船停机坪或访问条件。");
55 if(count<1)throw new ArgumentOutOfRangeException(nameof(count));
56 var allowed=acceptedCommodities.ToHashSet(StringComparer.Ordinal);
57 var center=request.SearchCenterPosition??request.CurrentPosition;
58 // Resolve the newest observation before filtering it. A depleted,
59 // restricted or stale latest quote must never resurrect older stock.
60 var fresh=book.Quotes.GroupBy(q=>(q.MarketId,q.Commodity))
61 .Select(g=>g.OrderByDescending(q=>q.CollectedAt).ThenBy(q=>q.Supply).First())
62 .Where(q=>q.CollectedAt<=request.AsOf.AddMinutes(5)&&q.CollectedAt>=request.AsOf.AddMinutes(-request.MaxMarketAgeMinutes)&&!q.Illegal&&!q.Rare&&allowed.Contains(q.Commodity))
63 .GroupBy(q=>q.MarketId).ToDictionary(g=>g.Key,g=>g.ToArray());
64 if(!fresh.TryGetValue(destination.MarketId,out var destinationRows))return [];
65 var buyers=destinationRows.Where(q=>q.SellPrice>0&&q.Demand>0&&q.SellPrice<=NativeTradeBatchPolicy.MaximumUnitPrice).ToDictionary(q=>q.Commodity);
66 var sources=book.Stations.Where(s=>s.MarketId!=destination.MarketId&&s.Position is{IsValid:true}&&s.Position.DistanceTo(center)<=request.SearchRadiusLy
67 &&s.Position.DistanceTo(destination.Position)<=request.MaxLegDistanceLy&&TradeRoutePlanner.IsStationCompatible(s,request,false)
68 &&StationSourcePolicy.Allows(s,request.AllowedSourceKinds)&&fresh.ContainsKey(s.MarketId)).DistinctBy(s=>s.MarketId).ToArray();
69 var found=new ConcurrentBag<AutomaticSupplyChoice>();long processed=0;var gate=new object();
70 progress?.Report(new("比较 CG 供货与循环收益",0,sources.Length,"所有合格货源均参与;数量受库存、需求、实际空舱和本金共同约束。"));
71 var budget=Math.Max(0,Math.Min(request.Credits-request.ReserveCredits,request.InvestmentCredits??decimal.MaxValue));
72 Parallel.ForEach(sources,new ParallelOptions{CancellationToken=token,MaxDegreeOfParallelism=TradeRoutePlanner.MaxParallelism},source=>
73 {
74 var delivery=TradeRoutePlanner.EstimateTravel(source.Position!,source.SystemName,destination,request);
75 var empty=TradeRoutePlanner.EstimateTravel(destination.Position!,destination.SystemName,source,request);
76 var reposition=request.CurrentMarketId==destination.MarketId||request.CurrentMarketId==source.MarketId?null
77 :TradeRoutePlanner.EstimateTravel(request.CurrentPosition!,request.CurrentSystemName,source,request);
78 foreach(var offer in fresh[source.MarketId])
79 {
80 token.ThrowIfCancellationRequested();
81 if(offer.BuyPrice is <=0 or >NativeTradeBatchPolicy.MaximumUnitPrice||offer.Supply<=0||!NativeTradeBatchPolicy.AllowsFormal(offer.Commodity)
82 ||!buyers.TryGetValue(offer.Commodity,out var target)||target.SellPrice-offer.BuyPrice<Math.Max(1,request.MinimumProfitPerTon))continue;
83 var quantity=(int)Math.Min(Math.Min((long)request.UsableCargoCapacity,offer.Supply),Math.Min(target.Demand,Math.Min(int.MaxValue,decimal.Floor(budget/offer.BuyPrice))));
84 if(quantity<=0)continue;
85 var order=new TradeOrder(offer.Commodity,offer.DisplayName,quantity,offer.BuyPrice,target.SellPrice);
86 var cost=delivery.Cost+empty.Cost+(reposition?.Cost??0);var profit=order.Profit-cost;if(profit<=0)continue;
87 var age=Math.Max(0,(request.AsOf-(offer.CollectedAt<target.CollectedAt?offer.CollectedAt:target.CollectedAt)).TotalMinutes);
88 var risk=Math.Min(1,Math.Max(TradeRoutePlanner.SecurityRisk(source.Security),TradeRoutePlanner.SecurityRisk(destination.Security))
89 +.15*(double)quantity/Math.Max(1,target.Demand)+.12*age/request.MaxMarketAgeMinutes);
90 if(risk>(request.Risk switch{RiskTolerance.Conservative=>.4,RiskTolerance.Balanced=>.75,_=>1}))continue;
91 var leg=new TradeLeg{Number=1,From=source,To=destination,Orders=[order],DistanceLy=delivery.Distance,EstimatedJumps=delivery.Jumps,
92 EstimatedMinutes=delivery.Minutes,OperatingCost=delivery.Cost,CashAfter=request.Credits+order.Profit-delivery.Cost,RiskScore=risk};
93 found.Add(new(source,leg,offer.Supply,target.Demand,request.UsableCargoCapacity,empty.Minutes,reposition?.Minutes??0,
94 profit,delivery.Minutes+empty.Minutes+(reposition?.Minutes??0)));
95 }
96 lock(gate){processed++;progress?.Report(new("比较 CG 供货与循环收益",processed,sources.Length,"已将实际可装载数量及空载返程计入 Cr/h。"));}
97 });
98 return found.OrderByDescending(c=>c.CreditsPerHour).ThenByDescending(c=>c.EstimatedNetProfit).ThenByDescending(c=>c.Order.Quantity)
99 .ThenBy(c=>c.Source.MarketId).ThenBy(c=>c.Order.Commodity,StringComparer.Ordinal).Take(count).ToArray();
100 }
101 }
Modified XFEEDToolBox.Core/Execution/AutomaticTradeConfiguration.cs +4 -0
@@ -16,6 +16,10 @@ public sealed record AutomaticTradeConfiguration
16 16 public ulong? CommunityGoalId{get;init;}
17 17 public string[] Commodities{get;init;}=[];
18 18 public bool ReturnTrade{get;init;}=true;
19 public bool AutoSelectSource{get;init;}=true;
20 public PlannerRequest? SourcePlanning{get;init;}
21 public bool SourceCenterFollowsPlayer{get;init;}=true;
22 public bool UsesDynamicSources=>Mode==AutomaticTradeMode.CommunityGoal&&AutoSelectSource;
19 23 public decimal ReserveCredits{get;init;}
20 24 public decimal? InvestmentCredits{get;init;}
21 25 public bool RequireSupercruiseOvercharge{get;init;}=true;
Modified XFEEDToolBox.Core/Execution/AutomaticTradeEngine.cs +83 -18
@@ -5,13 +5,14 @@ using XFEEDToolBox.Core.Navigation;
5 5 namespace XFEEDToolBox.Core.Execution;
6 6
7 7 public sealed record AutomaticTradeProgress(string Phase,string Message,string NextDestination,int CompletedBatches,decimal NetProfit,string? ActiveJourney=null)
8 {public AutomaticTradeEstimate? Estimate{get;init;}public int CompletedRounds{get;init;}}
8 {public AutomaticTradeEstimate? Estimate{get;init;}public int CompletedRounds{get;init;}public AutomaticSupplyChoice? SupplySelection{get;init;}}
9 9
10 10 /// <summary>A toolbox-owned loop. No agent, scheduler or UI callback is needed to advance it.</summary>
11 11 public sealed class AutomaticTradeEngine(AutomaticTradeConfiguration configuration,NativeTradeCommandDriver driver,IOriginalCommerceChannel channel,
12 12 Func<PlayerState> player,Func<CancellationToken,Task> beforeInput,string journalDirectory,
13 13 Func<CancellationToken,Task>? recoverToSameMode=null,Func<Station,IReadOnlyList<CommodityQuote>,CancellationToken,Task>? publishQuotes=null,
14 Func<CancellationToken,Task>? validateFlight=null)
14 Func<CancellationToken,Task>? validateFlight=null,
15 Func<AutomaticSupplyRequest,CancellationToken,Task<AutomaticSupplyChoice?>>? selectSource=null)
15 16 {
16 17 public event Action<AutomaticTradeProgress>? Progress;
17 18 public int MaximumCycles{get;init;}
@@ -28,18 +29,22 @@ public sealed class AutomaticTradeEngine(AutomaticTradeConfiguration configurati
28 29 private PlayerState initial=new();
29 30 private TradeProfitLedger? routeLedger;
30 31 private readonly string root=configuration.WorkingDirectory;
32 private Station source=configuration.Source;
33 private AutomaticSupplyChoice? supplySelection;
34 private sealed record SelectedSource(int Schema,long Destination,ulong? Goal,AutomaticSupplyChoice Choice);
31 35
32 36 private async Task WriteAsync(string path,object value,CancellationToken token)
33 37 {await File.WriteAllTextAsync(path+".tmp",JsonSerializer.Serialize(value,ToolboxSettings.JsonOptions),token).ConfigureAwait(false);File.Move(path+".tmp",path,true);}
34 38 private async Task StateAsync(string value,string text,Station? destination=null,CancellationToken token=default)
35 39 {
36 40 phase=value;message=text;if(destination is not null)next=destination.DisplayName;
37 Progress?.Invoke(new(phase,message,next,completed,profit,active){Estimate=estimate,CompletedRounds=completedRounds});
41 Progress?.Invoke(new(phase,message,next,completed,profit,active){Estimate=estimate,CompletedRounds=completedRounds,SupplySelection=supplySelection});
38 42 var current=player();
39 43 await WriteAsync(Path.Combine(root,"state.json"),new{at=DateTimeOffset.UtcNow,phase,issue=value=="requires-recovery"?text:null,message=text,nextDestination=next,
40 44 executor="XFEEDToolBox",agentControlsGame=false,scheduledTask=false,independentLoop=true,toolboxProcessId=Environment.ProcessId,
41 45 completedBatches=completed,completedRounds,activeJourney=active,current.SystemName,current.StationName,current.Docked,current.CargoCount,
42 current.CurrentShipId,current.Ship,current.CargoCapacity,netProfit=profit},token).ConfigureAwait(false);
46 current.CurrentShipId,current.Ship,current.CargoCapacity,netProfit=profit,sourceMarketId=source.MarketId,sourceName=source.DisplayName,
47 supplySelection},token).ConfigureAwait(false);
43 48 }
44 49 private void RefreshCompleted()
45 50 {
@@ -52,7 +57,7 @@ public sealed class AutomaticTradeEngine(AutomaticTradeConfiguration configurati
52 57 else if(File.Exists(Path.Combine(path,"external-result.json")))
53 58 {using var external=JsonDocument.Parse(File.ReadAllText(Path.Combine(path,"external-result.json")));if(external.RootElement.GetProperty("passed").GetBoolean())profit+=external.RootElement.GetProperty("profit").GetProperty("netProfit").GetDecimal();}
54 59 }
55 completedRounds=AutomaticTradeRounds.Refresh(configuration,player());
60 completedRounds=AutomaticTradeRounds.Refresh(configuration with{Source=source},player());
56 61 }
57 62 private async Task CheckAsync(CancellationToken token)
58 63 {
@@ -66,6 +71,11 @@ public sealed class AutomaticTradeEngine(AutomaticTradeConfiguration configurati
66 71 if(recoverToSameMode is null)throw new InvalidOperationException("原始返回主菜单恢复未就绪,当前现场已保留。");
67 72 var previousPhase=phase;RevokeMenuRecovery();
68 73 await StateAsync("recovering-session","正在通过原始主菜单恢复同一游戏模式",token:token);
74 // Failed docking handoff revokes the old input context. Release it
75 // and require a newly verified healthy context before menu recovery;
76 // the health authorizer retains real damage and emergency blocks.
77 await driver.ReleaseAllAsync().ConfigureAwait(false);
78 await beforeInput(token).ConfigureAwait(false);
69 79 await recoverToSameMode(token).ConfigureAwait(false);var restored=player();
70 80 if(restored.CommanderId!=initial.CommanderId||restored.CurrentShipId!=initial.CurrentShipId||restored.CargoCapacity!=initial.CargoCapacity)
71 81 throw new InvalidOperationException("重进后的身份与本轮飞船不一致。");
@@ -94,7 +104,7 @@ public sealed class AutomaticTradeEngine(AutomaticTradeConfiguration configurati
94 104 await StateAsync("travelling","正在前往 "+destination.DisplayName,destination,token);
95 105 for(var attempt=0;;attempt++)
96 106 {
97 try{await driver.ExecuteAsync(new(TradeActionKind.Travel,new(){From=player().MarketId==configuration.Source.MarketId?configuration.Source:configuration.Destination,To=destination},null),CheckAsync,token).ConfigureAwait(false);RefreshCompleted();return;}
107 try{await driver.ExecuteAsync(new(TradeActionKind.Travel,new(){From=player().MarketId==source.MarketId?source:configuration.Destination,To=destination},null),CheckAsync,token).ConfigureAwait(false);RefreshCompleted();return;}
98 108 catch(DockingHandoffException)when(attempt<3&&recoverToSameMode is not null){await RecoverAsync(token).ConfigureAwait(false);}
99 109 catch(StationDepartureRecoveryException)when(attempt<3&&recoverToSameMode is not null){await RecoverAsync(token).ConfigureAwait(false);}
100 110 catch(Exception error)when(NavigationRecovery.CanRetry(error,token))
@@ -110,6 +120,40 @@ public sealed class AutomaticTradeEngine(AutomaticTradeConfiguration configurati
110 120 return rows;
111 121 }
112 122 private bool FreshQuotes(Station station)=>quotes.TryGetValue(station.MarketId,out var rows)&&rows.Count>0&&rows.All(q=>DateTimeOffset.UtcNow-q.CollectedAt<TimeSpan.FromMinutes(30));
123 private IReadOnlyDictionary<long,IReadOnlyList<CommodityQuote>> RemainingObservedQuotes()
124 {
125 var receipts=new List<ConfirmedTradeReceipt>();
126 foreach(var file in Directory.EnumerateDirectories(root,"journey-*").Select(p=>Path.Combine(p,"journey.json")).Where(File.Exists))
127 {
128 var checkpoint=TradeJourneyCheckpoints.Load(file);
129 if(checkpoint.Commander!=initial.CommanderId||checkpoint.ShipId!=initial.CurrentShipId)continue;
130 if(checkpoint.Bought is{} bought)receipts.Add(bought);if(checkpoint.Sold is{} sold)receipts.Add(sold);
131 }
132 return AutomaticSupplyPlanner.DeductConfirmedTrades(quotes,receipts);
133 }
134 private async Task<bool> SelectSourceAsync(CancellationToken token)
135 {
136 if(!configuration.UsesDynamicSources||selectSource is null)return true;
137 if(player().CargoCount!=0)throw new InvalidOperationException("已有货物必须先按原订单交付,不能在载货时替换货源。");
138 await StateAsync("selecting-source","正在按供应量、实际货舱和完整循环时间重新比较全部 CG 货源",token:token);
139 var wallet=await driver.RefreshWalletAsync(token);var current=player();
140 var services=current.MarketId is >0?await driver.ReadServiceBudgetAsync(current.MarketId.Value,token):0;
141 var choice=await selectSource(new(configuration with{Source=source},current,wallet.Credits,
142 configuration.PurchaseReserve(wallet.Credits,current.Rebuy,services),RemainingObservedQuotes()),token).ConfigureAwait(false);
143 if(choice is null)
144 {
145 await StateAsync("waiting-market","当前合格货源的供应、需求或资金不足,保持停靠等待新报价;未沿用已耗尽的货源。",token:token);
146 await Task.Delay(TimeSpan.FromSeconds(30),token).ConfigureAwait(false);return false;
147 }
148 if(choice.Source.MarketId==configuration.Destination.MarketId||choice.Delivery.To.MarketId!=configuration.Destination.MarketId
149 ||!configuration.Commodities.Contains(choice.Order.Commodity,StringComparer.Ordinal)||choice.Order.Quantity>current.FreeCargo)
150 throw new InvalidDataException("新货源、CG交付站或实际空舱与规划结果不一致。");
151 Planning.ShipPadPolicy.RequireFits(choice.Source,current.Ship);
152 source=choice.Source;supplySelection=choice;estimate=new(choice.EstimatedNetProfit,choice.EstimatedMinutes,choice.Basis);
153 await WriteAsync(Path.Combine(root,"selected-source.json"),new SelectedSource(1,configuration.Destination.MarketId,configuration.CommunityGoalId,choice),token);
154 await StateAsync("source-selected",$"已选择 {source.DisplayName} · {choice.Basis} 预计 {choice.CreditsPerHour:N0} Cr/h。",source,token);
155 return true;
156 }
113 157 private NativeCommunityGoalClient GoalClient()=>new(channel,configuration.Destination.MarketId,configuration.CommunityGoalId);
114 158 private string? ManualSettlement(string directory,TradeLeg leg)
115 159 {
@@ -142,11 +186,13 @@ public sealed class AutomaticTradeEngine(AutomaticTradeConfiguration configurati
142 186 private async Task CompleteAsync(string directory,TradeLeg leg,CancellationToken token)
143 187 {
144 188 Planning.ShipPadPolicy.RequireFits(leg.From,player().Ship);Planning.ShipPadPolicy.RequireFits(leg.To,player().Ship);
145 estimate=AutomaticTradeEstimate.For(leg,root,initial.CurrentShipId);active=directory;await StateAsync("cargo-journey","正在采购、运输并出售本批货物",leg.To,token);
189 estimate=supplySelection is {} selection&&selection.Source.MarketId==leg.From.MarketId&&selection.Order==leg.Orders.Single()
190 ?new(selection.EstimatedNetProfit,selection.EstimatedMinutes,selection.Basis):AutomaticTradeEstimate.For(leg,root,initial.CurrentShipId);
191 active=directory;await StateAsync("cargo-journey","正在采购、运输并出售本批货物",leg.To,token);
146 192 for(var attempt=0;;attempt++)
147 193 {
148 194 var journey=new NativeTradeJourney(driver,player,CheckAsync,directory){JournalDirectory=journalDirectory};
149 journey.Status+=text=>Progress?.Invoke(new(phase,text,next,completed,profit,active){Estimate=estimate,CompletedRounds=completedRounds});
195 journey.Status+=text=>Progress?.Invoke(new(phase,text,next,completed,profit,active){Estimate=estimate,CompletedRounds=completedRounds,SupplySelection=supplySelection});
150 196 try{await journey.RunAsync(leg,token).ConfigureAwait(false);break;}
151 197 catch(NativeTradeJourneyIncompleteException error)when(error.InnerException is DockingHandoffException&&attempt<3&&recoverToSameMode is not null){await RecoverAsync(token).ConfigureAwait(false);}
152 198 catch(NativeTradeJourneyIncompleteException error)when(error.InnerException is StationDepartureRecoveryException&&attempt<3&&recoverToSameMode is not null){await RecoverAsync(token).ConfigureAwait(false);}
@@ -180,10 +226,18 @@ public sealed class AutomaticTradeEngine(AutomaticTradeConfiguration configurati
180 226 initial=player();
181 227 try
182 228 {
229 var selectedFile=Path.Combine(root,"selected-source.json");
230 if(configuration.UsesDynamicSources&&selectSource is not null&&File.Exists(selectedFile))
231 {
232 var saved=JsonSerializer.Deserialize<SelectedSource>(await File.ReadAllTextAsync(selectedFile,token),ToolboxSettings.JsonOptions);
233 if(saved is null||saved.Schema!=1||saved.Destination!=configuration.Destination.MarketId||saved.Goal!=configuration.CommunityGoalId
234 ||saved.Choice.Source.MarketId==configuration.Destination.MarketId)throw new InvalidDataException("已保存的CG货源与本次交付活动不一致。");
235 source=saved.Choice.Source;
236 }
183 237 RefreshCompleted();await StateAsync("preflight","正在核对当前飞船和交易接口",token:token);
184 238 if(!initial.InGame||initial.CurrentShipId is null||initial.CargoCapacity<=0||initial.Rebuy<=0||!initial.CargoReliable)
185 239 throw new InvalidOperationException("当前飞船、实际货舱、重购费或货物观测尚未就绪。");
186 Planning.ShipPadPolicy.RequireFits(configuration.Source,initial.Ship);Planning.ShipPadPolicy.RequireFits(configuration.Destination,initial.Ship);
240 Planning.ShipPadPolicy.RequireFits(source,initial.Ship);Planning.ShipPadPolicy.RequireFits(configuration.Destination,initial.Ship);
187 241 var inventory=await channel.CallAsync(new{op="commerce.inventory"},token).ConfigureAwait(false);
188 242 await ExternalTradeSettlement.ReconcileAsync(root,journalDirectory,initial,inventory,token).ConfigureAwait(false);
189 243 await ExternalNativeSaleSettlement.ReconcileAsync(root,journalDirectory,initial,inventory,channel,token).ConfigureAwait(false);
@@ -191,7 +245,7 @@ public sealed class AutomaticTradeEngine(AutomaticTradeConfiguration configurati
191 245 await driver.PreflightAsync(token).ConfigureAwait(false);
192 246 if(validateFlight is not null)await validateFlight(token).ConfigureAwait(false);
193 247 BeginMenuRecovery(token);
194 if(player().Docked&&player().MarketId is {} currentMarket&&currentMarket!=configuration.Source.MarketId&&currentMarket!=configuration.Destination.MarketId)
248 if(player().Docked&&player().MarketId is {} currentMarket&&currentMarket!=source.MarketId&&currentMarket!=configuration.Destination.MarketId)
195 249 await driver.ServiceDockedAsync(new(currentMarket,initial.CargoCapacity),CheckAsync,token).ConfigureAwait(false);
196 250 await WriteAsync(Path.Combine(root,"current-ship.json"),new{at=DateTimeOffset.UtcNow,initial.CommanderId,initial.CurrentShipId,initial.Ship,initial.CargoCapacity,
197 251 initial.Rebuy,reserve=Math.Max(configuration.ReserveCredits,initial.Rebuy)},token);
@@ -211,10 +265,10 @@ public sealed class AutomaticTradeEngine(AutomaticTradeConfiguration configurati
211 265 // Resolve all prior orders first, then finish station services before another departure.
212 266 if(player().Docked&&player().MarketId is {} resumedMarket)
213 267 await driver.ServiceDockedAsync(new(resumedMarket,initial.CargoCapacity),CheckAsync,token).ConfigureAwait(false);
214 foreach(var station in new[]{configuration.Source,configuration.Destination})
268 foreach(var file in Directory.EnumerateFiles(root,"quotes-*.json"))
215 269 {
216 var file=Path.Combine(root,"quotes-"+station.MarketId+".json");
217 if(File.Exists(file))quotes[station.MarketId]=JsonSerializer.Deserialize<CommodityQuote[]>(await File.ReadAllTextAsync(file,token),ToolboxSettings.JsonOptions)??[];
270 var rows=JsonSerializer.Deserialize<CommodityQuote[]>(await File.ReadAllTextAsync(file,token),ToolboxSettings.JsonOptions)??[];
271 if(rows.Length>0&&rows.Select(q=>q.MarketId).Distinct().Count()==1)quotes[rows[0].MarketId]=rows;
218 272 }
219 273 if(configuration.Mode==AutomaticTradeMode.CommunityGoal)
220 274 {await TravelAsync(configuration.Destination,token);await GoalAsync(token);await QuotesAsync(configuration.Destination,token);await driver.ServiceDockedAsync(new(configuration.Destination.MarketId,initial.CargoCapacity),CheckAsync,token);}
@@ -223,11 +277,21 @@ public sealed class AutomaticTradeEngine(AutomaticTradeConfiguration configurati
223 277 {
224 278 await CheckAsync(token);
225 279 var atDestination=player().Docked&&player().MarketId==configuration.Destination.MarketId;
226 var from=atDestination?configuration.Destination:configuration.Source;var to=atDestination?configuration.Source:configuration.Destination;
227 if(atDestination&&!configuration.ReturnTrade){await TravelAsync(configuration.Source,token);continue;}
280 // Complete any configured return trade to this batch's home
281 // before comparing suppliers for a new outbound batch.
282 if(atDestination&&!configuration.ReturnTrade&&!await SelectSourceAsync(token))continue;
283 var from=atDestination?configuration.Destination:source;var to=atDestination?source:configuration.Destination;
284 if(atDestination&&!configuration.ReturnTrade){await TravelAsync(source,token);continue;}
228 285 if(!FreshQuotes(to))
229 286 {await TravelAsync(to,token);await QuotesAsync(to,token);await driver.ServiceDockedAsync(new(to.MarketId,initial.CargoCapacity),CheckAsync,token);continue;}
230 287 await TravelAsync(from,token);var live=await QuotesAsync(from,token);
288 if(from.MarketId!=configuration.Destination.MarketId&&configuration.UsesDynamicSources&&selectSource is not null)
289 {
290 // Replace the cached source stock with the actual docked
291 // market before choosing an irreversible purchase.
292 if(!await SelectSourceAsync(token))continue;
293 if(source.MarketId!=from.MarketId){await TravelAsync(source,token);continue;}
294 }
231 295 var wallet=await driver.RefreshWalletAsync(token);
232 296 var serviceBudget=await driver.ReadServiceBudgetAsync(from.MarketId,token);
233 297 var reserve=configuration.PurchaseReserve(wallet.Credits,player().Rebuy,serviceBudget);
@@ -235,9 +299,9 @@ public sealed class AutomaticTradeEngine(AutomaticTradeConfiguration configurati
235 299 if(configuration.Mode==AutomaticTradeMode.CommunityGoal&&to.MarketId==configuration.Destination.MarketId&&filter is not {Length:>0})
236 300 throw new InvalidOperationException("请为此贸易CG指定接受的商品;不会猜测CG范围或倍增已提高的报价。");
237 301 TradeOrder order;
238 try{order=AutomaticTradePlanner.Select(live,quotes[to.MarketId],player().CargoCapacity,wallet.Credits,reserve,filter);}
302 try{order=AutomaticTradePlanner.Select(live,quotes[to.MarketId],player().FreeCargo,wallet.Credits,reserve,filter);}
239 303 catch(InvalidOperationException)when(from.MarketId==configuration.Destination.MarketId)
240 {await driver.ServiceDockedAsync(new(from.MarketId,initial.CargoCapacity),CheckAsync,token);await TravelAsync(configuration.Source,token);continue;}
304 {await driver.ServiceDockedAsync(new(from.MarketId,initial.CargoCapacity),CheckAsync,token);await TravelAsync(source,token);continue;}
241 305 catch(InvalidOperationException)
242 306 {
243 307 await driver.ServiceDockedAsync(new(from.MarketId,initial.CargoCapacity),CheckAsync,token);
@@ -253,7 +317,8 @@ public sealed class AutomaticTradeEngine(AutomaticTradeConfiguration configurati
253 317 var goal=CommunityGoalCatalogue.RequireDestinationTrade(membership.Goals,to.MarketId,configuration.CommunityGoalId,DateTimeOffset.UtcNow,true);
254 318 baseline=goal.Contribution??0;goalId=goal.Id;
255 319 }
256 var leg=new TradeLeg{Number=1,From=from,To=to,Orders=[order]};
320 var leg=supplySelection is {} chosen&&chosen.Source.MarketId==from.MarketId&&chosen.Order==order?chosen.Delivery
321 :new TradeLeg{Number=1,From=from,To=to,Orders=[order]};
257 322 var directory=Path.Combine(root,"journey-"+Guid.NewGuid().ToString("N"));Directory.CreateDirectory(directory);
258 323 await WriteAsync(Path.Combine(directory,"leg.json"),leg,token);
259 324 if(goalId is not null)await WriteAsync(Path.Combine(directory,"membership-before.json"),new{goalId,contribution=baseline,at=DateTimeOffset.UtcNow},token);
Modified XFEEDToolBox.Core/Execution/AutomaticTradeRounds.cs +21 -8
@@ -26,6 +26,17 @@ public static class AutomaticTradeRounds
26 26 public static int Refresh(AutomaticTradeConfiguration configuration,PlayerState current)
27 27 {
28 28 var root=configuration.WorkingDirectory;
29 bool Outbound(TradeLeg leg)=>leg.To.MarketId==configuration.Destination.MarketId
30 &&(configuration.UsesDynamicSources?configuration.Commodities.Length>0&&leg.Orders.Count>0
31 &&leg.From.MarketId!=configuration.Destination.MarketId&&leg.Orders.All(o=>configuration.Commodities.Contains(o.Commodity,StringComparer.Ordinal))
32 :leg.From.MarketId==configuration.Source.MarketId);
33 bool SameGoal(Journey journey)
34 {
35 if(configuration.Mode!=AutomaticTradeMode.CommunityGoal||configuration.CommunityGoalId is null)return true;
36 var file=Path.Combine(journey.Directory,"membership-before.json");if(!File.Exists(file))return false;
37 using var proof=JsonDocument.Parse(File.ReadAllText(file));
38 return proof.RootElement.GetProperty("goalId").GetUInt64()==configuration.CommunityGoalId;
39 }
29 40 if(!Directory.Exists(root)||string.IsNullOrWhiteSpace(current.CommanderId)||current.CurrentShipId is not {} ship)return 0;
30 41 var completed=new List<Journey>();var purchases=new List<ConfirmedTradeReceipt>();
31 42 foreach(var directory in Directory.EnumerateDirectories(root,"journey-*"))
@@ -34,7 +45,7 @@ public static class AutomaticTradeRounds
34 45 if(!File.Exists(checkpointPath))continue;
35 46 var checkpoint=TradeJourneyCheckpoints.Load(checkpointPath);
36 47 if(checkpoint.Commander!=current.CommanderId||checkpoint.ShipId!=ship)continue;
37 if(checkpoint.Bought is {} purchase&&purchase.MarketId==configuration.Source.MarketId)purchases.Add(purchase);
48 if(checkpoint.Bought is {} purchase)purchases.Add(purchase);
38 49 var resultPath=Path.Combine(directory,"result.json");var legPath=Path.Combine(directory,"leg.json");
39 50 if(!File.Exists(resultPath)||!File.Exists(legPath))continue;
40 51 var result=JsonSerializer.Deserialize<NativeTradeJourneyResult>(File.ReadAllText(resultPath),ToolboxSettings.JsonOptions);
@@ -44,7 +55,7 @@ public static class AutomaticTradeRounds
44 55 ||bought.Commodity!=leg.Orders[0].Commodity||bought.QuantityDelta!=leg.Orders[0].Quantity||sold.ConfirmedAt>result.Finished)continue;
45 56 completed.Add(new(directory,leg,checkpoint));
46 57 }
47 var candidates=completed.Where(journey=>journey.Leg.From.MarketId==configuration.Source.MarketId&&journey.Leg.To.MarketId==configuration.Destination.MarketId)
58 var candidates=completed.Where(journey=>Outbound(journey.Leg)&&SameGoal(journey))
48 59 .DistinctBy(journey=>journey.Checkpoint.Sold!.OperationKey).OrderBy(journey=>journey.Checkpoint.Bought!.ConfirmedAt).ToArray();
49 60 // Two distinct batches cannot both occupy this ship's cargo hold over
50 61 // overlapping intervals. Ambiguous records must not inflate rounds.
@@ -52,7 +63,8 @@ public static class AutomaticTradeRounds
52 63 &&other.Checkpoint.Bought!.ConfirmedAt<journey.Checkpoint.Sold!.ConfirmedAt
53 64 &&journey.Checkpoint.Bought!.ConfirmedAt<other.Checkpoint.Sold!.ConfirmedAt)).ToArray();
54 65 if(outbound.Length==0)return 0;
55 var scope=JsonSerializer.Serialize(new{source=configuration.Source.MarketId,destination=configuration.Destination.MarketId,configuration.ReturnTrade,
66 var scope=JsonSerializer.Serialize(new{source=configuration.UsesDynamicSources?0:configuration.Source.MarketId,destination=configuration.Destination.MarketId,configuration.ReturnTrade,
67 goal=configuration.Mode==AutomaticTradeMode.CommunityGoal?configuration.CommunityGoalId:null,
56 68 commander=current.CommanderId,ship},ToolboxSettings.JsonOptions);
57 69 var records=Path.Combine(root,"rounds-v1",Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(scope))));
58 70 var count=0;
@@ -63,7 +75,7 @@ public static class AutomaticTradeRounds
63 75 if(File.Exists(path))
64 76 {
65 77 var existing=JsonSerializer.Deserialize<Completion>(File.ReadAllText(path),ToolboxSettings.JsonOptions);
66 if(existing is null||existing.Schema!=1||existing.SourceMarketId!=configuration.Source.MarketId||existing.DestinationMarketId!=configuration.Destination.MarketId
78 if(existing is null||existing.Schema!=1||existing.SourceMarketId!=journey.Leg.From.MarketId||existing.DestinationMarketId!=configuration.Destination.MarketId
67 79 ||existing.ReturnTrade!=configuration.ReturnTrade||existing.Commander!=current.CommanderId||existing.ShipId!=ship
68 80 ||existing.OutboundPurchaseKey!=bought.OperationKey||existing.OutboundSaleKey!=sold.OperationKey||existing.OutboundSoldAt!=sold.ConfirmedAt
69 81 ||existing.CompletedAt<sold.ConfirmedAt)throw new InvalidDataException("已完成轮次记录与原始交易回执不一致。");
@@ -75,12 +87,13 @@ public static class AutomaticTradeRounds
75 87 {
76 88 // A later purchase at the source also proves a return that
77 89 // completed just before a restart or the next journey began.
78 var witnesses=purchases.Where(receipt=>receipt.ConfirmedAt>=sold.ConfirmedAt&&receipt.OperationKey!=bought.OperationKey)
90 var home=journey.Leg.From.MarketId;
91 var witnesses=purchases.Where(receipt=>receipt.MarketId==home&&receipt.ConfirmedAt>=sold.ConfirmedAt&&receipt.OperationKey!=bought.OperationKey)
79 92 .Select(receipt=>new Arrival(receipt.ConfirmedAt,"confirmed-purchase-at-source",receipt.OperationKey))
80 .Concat(completed.Where(other=>other.Leg.From.MarketId==configuration.Destination.MarketId&&other.Leg.To.MarketId==configuration.Source.MarketId
93 .Concat(completed.Where(other=>other.Leg.From.MarketId==configuration.Destination.MarketId&&other.Leg.To.MarketId==home
81 94 &&other.Checkpoint.Sold!.ConfirmedAt>=sold.ConfirmedAt)
82 95 .Select(other=>new Arrival(other.Checkpoint.Sold!.ConfirmedAt,"confirmed-return-sale",other.Checkpoint.Sold.OperationKey))).ToList();
83 if(current.InGame&&current.Docked&&current.MarketId==configuration.Source.MarketId&&current.LocationAt is {} dockedAt
96 if(current.InGame&&current.Docked&&current.MarketId==home&&current.LocationAt is {} dockedAt
84 97 &&dockedAt>=sold.ConfirmedAt&&dockedAt<=DateTimeOffset.UtcNow.AddSeconds(1))
85 98 witnesses.Add(new(dockedAt,"journal-docked-at-source",null));
86 99 // Home must have been reached before the next outbound buy;
@@ -89,7 +102,7 @@ public static class AutomaticTradeRounds
89 102 arrival=witnesses.Where(witness=>nextPurchase is null||witness.At<=nextPurchase).OrderBy(witness=>witness.At).FirstOrDefault();
90 103 }
91 104 if(arrival is null)continue;
92 var value=new Completion(1,configuration.Source.MarketId,configuration.Destination.MarketId,configuration.ReturnTrade,current.CommanderId,ship,
105 var value=new Completion(1,journey.Leg.From.MarketId,configuration.Destination.MarketId,configuration.ReturnTrade,current.CommanderId,ship,
93 106 bought.OperationKey,sold.OperationKey,Path.GetFileName(journey.Directory),sold.ConfirmedAt,arrival.At,arrival.Evidence,arrival.OperationKey);
94 107 Directory.CreateDirectory(records);var temporary=path+"."+Guid.NewGuid().ToString("N")+".tmp";
95 108 try
Modified XFEEDToolBox.Core/Models/TradingModels.cs +1 -1
@@ -244,7 +244,7 @@ public sealed record PlannerRequest
244 244 {
245 245 if (Credits <= ReserveCredits || ReserveCredits < 0) throw new ArgumentException("可用现金必须高于保留资金。");
246 246 if (InvestmentCredits is <= 0) throw new ArgumentException("固定投入金额必须大于零;使用全部可用资金时应留空。");
247 if (CargoCapacity is < 1 or > 2048) throw new ArgumentException("可用货舱须在 1–2048 t 之间。");
247 if (CargoCapacity < 1) throw new ArgumentException("实际货舱容量须大于零。");
248 248 if (OccupiedCargo < 0 || OccupiedCargo >= CargoCapacity) throw new ArgumentException("现有货物必须少于货舱总容量;请先腾出可用货舱。");
249 249 if (!double.IsFinite(LadenJumpRangeLy) || LadenJumpRangeLy is <= 0 or > 500) throw new ArgumentException("请填写有效的满载跳跃距离。");
250 250 if (!double.IsFinite(EstimatedJumpRangeFactor) || EstimatedJumpRangeFactor is <= 0 or > 1) throw new ArgumentException("估算满载跳距的保守系数须大于零且不超过 1。");
Modified XFEEDToolBox.Core/Planning/TradeRoutePlanner.cs +3 -3
@@ -32,7 +32,7 @@ public sealed partial class TradeRoutePlanner
32 32 return new(candidates,new(code,message,book.Stations.Count,compatible.Count,freshStations,fresh.Length,
33 33 buyStations,sellStations,originBuys,book.Truncated){SearchPruned=limited});
34 34 }
35 private sealed record Travel(double Distance, int Jumps, double Minutes, decimal Cost);
35 internal sealed record Travel(double Distance, int Jumps, double Minutes, decimal Cost);
36 36 private sealed record Edge(Station From, Station To, Travel Travel, double Risk, CargoCandidate[] Candidates);
37 37 private sealed record SearchState(Station At, decimal Cash, List<TradeLeg> Legs, Dictionary<(long, string, bool), int> Used, Travel Deadhead)
38 38 {
@@ -217,7 +217,7 @@ public sealed partial class TradeRoutePlanner
217 217 return ranked.Length == 0 ? [Plan(null)] : ranked.Select(state => Plan(state)).ToArray();
218 218 }
219 219
220 private static Travel EstimateTravel(Coordinates from, string fromSystem, Station to, PlannerRequest request)
220 internal static Travel EstimateTravel(Coordinates from, string fromSystem, Station to, PlannerRequest request)
221 221 {
222 222 var distance = from.DistanceTo(to.Position!);
223 223 var sameSystem = distance < .00001 || !string.IsNullOrWhiteSpace(fromSystem) && fromSystem.Equals(to.SystemName, StringComparison.OrdinalIgnoreCase);
@@ -265,5 +265,5 @@ public sealed partial class TradeRoutePlanner
265 265 return true;
266 266 }
267 267
268 private static double SecurityRisk(SecurityLevel level) => level switch { SecurityLevel.High => .06, SecurityLevel.Medium => .18, SecurityLevel.Low => .48, SecurityLevel.Anarchy => .78, _ => .5 };
268 internal static double SecurityRisk(SecurityLevel level) => level switch { SecurityLevel.High => .06, SecurityLevel.Medium => .18, SecurityLevel.Low => .48, SecurityLevel.Anarchy => .78, _ => .5 };
269 269 }
Added XFEEDToolBox.Test/AutomaticSupplyPlannerTests.cs +94 -0
@@ -0,0 +1,94 @@
1 using System.Collections.Concurrent;
2 using XFEEDToolBox.Core.Execution;
3 using XFEEDToolBox.Core.Models;
4 using XFEEDToolBox.Core.Planning;
5 using XFEExtension.NetCore.XUnit.Attributes;
6
7 namespace XFEEDToolBox.Test;
8
9 [TestFixture]public sealed class AutomaticSupplyPlannerTests
10 {
11 private static readonly DateTimeOffset Now=DateTimeOffset.UtcNow;
12 private static Station Station(long id,double distance=10)=>new(){MarketId=id,Name="Port "+id,SystemName="System "+id,
13 Position=new(distance,0,0),DistanceToArrivalLs=256,MaxLandingPad=LandingPad.Large,Type="Orbis",Security=SecurityLevel.High};
14 private static readonly Station Destination=Station(999,0);
15 private static CommodityQuote Quote(long id,long supply=1000000,long buy=100,long sell=1000,long demand=1000000)=>new(){MarketId=id,
16 Commodity="uranium",DisplayName="铀",BuyPrice=buy,SellPrice=sell,Supply=supply,Demand=demand,CollectedAt=Now};
17 private static PlannerRequest Request=>new(){StartMarketId=999,Credits=100000000,ReserveCredits=1000000,CargoCapacity=1200,
18 ActualShipType="Type9",RequiredPad=LandingPad.Large,CurrentPosition=new(0,0,0),CurrentSystemName=Destination.SystemName,
19 CurrentMarketId=999,SearchRadiusLy=500,MaxLegDistanceLy=500,LadenJumpRangeLy=30,AsOf=Now};
20 private static IReadOnlyList<AutomaticSupplyChoice> Calculate(Station[] sources,CommodityQuote[] quotes,PlannerRequest? request=null)
21 =>AutomaticSupplyPlanner.Calculate(new([Destination,..sources],[Quote(999),..quotes]),request??Request,Destination,["uranium"]);
22
23 [Test]public void Actual875TonSupplyLosesToFull1200LoadEvenWithAHigherPerTonSpread()
24 {
25 var choices=Calculate([Station(1),Station(2)],[Quote(1,875,buy:50),Quote(2,1185593,buy:100)]);
26 Check.Equal(2L,choices[0].Source.MarketId);Check.Equal(1200,choices[0].Order.Quantity);Check.Equal(875,choices[1].Order.Quantity);
27 Check.That(choices[0].CreditsPerHour>choices[1].CreditsPerHour);Check.Equal(1185593L,choices[0].Supply);
28 Check.Equal(choices[0].EstimatedNetProfit*60/(decimal)choices[0].EstimatedMinutes,choices[0].CreditsPerHour);
29 Check.That(choices[0].Basis.Contains("空载返程"));
30 }
31 [Test]public void LargerActualHoldsAreNotClampedAndFundsDemandAndOccupiedSpaceStillBoundOrders()
32 {
33 Check.Equal(4096,Calculate([Station(1)],[Quote(1)],Request with{CargoCapacity=4096})[0].Order.Quantity);
34 Check.Equal(3000,Calculate([Station(1)],[Quote(1)],Request with{CargoCapacity=4096,OccupiedCargo=1096})[0].Order.Quantity);
35 Check.Equal(12,Calculate([Station(1)],[Quote(1)],Request with{Credits=2200,ReserveCredits=1000})[0].Order.Quantity);
36 Check.Equal(7,Calculate([Station(1)],[Quote(999,demand:7) with{CollectedAt=Now.AddSeconds(1)},Quote(1)])[0].Order.Quantity);
37 Check.Equal(9,Calculate([Station(1)],[Quote(1)],Request with{InvestmentCredits=900})[0].Order.Quantity);
38 }
39 [Test]public void MoreStockIsNotAutomaticallyBetterThanAShorterCompleteCycle()
40 {
41 var choices=Calculate([Station(1,10),Station(2,400)],[Quote(1,1200),Quote(2,5000000)]);
42 Check.Equal(1L,choices[0].Source.MarketId);Check.That(choices[1].EstimatedMinutes>choices[0].EstimatedMinutes);
43 Check.That(choices.All(c=>c.EstimatedMinutes==c.Delivery.EstimatedMinutes+c.EmptyReturnMinutes+c.RepositionMinutes));
44 var transfer=Calculate([Station(1)],[Quote(1)],Request with{CurrentMarketId=3,CurrentPosition=new(100,0,0)})[0];
45 Check.That(transfer.RepositionMinutes>0);Check.That(transfer.EstimatedMinutes>choices[0].EstimatedMinutes);
46 }
47 [Test]public void LatestZeroOrIllegalQuoteNeverResurrectsOlderLargeStock()
48 {
49 var old=Quote(1,5000000) with{CollectedAt=Now.AddMinutes(-1)};
50 Check.Equal(0,Calculate([Station(1)],[old,Quote(1,0)]).Count);
51 Check.Equal(0,Calculate([Station(1)],[old,Quote(1) with{Illegal=true}]).Count);
52 Check.Equal(0,Calculate([Station(1)],[Quote(1) with{CollectedAt=Now.AddHours(-7)}]).Count);
53 }
54 [Test]public void OwnConfirmedPurchaseConsumesOldStockOnceAndNewLiveQuotesSupersedeThatDeduction()
55 {
56 var purchase=new ConfirmedTradeReceipt("purchase",1,"uranium",875,100,1000000,912500,0,875,"commander",8,Now.AddHours(-1),Now.AddSeconds(1),true,true);
57 var sale=purchase with{OperationKey="sale",MarketId=999,QuantityDelta=-875,ConfirmedAt=Now.AddMinutes(5)};
58 IReadOnlyDictionary<long,IReadOnlyList<CommodityQuote>> observed=new Dictionary<long,IReadOnlyList<CommodityQuote>>{[1]=[Quote(1,875)],[999]=[Quote(999)]};
59 var remaining=AutomaticSupplyPlanner.DeductConfirmedTrades(observed,[purchase,purchase,sale]);
60 Check.Equal(0L,remaining[1][0].Supply);Check.Equal(999125L,remaining[999][0].Demand);Check.Equal(875L,observed[1][0].Supply);
61 Check.Equal(0,Calculate([Station(1)],[..remaining[1]]).Count);
62 observed=new Dictionary<long,IReadOnlyList<CommodityQuote>>{[1]=[Quote(1,1234) with{CollectedAt=Now.AddMinutes(2)}]};
63 Check.Equal(1234L,AutomaticSupplyPlanner.DeductConfirmedTrades(observed,[purchase])[1][0].Supply);
64 }
65 [Test]public void ActualSnapshotReplacesOlderCacheButNewerObservedReplenishmentCanParticipate()
66 {
67 IReadOnlyDictionary<long,IReadOnlyList<CommodityQuote>> observed=new Dictionary<long,IReadOnlyList<CommodityQuote>>{[1]=[Quote(1,0)]};
68 var cached=new MarketBook([Destination,Station(1)],[Quote(999),Quote(1,5000000) with{CollectedAt=Now.AddMinutes(-1)}]);
69 var depleted=AutomaticSupplyPlanner.MergeObservations(cached,observed);
70 Check.Equal(0,AutomaticSupplyPlanner.Calculate(depleted,Request,Destination,["uranium"]).Count);
71 var refreshed=AutomaticSupplyPlanner.MergeObservations(cached with{Quotes=[Quote(999),Quote(1,2000) with{CollectedAt=Now.AddMinutes(1)}]},observed);
72 Check.Equal(1200,AutomaticSupplyPlanner.Calculate(refreshed,Request,Destination,["uranium"])[0].Order.Quantity);
73 }
74 [Test]public void ActualLargeShipPadAndUserSourceTypesApplyToEverySupplier()
75 {
76 var small=Station(1) with{MaxLandingPad=LandingPad.Medium};var carrier=Station(2) with{Type="FleetCarrier",IsFleetCarrier=true};
77 var port=Station(3);var quotes=new[]{Quote(1),Quote(2),Quote(3)};
78 var choices=Calculate([small,carrier,port],quotes,Request with{AllowedSourceKinds=StationSourceKinds.FleetCarrier});
79 Check.Equal(1,choices.Count);Check.Equal(2L,choices[0].Source.MarketId);
80 Check.Equal(0,Calculate([small],[Quote(1)],Request with{AllowUnknownMetadata=true}).Count);
81 Check.Equal(0,Calculate([port with{MaxLandingPad=LandingPad.Unknown}],[Quote(3)],Request with{AllowUnknownMetadata=true}).Count);
82 }
83 private sealed class InlineProgress(Action<TradePlanningProgress> action):IProgress<TradePlanningProgress>{public void Report(TradePlanningProgress value)=>action(value);}
84 [Test]public void All501SourcesAreComparedBeforeTakingDisplayResultsAndProgressCountsRealWork()
85 {
86 var sources=Enumerable.Range(1,501).Select(id=>Station(id)).ToArray();var quotes=sources.Select(s=>Quote(s.MarketId,s.MarketId==501?1200:1)).ToArray();
87 var events=new ConcurrentQueue<TradePlanningProgress>();
88 var result=AutomaticSupplyPlanner.Calculate(new([Destination,..sources],[Quote(999),..quotes]),Request,Destination,["uranium"],
89 progress:new InlineProgress(events.Enqueue));
90 Check.Equal(501L,result[0].Source.MarketId);Check.Equal(8,result.Count);
91 Check.That(events.Any(e=>e.Processed==501&&e.Total==501));
92 Check.That(events.All(e=>e.Total is null||e.Processed<=e.Total));
93 }
94 }
Modified XFEEDToolBox.Test/AutomaticTradeMenuIntegrationTests.cs +9 -2
@@ -54,6 +54,7 @@ public sealed class AutomaticTradeMenuIntegrationTests
54 54 {
55 55 using var temporary=new TemporaryDirectory();using var cancellation=new CancellationTokenSource(TimeSpan.FromSeconds(5));
56 56 var current=Player;var channel=new Commerce();var menus=new Menus();var leases=new List<Lease>();var visits=0;var recoveries=0;var released=0;var preflightObserved=false;
57 var protectionChecks=0;var recoveryProtectionObserved=false;
57 58 var driver=new NativeTradeCommandDriver(channel,()=>current,Path.Combine(temporary.Path,"operations"),async(_,_,token)=>
58 59 {
59 60 if(++visits==1)
@@ -72,14 +73,20 @@ public sealed class AutomaticTradeMenuIntegrationTests
72 73 }
73 74 };
74 75 Check.Equal(0,leases.Count);
75 var engine=new AutomaticTradeEngine(Configuration(temporary.Path),driver,channel,()=>current,_=>Task.CompletedTask,temporary.Path,_=>
76 var engine=new AutomaticTradeEngine(Configuration(temporary.Path),driver,channel,()=>current,_=>
77 {
78 protectionChecks++;
79 if(visits==1&&leases[0].Disposed&&released==1)recoveryProtectionObserved=true;
80 return Task.CompletedTask;
81 },temporary.Path,_=>
76 82 {
77 83 Check.That(leases.Single().Disposed&&!leases.Single().Allowed);Check.Equal(1,menus.Ends);
84 Check.That(recoveryProtectionObserved&&protectionChecks>=2,"A failed handoff requires released inputs and a fresh healthy context before recovery menu actions.");
78 85 current=current with{LoadGameAt=current.LoadGameAt!.Value.AddSeconds(1)};recoveries++;return Task.CompletedTask;
79 86 },validateFlight:_=>{Check.Equal(0,leases.Count);return Task.CompletedTask;});
80 87 engine.Progress+=progress=>{if(progress.Phase=="preflight"){preflightObserved=true;Check.Equal(0,leases.Count);}};
81 88 await engine.RunAsync(cancellation.Token).WaitAsync(TimeSpan.FromSeconds(3));
82 Check.That(preflightObserved);Check.Equal(1,recoveries);Check.Equal(2,visits);Check.Equal(1,released);
89 Check.That(preflightObserved);Check.Equal(1,recoveries);Check.Equal(2,visits);Check.Equal(2,released);
83 90 Check.That(leases.All(lease=>lease.Disposed&&!lease.Allowed));Check.That(channel.Calls.All(op=>op is "describe" or "commerce.inventory"));
84 91 }
85 92 [Test]public async Task EngineInterfaceAndFlightPreparationFailuresNeverCreateARunningScope()
Modified XFEEDToolBox.Test/AutomaticTradeRoundsTests.cs +27 -0
@@ -101,4 +101,31 @@ public class AutomaticTradeRoundsTests
101 101 File.WriteAllText(resultPath,JsonSerializer.Serialize(result,ToolboxSettings.JsonOptions));
102 102 Check.Equal(0,AutomaticTradeRounds.Refresh(Configuration(temporary.Path,false),Player));
103 103 }
104 private static void GoalProof(string directory,ulong goal=864)
105 =>File.WriteAllText(Path.Combine(directory,"membership-before.json"),JsonSerializer.Serialize(new{goalId=goal,contribution=0}));
106 [Test]public void DynamicCgSourceChangesAndRestartPreserveAllConfirmedRounds()
107 {
108 using var temporary=new TemporaryDirectory();
109 var config=Configuration(temporary.Path,false) with{Mode=AutomaticTradeMode.CommunityGoal,CommunityGoalId=864,Commodities=["gold"]};
110 GoalProof(Journey(temporary.Path,"first",1,2,0));
111 Check.Equal(1,AutomaticTradeRounds.Refresh(config,Player));
112 GoalProof(Journey(temporary.Path,"different-source",3,2,4));
113 var changed=config with{Source=Station(3)};
114 Check.Equal(2,AutomaticTradeRounds.Refresh(changed,Player));Check.Equal(2,AutomaticTradeRounds.Refresh(config,Player));
115 Check.Equal(0,AutomaticTradeRounds.Refresh(config with{Commodities=["uranium"]},Player));
116 GoalProof(Journey(temporary.Path,"other-goal",4,2,8),865);
117 Journey(temporary.Path,"unverified-goal",5,2,12);
118 Check.Equal(2,AutomaticTradeRounds.Refresh(changed,Player));
119 Check.Equal(2,Directory.EnumerateFiles(Path.Combine(temporary.Path,"rounds-v1"),"*.json",SearchOption.AllDirectories).Count());
120 }
121 [Test]public void DynamicReturnRouteRequiresTheActualSourceOfEachBatch()
122 {
123 using var temporary=new TemporaryDirectory();
124 var config=Configuration(temporary.Path) with{Mode=AutomaticTradeMode.CommunityGoal,CommunityGoalId=864,Commodities=["gold"]};
125 GoalProof(Journey(temporary.Path,"from-three",3,2,0));
126 Journey(temporary.Path,"buy-at-other-source",1,2,4,sold:false);
127 Check.Equal(0,AutomaticTradeRounds.Refresh(config,Player with{MarketId=1,LocationAt=Start.AddMinutes(5)}));
128 Check.Equal(1,AutomaticTradeRounds.Refresh(config,Player with{MarketId=3,LocationAt=Start.AddMinutes(5)}));
129 Check.Equal(1,AutomaticTradeRounds.Refresh(config with{Source=Station(4)},Player));
130 }
104 131 }
Added XFEEDToolBox/ViewModels/AutomaticSupplyViewModel.cs +59 -0
@@ -0,0 +1,59 @@
1 using System.Text.Json;
2 using CommunityToolkit.Mvvm.ComponentModel;
3 using XFEEDToolBox.Core.Execution;
4 using XFEEDToolBox.Core.Models;
5 using XFEEDToolBox.Core.Planning;
6
7 namespace XFEEDToolBox.ViewModels;
8
9 public partial class ToolboxViewModel
10 {
11 [ObservableProperty]public partial string AutomaticSupplySummary{get;set;}="";
12
13 private async Task<AutomaticSupplyChoice?> SelectAutomaticSupplyAsync(AutomaticSupplyRequest value,CancellationToken token)
14 {
15 var config=value.Configuration;var player=value.Player;var settings=runtime.Settings;
16 if(value.Credits<=value.Reserve)return null;
17 if(player.MaxJumpRange is not (>0 and <=500))throw new InvalidOperationException("正在等待当前飞船的实际跳跃射程,未使用假定船型参数。");
18 var pad=ShipPadPolicy.RequiredPad(player.Ship);
19 var saved=config.SourcePlanning??settings.Planner;
20 var center=config.SourceCenterFollowsPlayer?player.Position:saved.SearchCenterPosition??settings.MarketCacheCenterPosition;
21 var request=saved with{StartMarketId=config.Destination.MarketId,Credits=value.Credits,ReserveCredits=value.Reserve,
22 InvestmentCredits=config.InvestmentCredits,CargoCapacity=player.CargoCapacity,OccupiedCargo=player.CargoCount,
23 ActualShipType=player.Ship,ShipRequiredPad=pad,RequiredPad=(LandingPad)Math.Max((int)pad,(int)saved.RequiredPad),
24 CurrentPosition=player.Position,CurrentSystemName=player.SystemName,CurrentMarketId=player.MarketId,
25 LadenJumpRangeLy=player.MaxJumpRange.Value,JumpRangeIsEstimate=true,SearchCenterPosition=center,
26 SearchCenterName=config.SourceCenterFollowsPlayer?player.SystemName:saved.SearchCenterName,
27 SearchRadiusLy=config.SourcePlanning?.SearchRadiusLy??settings.MarketCacheRadiusLy,AsOf=DateTimeOffset.UtcNow};
28 var membershipPath=Path.Combine(config.WorkingDirectory,"membership","membership-latest.json");
29 var membership=JsonSerializer.Deserialize<OriginalCommunityGoalRead>(await File.ReadAllTextAsync(membershipPath,token),ToolboxSettings.JsonOptions)
30 ??throw new InvalidDataException("CG报名与接收商品尚未就绪。");
31 var goal=CommunityGoalCatalogue.RequireDestinationTrade(membership.Goals,config.Destination.MarketId,config.CommunityGoalId,request.AsOf,true);
32 var accepted=config.Commodities.Intersect(goal.AcceptedCommodities,StringComparer.Ordinal).ToArray();
33 if(accepted.Length==0)throw new InvalidDataException("所选商品不在当前有效CG的实际接收范围内。");
34 var ready=new TaskCompletionSource<IProgress<TradePlanningProgress>>(TaskCreationOptions.RunContinuationsAsynchronously);
35 using var cancellation=token.Register(()=>ready.TrySetCanceled(token));
36 if(!dispatcher.TryEnqueue(()=>{if(!token.IsCancellationRequested)ready.TrySetResult(BeginTradePlanningProgress(token));}))
37 throw new OperationCanceledException("规划界面已关闭。",token);
38 var progress=await ready.Task.ConfigureAwait(false);
39 try
40 {
41 return await Task.Run(async()=>
42 {
43 var cached=runtime.Markets.LoadPlanningRegion(request,token:token,progress:progress);
44 var stations=cached.Stations.Append(config.Source).Append(config.Destination).DistinctBy(s=>s.MarketId).ToArray();
45 // A live station snapshot replaces its cached rows completely,
46 // including zero stock and commodities no longer offered.
47 var merged=AutomaticSupplyPlanner.MergeObservations(cached with{Stations=stations},value.ObservedQuotes);
48 var choices=AutomaticSupplyPlanner.Calculate(merged,request,config.Destination,accepted,token:token,progress:progress);
49 var proof=new{at=request.AsOf,goalId=goal.Id,request,stations=cached.Stations.Count,quotes=merged.Quotes.Count,
50 supplyBasis="缓存供需估算;实际到站报价优先,采购前及每轮交付后重算",choices};
51 var path=Path.Combine(config.WorkingDirectory,"supply-candidates-latest.json");
52 await File.WriteAllTextAsync(path+".tmp",JsonSerializer.Serialize(proof,ToolboxSettings.JsonOptions),token);
53 File.Move(path+".tmp",path,true);
54 return choices.FirstOrDefault();
55 },token).ConfigureAwait(false);
56 }
57 finally{dispatcher.TryEnqueue(EndTradePlanningProgress);}
58 }
59 }
Modified XFEEDToolBox/ViewModels/AutomaticTradeViewModel.cs +5 -2
Modified XFEEDToolBox/Views/PlannerPage.xaml +1 -1
Modified XFEEDToolBox/XFEEDToolBox.csproj +1 -1
Added docs/INTERFACE-0.4.24.md +19 -0
Added docs/VALIDATION-0.4.24.md +9 -0