返回提交历史
Modified
README.md
+4
-2
Modified
Start-Toolbox.cmd
+8
-0
Added
XFEEDToolBox.Core/Execution/AutomaticSupplyPlanner.cs
+101
-0
Modified
XFEEDToolBox.Core/Execution/AutomaticTradeConfiguration.cs
+4
-0
Modified
XFEEDToolBox.Core/Execution/AutomaticTradeEngine.cs
+83
-18
Modified
XFEEDToolBox.Core/Execution/AutomaticTradeRounds.cs
+21
-8
Modified
XFEEDToolBox.Core/Models/TradingModels.cs
+1
-1
Modified
XFEEDToolBox.Core/Planning/TradeRoutePlanner.cs
+3
-3
Added
XFEEDToolBox.Test/AutomaticSupplyPlannerTests.cs
+94
-0
Modified
XFEEDToolBox.Test/AutomaticTradeMenuIntegrationTests.cs
+9
-2
Modified
XFEEDToolBox.Test/AutomaticTradeRoundsTests.cs
+27
-0
Added
XFEEDToolBox/ViewModels/AutomaticSupplyViewModel.cs
+59
-0
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
XFEstudio/XFEEDToolBox
fix: rank CG suppliers by actual cargo and full-cycle hourly profit
5ec00d5
代码差异
17 个文件
+449
-38
@@ -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
@@ -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%"
@@ -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
}
@@ -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;
@@ -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&¤tMarket!=configuration.Source.MarketId&¤tMarket!=configuration.Destination.MarketId)
248
if(player().Docked&&player().MarketId is {} currentMarket&¤tMarket!=source.MarketId&¤tMarket!=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);
@@ -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&¤t.Docked&¤t.MarketId==configuration.Source.MarketId&¤t.LocationAt is {} dockedAt
96
if(current.InGame&¤t.Docked&¤t.MarketId==home&¤t.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
@@ -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。");
@@ -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
}
@@ -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
}
@@ -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()
@@ -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
}
@@ -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
}