XFEstudio/XFEEDToolBox
Fix toolbox-owned trade execution and live handoff recovery
Connect configured original docking and cruise components, initialize route planning before use, and wait for fresh protection after scene changes. Preserve completed purchases and propagate failed journey details for recovery. Read fractional market quantities conservatively without changing prices. Reuse verified CG membership at the supply station and confirm delivery through the original Homer interface. Use original Journal evidence when the derived event index is unavailable. Reconcile owned SCO stop/renewal races against matching original receipts before continuing. Validation: Release build with no warnings or errors; actual 192t buy/service, departure, jump, software cruise, original docking, sale/service, wallet and CG contribution reconciliation, followed by the next automatic cycle. SCO qualification and original interdiction escape remain incomplete.
c479eb6
代码差异
@@ -14,11 +14,16 @@ public sealed record AutomaticTradeConfiguration
public bool RequireSupercruiseOvercharge{get;init;}=true;
public bool RequireInterdictionEscape{get;init;}=true;
public bool ValidateMenuReentry{get;init;}=true;
public string? ConfirmedCommunityGoalJourney{get;init;}
public void Validate()
{
if(!RequireSupercruiseOvercharge||!RequireInterdictionEscape)
throw new InvalidDataException("自动跑商需要启用SCO温度燃油控制与原始逃逸矢量保护。");
if(ConfirmedCommunityGoalJourney is not null&&!Path.IsPathFullyQualified(ConfirmedCommunityGoalJourney))
throw new InvalidDataException("已确认CG旅程必须使用完整路径。");
if(Schema!=1||Source.MarketId<=0||Destination.MarketId!=CommunityGoalCatalogue.HomerMarket||Source.MarketId==Destination.MarketId
||string.IsNullOrWhiteSpace(Source.Name)||string.IsNullOrWhiteSpace(Source.SystemName)||string.IsNullOrWhiteSpace(Destination.Name)||string.IsNullOrWhiteSpace(Destination.SystemName)
||!Path.IsPathFullyQualified(WorkingDirectory)||EmptyValidationVisits is <1 or >10||CargoCapacity is <1 or >192||ReserveCredits<0)
||!Path.IsPathFullyQualified(WorkingDirectory)||EmptyValidationVisits is <0 or >10||CargoCapacity is <1 or >192||ReserveCredits<0)
throw new InvalidDataException("自动跑商配置缺少明确站点、工作目录、空载验收次数或已验证货舱参数。");
}
}
@@ -146,10 +146,20 @@ public sealed class NativeTradeCommandDriver:IConfirmedTradeCommandDriver,IConfi
var page=offset==0?snapshot:await channel.CallAsync(new{op="commerce.market.status",generation,offset,maximum=50},token);
foreach(var row in page.GetProperty("commodities").EnumerateArray())
{
long Number(string name)=>row.TryGetProperty(name,out var value)&&value.ValueKind==JsonValueKind.Number?value.GetInt64():0;
long Number(string field,bool quantity=false)
{
if(!row.TryGetProperty(field,out var value)||value.ValueKind!=JsonValueKind.Number)return 0;
if(value.TryGetInt64(out var integer)&&integer>=0)return integer;
if(!value.TryGetDecimal(out var number)||number<0||number>long.MaxValue||!quantity&&decimal.Truncate(number)!=number)
throw new InvalidDataException($"Original market field {field} has an invalid value: {value.GetRawText()}");
// The server can quote fractional supply or demand.
// Only whole tonnes are available for an order.
return decimal.ToInt64(quantity?decimal.Floor(number):number);
}
var name=CommodityCatalog.Key(row.GetProperty("name").GetString()??"");
rows.Add(new(){MarketId=market,Commodity=name,DisplayName=name,BuyPrice=Number("buyPrice"),SellPrice=Number("sellPrice"),Supply=Number("stock"),
Demand=Number("demand"),CollectedAt=at,Source=DataSource.Journal,Illegal=row.TryGetProperty("illegal",out var illegal)&&illegal.ValueKind==JsonValueKind.True});
rows.Add(new(){MarketId=market,Commodity=name,DisplayName=name,BuyPrice=Number("buyPrice"),SellPrice=Number("sellPrice"),Supply=Number("stock",true),
Demand=Number("demand",true),CollectedAt=at,Source=DataSource.Journal,Illegal=row.TryGetProperty("illegal",out var illegal)&&illegal.ValueKind==JsonValueKind.True
||row.TryGetProperty("legality",out var legality)&&legality.ValueKind==JsonValueKind.String&&!string.IsNullOrEmpty(legality.GetString())});
}
}
return rows;
@@ -4,6 +4,8 @@ using XFEEDToolBox.Core.Models;
namespace XFEEDToolBox.Core.Execution;
public sealed record NativeTradeJourneyResult(bool Passed,TradeJourneyCheckpoint Checkpoint,TradeProfitSnapshot Profit,long OpeningCredits,long? ClosingCredits,string? Failure,DateTimeOffset Finished);
public sealed class NativeTradeJourneyIncompleteException(NativeTradeJourneyResult result,Exception? cause):InvalidOperationException("跑商批次未完成,原始货物和回执已保留。",cause)
{public NativeTradeJourneyResult Result{get;}=result;}
/// <summary>A resumable production cargo journey, driven entirely by the toolbox.</summary>
public sealed class NativeTradeJourney(NativeTradeCommandDriver driver,Func<PlayerState> player,Func<CancellationToken,Task> check,string directory)
@@ -53,7 +55,7 @@ public sealed class NativeTradeJourney(NativeTradeCommandDriver driver,Func<Play
}
void RecordService(ConfirmedDockedServiceReceipt receipt)=>ledger.Record(receipt.ToCashEntry());
driver.TradeConfirmed+=RecordTrade;driver.ServiceConfirmed+=RecordService;
using var stop=CancellationTokenSource.CreateLinkedTokenSource(token);Task? services=null;long? closing=null;string? failure=null;
using var stop=CancellationTokenSource.CreateLinkedTokenSource(token);Task? services=null;long? closing=null;string? failure=null;Exception? cause=null;
var saleWasAlreadyConfirmed=checkpoint.Sold is not null;
try
{
@@ -91,7 +93,7 @@ public sealed class NativeTradeJourney(NativeTradeCommandDriver driver,Func<Play
if(!player().Docked||player().MarketId!=leg.To.MarketId||player().CargoCount!=0||checkpoint.Sold is null||opening+ledger.Snapshot.CashChange!=closing)
throw new InvalidDataException("本批次原始余额、空货舱或实际停靠尚未完整核对。");
}
catch(Exception error){failure=error.ToString();stop.Cancel();}
catch(Exception error){cause=error;failure=error.ToString();stop.Cancel();}
finally
{
if(services is not null)try{await services;}catch(Exception error){failure??=error.ToString();}
@@ -101,7 +103,7 @@ public sealed class NativeTradeJourney(NativeTradeCommandDriver driver,Func<Play
var attempts=Path.Combine(directory,"attempts");Directory.CreateDirectory(attempts);
await WriteAsync(Path.Combine(attempts,Guid.NewGuid().ToString("N")+".json"),result,CancellationToken.None);
await WriteAsync(resultPath,result,CancellationToken.None);
if(!result.Passed)throw new InvalidOperationException("跑商批次未完成,原始货物和回执已保留。",new InvalidDataException(failure));
if(!result.Passed)throw new NativeTradeJourneyIncompleteException(result,cause);
return result;
}
private static async Task WriteAsync(string path,object value,CancellationToken token)
@@ -12,10 +12,18 @@ public sealed class NativeTradeNavigation(int processId,string gameHash,Func<Pla
private readonly SemaphoreSlim gate=new(1,1);
public event Action<string>? Status;
private NativeGameInputChannel Inputs()=>new(processId,gameHash,pluginDirectory);
private async Task ReadyAsync(Func<CancellationToken,Task> beforeInput,CancellationToken token)
{
await NavigationRecovery.ReadWithRecoveryAsync(async cancellation=>
{
await beforeInput(cancellation);await ensureProtection(cancellation);return true;
},()=>Status?.Invoke("正在等待场景切换后的连续健康遥测,恢复后继续原航程。"),token);
}
private async Task<NativeSystemFlightControlDriver> LocalAsync(CancellationToken token)
{
var driver=new NativeSystemFlightControlDriver(processId,gameHash,player,Inputs(),ensureProtection,useNativeSupercruiseAssist:false);
await driver.ConfigureOriginalDockingControlAsync(Path.Combine(pluginDirectory,"DockingControl"),token);
await driver.ConfigureOriginalCruiseControlAsync(fuelReserve,token:token);
return driver;
}
private async Task<(CurrentSystemSnapshot Catalogue,SystemLocation Location)> ResolveAsync(Station destination,CancellationToken token)
@@ -33,7 +41,7 @@ public sealed class NativeTradeNavigation(int processId,string gameHash,Func<Pla
await gate.WaitAsync(token);
try
{
await beforeInput(token);await ensureProtection(token);
await ReadyAsync(beforeInput,token);
var initial=player();
if(initial.Docked&&initial.MarketId==destination.MarketId)return;
if(!initial.SystemName.Equals(destination.SystemName,StringComparison.OrdinalIgnoreCase))
@@ -52,12 +60,13 @@ public sealed class NativeTradeNavigation(int processId,string gameHash,Func<Pla
}
finally{await departure.ReleaseSystemFlightAsync();}
}
await beforeInput(token);await ensureProtection(token);
await ReadyAsync(beforeInput,token);
var galaxy=await new NativeGalaxyCatalog(processId).FindAsync(destination.SystemName,token);
var waypoint=new NavWaypoint(galaxy.SystemAddress??throw new InvalidDataException("目的星系身份缺失。"),galaxy.Name,galaxy.Position,galaxy.StarClass);
var interstellar=new NativeFlightControlDriver(processId,gameHash,player,fuelReserve,Inputs(),ensureProtection);
try
{
await NavigationRecovery.PreflightWithRecoveryAsync(interstellar,()=>Status?.Invoke("正在连接星际导航驱动"),token);
await new GameRoutePlanner(player).PlanAsync(waypoint,interstellar,null,token);
var route=new NavigationRunner(player);string? issue=null;
route.Progress+=value=>{issue=value.Message;Status?.Invoke(value.Message);};
@@ -67,12 +76,13 @@ public sealed class NativeTradeNavigation(int processId,string gameHash,Func<Pla
}
finally{await interstellar.ReleaseAsync();}
}
await beforeInput(token);await ensureProtection(token);
await ReadyAsync(beforeInput,token);
var resolved=await ResolveAsync(destination,token);
var target=Target(resolved.Catalogue.SystemAddress,resolved.Location);
var local=await LocalAsync(token);var arrival=new SystemNavigationRunner(player);string? lastIssue=null;
arrival.Progress+=value=>{lastIssue=value.Message;Status?.Invoke(value.Message);};
await arrival.RunAsync(target,local,token);
if(arrival.HandoffFailed)throw new DockingHandoffException(lastIssue??"原生自动降落交接未完成。",destination.MarketId);
if(arrival.State!=NavigationRunState.Completed||!player().Docked||player().MarketId!=destination.MarketId)
throw new InvalidOperationException(lastIssue??"目的站停靠尚未确认完成。");
}
@@ -8,14 +8,14 @@ public sealed record NativeCruiseControlCapabilities(bool Available,bool Qualifi
/// <summary>Program-owned SCO policy and bounded original-command receipts. Never steers or selects a target.</summary>
public sealed class NativeCruiseControlChannel(int processId,string gameSha256)
{
public const string Service="CruiseControl0514c";
public const string Service="CruiseControl0514d";
private readonly NavigationPipeClient pipe=new(processId,Service);
private readonly CruiseOverchargeSampleHistory history=new(gameSha256);
private readonly CruiseOverchargePolicy policy=new();
private ulong entity,world;
private bool leaseRequested,originalStartWitness;
private string owner="";
private string? pendingStopId,lastStopIdentity;
private string? pendingStopId,lastStopIdentity,originalStartRequestId;
public JsonElement? LastStatus{get;private set;}
public CruiseOverchargeObservation? LastObservation{get;private set;}
public CruiseOverchargeDecision? LastDecision{get;private set;}
@@ -24,11 +24,26 @@ public sealed class NativeCruiseControlChannel(int processId,string gameSha256)
public event Action<JsonElement>? OriginalStopReceipt;
private static bool Flag(JsonElement value,string key)=>value.TryGetProperty(key,out var found)&&found.ValueKind==JsonValueKind.True;
private static ulong Identity(JsonElement value,string key)=>ulong.Parse(value.GetProperty(key).GetString()!,CultureInfo.InvariantCulture);
private static bool Key(JsonElement value,string key)=>value.TryGetProperty(key,out var item)&&item.ValueKind==JsonValueKind.String
&&item.GetString() is {Length:32} text&&text.All(c=>c is >= '0' and <= '9' or >= 'a' and <= 'f');
private void CheckIdentity(JsonElement result)
{
if(result.GetProperty("schema").GetInt32()!=1||result.GetProperty("processId").GetInt32()!=processId
||result.GetProperty("gameSha256").GetString()!=gameSha256||result.GetProperty("service").GetString()!=Service
||!Flag(result,"overchargeControl")||!Flag(result,"enabled"))throw new InvalidDataException("Original SCO controller does not match this client.");
||!Flag(result,"overchargeControl")||!Flag(result,"enabled")||!Flag(result,"originalFuelRangeObservations")
||!Flag(result,"nativeStopReceipts")||!Flag(result,"expiringOriginalControl")||result.GetProperty("controlTelemetrySchema").GetInt32()!=1
||result.GetProperty("owningThread").GetInt32()<=0)throw new InvalidDataException("Original SCO controller does not match this client.");
if(Flag(result,"overchargeControlQualified"))
{
if(!result.TryGetProperty("qualifiedCycle",out var cycle)||cycle.ValueKind!=JsonValueKind.Object
||!Key(cycle,"owner")||!Key(cycle,"startRequestId")||!Key(cycle,"releaseRequestId")
||cycle.GetProperty("selectionGeneration").GetUInt64()==0||Identity(cycle,"entityId")==0||Identity(cycle,"referenceWorldAddress")==0
||cycle.GetProperty("activeSamples").GetInt32()<5)throw new InvalidDataException("Original SCO qualification has no matching live cycle.");
var enabledAt=cycle.GetProperty("enableAtUnixMilliseconds").GetUInt64();var activeAt=cycle.GetProperty("lastActiveAtUnixMilliseconds").GetUInt64();
var stopAt=cycle.GetProperty("originalStopAtUnixMilliseconds").GetUInt64();var offAt=cycle.GetProperty("offAtUnixMilliseconds").GetUInt64();
if(enabledAt==0||activeAt<enabledAt||stopAt<activeAt||offAt<=stopAt||offAt>(ulong)DateTimeOffset.UtcNow.AddMilliseconds(100).ToUnixTimeMilliseconds())
throw new InvalidDataException("Original SCO qualification timestamps do not establish enable, Active, stop and subsequent Off.");
}
}
private void Capture(JsonElement result)
{
@@ -50,7 +65,7 @@ public sealed class NativeCruiseControlChannel(int processId,string gameSha256)
{
if(owner.Length!=0&&owner!=controllerOwner)throw new InvalidOperationException("SCO channel is already bound to another controller.");
owner=controllerOwner;
var mayBoost=flight.Mode==LocalFlightMode.Supercruise&&decision.Actions==SystemNavigationAction.None
var mayBoost=flight.Mode==LocalFlightMode.Supercruise&&(decision.Actions&~SystemNavigationAction.CancelOwnedAssists)==SystemNavigationAction.None
&&decision.Phase is SystemNavigationPhase.Supercruise or SystemNavigationPhase.Aligning;
if(!mayBoost){await ReleaseAsync(token);return;}
if(flight.GameSha256!=gameSha256||flight.ShipEntity==0||flight.ReferenceWorld==0||selectionGeneration==0)
@@ -61,6 +76,13 @@ public sealed class NativeCruiseControlChannel(int processId,string gameSha256)
entity=flight.ShipEntity;world=flight.ReferenceWorld;history.Reset();originalStartWitness=false;
}
var status=await pipe.CallAsync(new{op="read"},token);Capture(status);
if(leaseRequested&&!Flag(status,"owned"))
{
// The native guard may stop our burst between updates. Observe
// its real Off state before requesting another original command.
await ReleaseAsync(token);
throw new FlightTelemetryUnavailableException("Original SCO burst ended; refresh flight telemetry before continuing.");
}
var observed=history.Read(status,flight,DateTimeOffset.UtcNow);LastObservation=observed;
if(!Flag(status,"owned")&&status.TryGetProperty("sample",out var sample)&&sample.ValueKind==JsonValueKind.Object&&!Flag(sample,"active"))originalStartWitness=false;
var selected=policy.Decide(flight,observed,reserve,DateTimeOffset.UtcNow);LastDecision=selected;
@@ -68,9 +90,23 @@ public sealed class NativeCruiseControlChannel(int processId,string gameSha256)
var requestId=Guid.NewGuid().ToString("N");
// Mark before IPC: a lost reply may still have queued an original call.
leaseRequested=true;
var result=await pipe.CallAsync(new{op="cruise.set",owner,requestId,selectionGeneration,sequence=flight.Sequence,
expiresAtUnixMilliseconds=deadline.ToUnixTimeMilliseconds(),requiredFuelReserveTonnes=reserve,
target=new{key=target.NativeKey,bodyAddress=target.BodyAddress.ToString(CultureInfo.InvariantCulture),kind=target.NativeKind,marketId=target.MarketId??uint.MaxValue,name=target.Name}},token);
JsonElement result;
try
{
result=await pipe.CallAsync(new{op="cruise.set",owner,requestId,selectionGeneration,sequence=flight.Sequence,
expiresAtUnixMilliseconds=deadline.ToUnixTimeMilliseconds(),requiredFuelReserveTonnes=reserve,
target=new{key=target.NativeKey,bodyAddress=target.BodyAddress.ToString(CultureInfo.InvariantCulture),kind=target.NativeKind,marketId=target.MarketId??uint.MaxValue,name=target.Name}},token);
}
catch(InvalidOperationException error)when(error.Message=="SCO was already active outside this controller."&&originalStartWitness)
{
var stopped=await pipe.CallAsync(new{op="read"},token);Capture(stopped);
if(Flag(stopped,"owned")||!stopped.TryGetProperty("lastOriginalStop",out var stop)||stop.ValueKind!=JsonValueKind.Object
||stop.GetProperty("owner").GetString()!=owner||stop.GetProperty("startRequestId").GetString()!=originalStartRequestId
||stop.GetProperty("selectionGeneration").GetUInt64()!=selectionGeneration||Identity(stop,"entityId")!=entity||Identity(stop,"referenceWorldAddress")!=world
||!Flag(stop,"nativeMethodCalled")||Flag(stop,"argument")||stop.GetProperty("nativeMethodRva").GetString()!="2eaacd0")throw;
await ReleaseAsync(token);
throw new FlightTelemetryUnavailableException("Original SCO stop overlapped its renewal; refresh telemetry before continuing.",error);
}
var command=result.GetProperty("generation").GetUInt64();
while(true)
{
@@ -89,6 +125,7 @@ public sealed class NativeCruiseControlChannel(int processId,string gameSha256)
if(receipt.GetProperty("nativeMethodRva").GetString()!="2eaacd0"||!Flag(receipt,"argument")||receipt.GetProperty("actualStateAfterCall").GetInt32() is not(1 or 2))
throw new InvalidDataException("Original SCO enable was not confirmed.");
originalStartWitness=true;
originalStartRequestId=requestId;
}
else if(!originalStartWitness)throw new InvalidDataException("SCO renewal has no original enable witness.");
OriginalReceipt?.Invoke(receipt.Clone());return;
@@ -9,7 +9,7 @@ namespace XFEEDToolBox.Core.Navigation;
public sealed class NavigationPipeClient(int processId,string service="Navigation")
{
public int ProcessId { get; }=processId>0?processId:throw new ArgumentOutOfRangeException(nameof(processId));
private readonly string serviceName=service is "Navigation" or "Interaction" or "SessionRecovery0514" or "SessionRecovery0514c" or "SessionRecovery0514d" or "SessionRecovery0514e" or "SessionRecovery0514f" or "SessionRecovery0514g" or "SessionRecovery0514h" or "SessionRecovery0514i" or "SessionRecovery0514j" or "SessionRecovery0514k" or "CruiseObservation0514a" or "CruiseObservation0514b" or "CruiseObservation0514c" or "CruiseControl0514a" or "CruiseControl0514b" or "CruiseControl0514c"?service:throw new ArgumentException("Unknown game service.",nameof(service));
private readonly string serviceName=service is "Navigation" or "Interaction" or "SessionRecovery0514" or "SessionRecovery0514c" or "SessionRecovery0514d" or "SessionRecovery0514e" or "SessionRecovery0514f" or "SessionRecovery0514g" or "SessionRecovery0514h" or "SessionRecovery0514i" or "SessionRecovery0514j" or "SessionRecovery0514k" or "CruiseObservation0514a" or "CruiseObservation0514b" or "CruiseObservation0514c" or "CruiseControl0514a" or "CruiseControl0514b" or "CruiseControl0514c" or "CruiseControl0514d"?service:throw new ArgumentException("Unknown game service.",nameof(service));
private readonly SemaphoreSlim gate=new(1,1);
public async Task<JsonElement> CallAsync(object request,CancellationToken token=default)
{
@@ -10,7 +10,7 @@ public sealed partial class NativeSystemFlightControlDriver
if(useNativeSupercruiseAssist)throw new InvalidOperationException("Original SCO requires toolbox navigation with game SCA disabled.");
var state=player();if(allowUnqualifiedEmptyTrial&&(!state.InGame||!state.Docked||state.CurrentShipId is null||state.CargoCount!=0))throw new InvalidOperationException("Unqualified SCO testing requires the empty ship to remain docked during setup.");
var channel=new NativeCruiseControlChannel(processId,gameHash);var capabilities=await channel.InspectAsync(token);
if(!capabilities.Available||!allowUnqualifiedEmptyTrial&&!capabilities.Qualified)throw new NotSupportedException("Original SCO controls have not completed live qualification.");
if(!capabilities.Available)throw new NotSupportedException("Original SCO controller is unavailable.");
originalCruiseControl=channel;originalCruiseFuelReserve=fuelReserve;
}
private Task UpdateOriginalCruiseAsync(SystemFlightDecision decision,SystemFlightFrame frame,SystemNavigationTarget target,DateTimeOffset deadline,CancellationToken token)
@@ -44,7 +44,12 @@ public sealed partial class NativeSystemFlightControlDriver(int processId,string
owningThread=hello.GetProperty("owningThread").GetInt32();
nativeDockingThrottle=Flag(hello,"nativeDockingThrottle");
cooldownFlightControls=Flag(hello,"localCooldownControls");
return new(gameHash,Flag(hello,"localTargets"),Flag(hello,"localCruiseControl"),Flag(hello,"localDockingRequests"),Flag(hello,"localNativeAssists"),
var applicationFlight = !useNativeSupercruiseAssist && originalCruiseControl is not null
&& Flag(hello,"localTargets") && Flag(hello,"applicationSteeringAxes") && Flag(hello,"referenceGeometry") && Flag(hello,"adaptiveFlightControl")
&& hello.TryGetProperty("localNavigationSchema",out var localSchema) && localSchema.GetInt32()==1;
var originalDocking = dockingResetPipe is not null && requestOriginalDockingStart;
return new(gameHash,Flag(hello,"localTargets"),Flag(hello,"localCruiseControl")||applicationFlight,
Flag(hello,"localDockingRequests")||originalDocking,Flag(hello,"localNativeAssists")||originalDocking,
Flag(hello,"localDockingPaths"),Flag(hello,"localPlanetaryApproach"),hello.GetProperty("evidence").GetString()??"");
}
public async Task SelectAsync(SystemNavigationTarget target,CancellationToken token)
@@ -21,8 +21,9 @@ public sealed class SystemFlightNavigator
public SystemFlightDecision Decide(SystemFlightFrame frame,SystemNavigationTarget target,DateTimeOffset now)
{
target.Validate();
if(!frame.InGame||frame.PlayerOverride||frame.ExternalThreat)
return Stop("已停止导航并交还飞船控制。");
if(!frame.InGame)return Stop("游戏尚未处于驾驶场景,已交还控制。");
if(frame.PlayerOverride)return Stop("原生接口报告玩家接管,已交还飞船控制。");
if(frame.ExternalThreat)return Stop("已检测到外部威胁,已停止导航并交还控制。");
if(frame.SystemAddress!=target.SystemAddress||frame.SelectedTarget is not {} selected||!target.SameIdentity(selected))
return Stop("游戏目标或所在星系已改变,已停止导航。");
if(!Valid(frame,now))return Wait("正在重新读取目标与飞行数据。");
@@ -13,6 +13,8 @@ public interface ISystemFlightControlDriver
Task SuspendSystemFlightAsync()=>ReleaseSystemFlightAsync();
}
public sealed record SystemNavigationProgress(NavigationRunState State,SystemNavigationTarget Target,SystemNavigationPhase Phase,string Message);
public sealed class DockingHandoffException(string message,long marketId):InvalidOperationException(message)
{public long MarketId{get;}=marketId;}
/// <summary>One local leg, with expiring inputs, actual target acknowledgements and bounded telemetry recovery.</summary>
public sealed class SystemNavigationRunner(Func<PlayerState> player,Func<DateTimeOffset>? clock=null)
@@ -21,6 +23,7 @@ public sealed class SystemNavigationRunner(Func<PlayerState> player,Func<DateTim
private readonly object gate=new();
private CancellationTokenSource? cancellation;
public NavigationRunState State{get;private set;}
public bool HandoffFailed{get;private set;}
public event Action<SystemNavigationProgress>? Progress;
public void Stop(){lock(gate)cancellation?.Cancel();}
public async Task RunAsync(SystemNavigationTarget target,ISystemFlightControlDriver driver,CancellationToken token=default)
@@ -31,7 +34,7 @@ public sealed class SystemNavigationRunner(Func<PlayerState> player,Func<DateTim
if(cancellation is not null)throw new InvalidOperationException("星系内导航已在运行。");
stop=CancellationTokenSource.CreateLinkedTokenSource(token);cancellation=stop;
}
var phase=SystemNavigationPhase.Preparing;var state=NavigationRunState.Running;var terminal="星系内导航已停止。";
HandoffFailed=false;var phase=SystemNavigationPhase.Preparing;var state=NavigationRunState.Running;var terminal="星系内导航已停止。";
void Report(SystemNavigationPhase next,string message){phase=next;State=NavigationRunState.Running;Progress?.Invoke(new(State,target,next,message));}
try
{
@@ -65,7 +68,10 @@ public sealed class SystemNavigationRunner(Func<PlayerState> player,Func<DateTim
if(decision.Phase==SystemNavigationPhase.Stopped)
{
if(decision.Actions.HasFlag(SystemNavigationAction.ExitForFailedHandoff))
{
HandoffFailed=true;
await driver.ApplySystemFlightAsync(decision,frame,target,frame.ObservedAt.AddMilliseconds(250),stop.Token);
}
state=NavigationRunState.Stopped;terminal=decision.Message;break;
}
await driver.ApplySystemFlightAsync(decision,frame,target,frame.ObservedAt.AddMilliseconds(250),stop.Token);
@@ -45,7 +45,7 @@ public partial class App : Application
{
await Runtime.InitializeAsync();
await ViewModel.InitializeAsync();
if(GetArgument(commandLine,"--automatic-trade") is {} automaticTrade){await ViewModel.RunAutomaticTradeAsync(automaticTrade);return;}
if(GetArgument(commandLine,"--automatic-trade") is {} automaticTrade){AppShellPage.Current!.Navigate(typeof(PlannerPage));await ViewModel.RunAutomaticTradeAsync(automaticTrade);return;}
if(GetArgument(commandLine,"--smoke-current-system") is {} systemReport){await RunCurrentSystemProbeAsync(systemReport);return;}
if(GetArgument(commandLine,"--smoke-interface") is {} interfaceReport){await RunInterfaceOnlyProbeAsync(interfaceReport);return;}
if(GetArgument(commandLine,"--smoke-ship-status") is {} shipStatusReport){await RunShipStatusProbeAsync(shipStatusReport);return;}
@@ -1,4 +1,6 @@
using System.Text.Json;
using System.Security.Cryptography;
using System.Text;
using XFEEDToolBox.Core.Execution;
using XFEEDToolBox.Core.Models;
using XFEEDToolBox.Core.Navigation;
@@ -8,6 +10,7 @@ namespace XFEEDToolBox.ViewModels;
public partial class ToolboxViewModel
{
private sealed record AutomaticTradeMembership(string CommanderId,int ProcessId,DateTimeOffset ObservedAt,OriginalCommunityGoalRead Read);
private CancellationTokenSource? automaticTradeStop;
internal async Task RunAutomaticTradeAsync(string configurationPath)
{
@@ -19,6 +22,7 @@ public partial class ToolboxViewModel
using var stop=CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);automaticTradeStop=stop;RunActive=true;
var token=stop.Token;var root=configuration.WorkingDirectory;var phase="preflight";var initial=runtime.Player;
var completedVisits=0;var completedBatches=0;decimal net=0;string? activeJourney=null;NativeTradeCommandDriver? driver=null;
var menuReentryVerified=true;int pid=0;decimal reserve=0;
void RefreshCompleted()
{
net=0;completedBatches=0;
@@ -32,7 +36,7 @@ public partial class ToolboxViewModel
}
async Task State(string value,string? issue=null)
{
phase=value;runtime.Log("自动跑商",issue??value);
phase=value;RunStatus=issue is null?"自动跑商中":"需要处理";RunMessage=issue??value;ActualProfit=$"{net:N0} Cr";ProfitLabel="本次已确认净利润";runtime.Log("自动跑商",issue??value);
var payload=new{at=DateTimeOffset.UtcNow,phase,issue,executor="XFEEDToolBox",agentControlsGame=false,scheduledTask=false,completedVisits,completedBatches,activeJourney,
runtime.Player.SystemName,runtime.Player.StationName,runtime.Player.Docked,runtime.Player.CargoCount,netProfit=net};
var path=Path.Combine(root,"state.json");await File.WriteAllTextAsync(path+".tmp",JsonSerializer.Serialize(payload,ToolboxSettings.JsonOptions));File.Move(path+".tmp",path,true);
@@ -48,20 +52,109 @@ public partial class ToolboxViewModel
async Task Travel(Station station)
{
if(runtime.Player.Docked&&runtime.Player.MarketId==station.MarketId)return;
await driver!.ExecuteAsync(new(TradeActionKind.Travel,new(){From=runtime.Player.MarketId==configuration.Source.MarketId?configuration.Source:configuration.Destination,To=station},null),Check,token);
for(var attempt=0;;attempt++)
{
try
{
await driver!.ExecuteAsync(new(TradeActionKind.Travel,new(){From=runtime.Player.MarketId==configuration.Source.MarketId?configuration.Source:configuration.Destination,To=station},null),Check,token);return;
}
catch(DockingHandoffException)when(attempt<3&&menuReentryVerified){await RecoverHandoff();}
}
}
async Task RecoverHandoff()
{
await State("reentering-solo-after-handoff");
if(!menuReentryVerified)throw new InvalidOperationException("返回主菜单路径尚未实机确认,保留客户端及保护等待修复。");
var workflow=new NativeRelogWorkflow(new NativeRelogChannel(pid,NativeRelogChannel.SupportedGameSha256,returnToMenuTrial:true),()=>runtime.Player)
{Timeout=TimeSpan.FromMinutes(8)};
workflow.Progress+=message=>runtime.Log("重进",message);await workflow.RunAsync(token);initial=runtime.Player;
var until=DateTimeOffset.UtcNow.AddSeconds(15);
while(true)
{
try{await Check(token);break;}
catch(Exception error)when(NavigationRecovery.CanRetry(error,token)&&DateTimeOffset.UtcNow<until){await Task.Delay(80,token);}
}
await State("connecting-native-trade");driver=await CreateNativeTradeDriverAsync(new(),token,new(configuration.CargoCapacity,reserve),Path.Combine(root,"operations"));await driver.PreflightAsync(token);
}
async Task<IReadOnlyList<CommodityQuote>> Quotes(Station station)
{
var rows=await driver!.ReadDockedQuotesAsync(station.MarketId,token);
await runtime.Markets.SaveSnapshotAsync(new(station,rows,DateTimeOffset.UtcNow,DataSource.Journal),token);return rows;
await runtime.Markets.SaveSnapshotAsync(new(station,rows,DateTimeOffset.UtcNow,DataSource.Journal),token);
if(station.MarketId==configuration.Destination.MarketId)
await File.WriteAllTextAsync(Path.Combine(root,"destination-original-quotes.json"),JsonSerializer.Serialize(rows,ToolboxSettings.JsonOptions),token);
return rows;
}
async Task RememberMembership(OriginalCommunityGoalRead read)
{
var path=Path.Combine(root,"membership-original.json");
await File.WriteAllTextAsync(path+".tmp",JsonSerializer.Serialize(new AutomaticTradeMembership(initial.CommanderId,pid,DateTimeOffset.UtcNow,read),ToolboxSettings.JsonOptions),token);
File.Move(path+".tmp",path,true);
}
async Task<(DateTimeOffset Time,string Event,string Json)> CurrentCommunityGoalEvent()
{
var path=Directory.EnumerateFiles(runtime.Settings.JournalDirectory,"Journal*.log").OrderByDescending(File.GetLastWriteTimeUtc).FirstOrDefault()
??throw new InvalidDataException("当前游戏原始日志不存在。");
using var stream=new FileStream(path,FileMode.Open,FileAccess.Read,FileShare.ReadWrite|FileShare.Delete);
using var reader=new StreamReader(stream);bool sameCommander=false;
(DateTimeOffset Time,string Event,string Json)? latest=null;
while(await reader.ReadLineAsync(token) is {} line)
{
if(!line.Contains("\"LoadGame\"",StringComparison.Ordinal)&&!line.Contains("\"CommunityGoal\"",StringComparison.Ordinal))continue;
using var document=JsonDocument.Parse(line);var row=document.RootElement;var kind=row.GetProperty("event").GetString();
if(kind=="LoadGame")sameCommander=row.TryGetProperty("FID",out var fid)&&fid.GetString()==initial.CommanderId;
else if(kind=="CommunityGoal"&&sameCommander)latest=(row.GetProperty("timestamp").GetDateTimeOffset(),kind,line);
}
return latest??throw new InvalidDataException("当前指挥官原始日志没有CG贡献事件。");
}
async Task<OriginalCommunityGoalRead> Membership(NativeCommunityGoalClient client)
{
if(runtime.Player.Docked&&runtime.Player.MarketId==configuration.Destination.MarketId)
{var read=await client.ReadAsync(true,token);await RememberMembership(read);return read;}
var cached=Path.Combine(root,"membership-original.json");OriginalCommunityGoalRead original;
if(File.Exists(cached))
{
var prior=JsonSerializer.Deserialize<AutomaticTradeMembership>(await File.ReadAllTextAsync(cached,token),ToolboxSettings.JsonOptions)
??throw new InvalidDataException("原始CG回执缓存为空。");
if(prior.CommanderId!=initial.CommanderId||prior.ProcessId!=pid||DateTimeOffset.UtcNow-prior.ObservedAt>TimeSpan.FromMinutes(30)||prior.ObservedAt>DateTimeOffset.UtcNow)
throw new InvalidDataException("原始CG缓存不属于当前指挥官或已过期。");
original=prior.Read;
}
else
{
var directory=configuration.ConfirmedCommunityGoalJourney??throw new InvalidDataException("采购站不能读取Homer CG接口;需要已确认的原始报名回执。");
using var result=JsonDocument.Parse(await File.ReadAllTextAsync(Path.Combine(directory,"result.json"),token));var value=result.RootElement;
var checkpoint=value.GetProperty("checkpoint");var sale=checkpoint.GetProperty("sold");
if(!value.GetProperty("passed").GetBoolean()||!value.GetProperty("walletReconciled").GetBoolean()
||checkpoint.GetProperty("commander").GetString()!=initial.CommanderId||sale.GetProperty("commanderId").GetString()!=initial.CommanderId
||sale.GetProperty("marketId").GetInt64()!=configuration.Destination.MarketId||!sale.GetProperty("serverConfirmed").GetBoolean()
||!sale.GetProperty("clientApplied").GetBoolean()||sale.GetProperty("cargoAfter").GetInt32()!=0)
throw new InvalidDataException("先前CG交货回执未确认当前指挥官。");
original=JsonSerializer.Deserialize<OriginalCommunityGoalRead>(await File.ReadAllTextAsync(Path.Combine(directory,"membership-after.json"),token),ToolboxSettings.JsonOptions)
??throw new InvalidDataException("先前旅程缺少原始CG回执。");
var goal=CommunityGoalCatalogue.RequireTrade(CommunityGoalCatalogue.Parse(original.Body),DateTimeOffset.UtcNow,true);
var latest=await CurrentCommunityGoalEvent();
using var journal=JsonDocument.Parse(latest.Json);
var goals=journal.RootElement.GetProperty("CurrentGoals").EnumerateArray().Where(row=>row.GetProperty("CGID").GetUInt64()==goal.Id).ToArray();
if(latest.Event!="CommunityGoal"||latest.Time<initial.LoadGameAt||DateTimeOffset.UtcNow-latest.Time>TimeSpan.FromMinutes(30)||latest.Time>DateTimeOffset.UtcNow.AddSeconds(3)
||goals.Length!=1||goals[0].GetProperty("IsComplete").GetBoolean()||goals[0].GetProperty("PlayerContribution").GetInt64()!=goal.Contribution)
throw new InvalidDataException("当前日志未核对先前原始CG贡献,保留货舱不采购。");
}
if(original.Generation==0||Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(original.Body)))!=original.Proof)
throw new InvalidDataException("原始CG响应摘要不匹配。");
return original with{Goals=CommunityGoalCatalogue.Parse(original.Body)};
}
async Task CompleteJourney(string path,TradeLeg leg,NativeCommunityGoalClient membershipClient)
{
using var prior=JsonDocument.Parse(await File.ReadAllTextAsync(Path.Combine(path,"membership-before.json"),token));
var baseline=prior.RootElement.GetProperty("contribution").GetInt64();var goalId=prior.RootElement.GetProperty("goalId").GetUInt64();
activeJourney=path;await State("cargo-journey");
var journey=new NativeTradeJourney(driver!,()=>runtime.Player,Check,path);journey.Status+=message=>runtime.Log("自动跑商",message);
await journey.RunAsync(leg,token);
for(var attempt=0;;attempt++)
{
var journey=new NativeTradeJourney(driver!,()=>runtime.Player,Check,path);journey.Status+=message=>{RunMessage=message;runtime.Log("自动跑商",message);};
try{await journey.RunAsync(leg,token);break;}
catch(NativeTradeJourneyIncompleteException error)when(error.InnerException is DockingHandoffException&&attempt<3&&menuReentryVerified)
{await RecoverHandoff();}
}
var until=DateTimeOffset.UtcNow.AddSeconds(35);OriginalCommunityGoalRead updated;
while(true)
{
@@ -71,23 +164,27 @@ public partial class ToolboxViewModel
await Task.Delay(300,token);
}
await File.WriteAllTextAsync(Path.Combine(path,"cg-contribution-confirmed.json"),JsonSerializer.Serialize(updated,ToolboxSettings.JsonOptions),token);
await RememberMembership(updated);
activeJourney=null;RefreshCompleted();await State("cycle-completed");
}
try
{
RefreshCompleted();
await State("preflight");RefreshCompleted();
var pending=Directory.EnumerateDirectories(root,"journey-*").Where(path=>File.Exists(Path.Combine(path,"leg.json"))&&!File.Exists(Path.Combine(path,"cg-contribution-confirmed.json"))).ToArray();
if(!initial.InGame||initial.CurrentShipId is null||initial.CargoCapacity!=configuration.CargoCapacity||initial.Rebuy<=0||pending.Length>1
||pending.Length==0&&(!initial.Docked||initial.CargoCount!=0))
||pending.Length==0&&initial.CargoCount!=0)
throw new InvalidOperationException("自动流程需要原船、实际重购费,以及安全停靠空舱或唯一可恢复货物批次。");
var reserve=Math.Max(configuration.ReserveCredits,initial.Rebuy);
reserve=Math.Max(configuration.ReserveCredits,initial.Rebuy);
await State("connecting-native-trade");
driver=await CreateNativeTradeDriverAsync(new(),token,new(configuration.CargoCapacity,reserve),Path.Combine(root,"operations"));await driver.PreflightAsync(token);
var pid=((NativeFlightControlDriver)runtime.FlightDriver!).ProcessId;var channel=new NativeCommerceChannel(pid);
pid=((NativeFlightControlDriver)runtime.FlightDriver!).ProcessId;var channel=new NativeCommerceChannel(pid);
var hello=await channel.CallAsync(new{op="describe"},token);
bool Feature(string name)=>hello.TryGetProperty(name,out var value)&&value.ValueKind==JsonValueKind.True;
if(configuration.RequireSupercruiseOvercharge&&!Feature("supercruiseOverchargeControl")||configuration.RequireInterdictionEscape&&!Feature("interdictionEscapeControl"))
throw new NotSupportedException("SCO温控/燃油控制和原始逃逸矢量模块尚未就绪,空载测试与采购尚未启动。");
var cruise=await new NativeCruiseControlChannel(pid,NativeRelogChannel.SupportedGameSha256).InspectAsync(token);
if(!cruise.Available)
throw new NotSupportedException("原生SCO启停及温度燃油控制尚未完成实机验收,采购尚未启动。");
var membershipClient=new NativeCommunityGoalClient(channel);
if(runtime.Player.Docked&&runtime.Player.MarketId==configuration.Destination.MarketId)await Membership(membershipClient);
if(pending.Length==1)
{
var saved=JsonSerializer.Deserialize<TradeLeg>(await File.ReadAllTextAsync(Path.Combine(pending[0],"leg.json"),token),ToolboxSettings.JsonOptions)
@@ -101,11 +198,20 @@ public partial class ToolboxViewModel
// program observes actual menu and new Solo session receipts.
var relog=new NativeRelogWorkflow(new NativeRelogChannel(pid,NativeRelogChannel.SupportedGameSha256,returnToMenuTrial:true),()=>runtime.Player)
{Timeout=TimeSpan.FromMinutes(8)};
relog.Progress+=message=>runtime.Log("重进",message);await relog.RunAsync(token);initial=runtime.Player;await Check(token);
relog.Progress+=message=>runtime.Log("重进",message);await relog.RunAsync(token);menuReentryVerified=true;initial=runtime.Player;await Check(token);
driver=await CreateNativeTradeDriverAsync(new(),token,new(configuration.CargoCapacity,reserve),Path.Combine(root,"operations"));await driver.PreflightAsync(token);
await File.WriteAllTextAsync(Path.Combine(root,"menu-reentry-passed.json"),JsonSerializer.Serialize(new{passed=true,pid,initial.LoadGameAt,at=DateTimeOffset.UtcNow,clientTerminated=false},ToolboxSettings.JsonOptions),token);
}
await Travel(configuration.Destination);var destinationQuotes=await Quotes(configuration.Destination);
IReadOnlyList<CommodityQuote> destinationQuotes=runtime.Markets.LoadRegion(configuration.Destination.MarketId,1).Quotes
.Where(row=>row.MarketId==configuration.Destination.MarketId&&row.Source==DataSource.Journal&&DateTimeOffset.UtcNow-row.CollectedAt<TimeSpan.FromMinutes(30)).ToArray();
var quoteCache=Path.Combine(root,"destination-original-quotes.json");
if(File.Exists(quoteCache))
{
var saved=JsonSerializer.Deserialize<CommodityQuote[]>(await File.ReadAllTextAsync(quoteCache,token),ToolboxSettings.JsonOptions);
if(saved is {Length:>0}&&saved.All(row=>row.MarketId==configuration.Destination.MarketId&&DateTimeOffset.UtcNow-row.CollectedAt<TimeSpan.FromMinutes(30)))destinationQuotes=saved;
}
if(destinationQuotes.Count==0){await Travel(configuration.Destination);destinationQuotes=await Quotes(configuration.Destination);}
await State("travelling-to-source");
if(destinationQuotes.Count==0)throw new InvalidDataException("目的站原始报价为空。");
foreach(var path in Directory.EnumerateFiles(root,"empty-visit-*.json"))
{
@@ -132,7 +238,7 @@ public partial class ToolboxViewModel
var candidates=sourceQuotes.Where(quote=>NativeTradeBatchPolicy.Allows(quote.Commodity)&&!quote.Illegal)
.Select(quote=>new CgBatchQuote(quote.Commodity,quote.DisplayName,quote.Supply,quote.BuyPrice,destinationQuotes.SingleOrDefault(value=>value.Commodity==quote.Commodity)?.SellPrice??0)).ToArray();
var order=CgBatchPlanner.Select(candidates,configuration.CargoCapacity,wallet.Credits,reserve);
var membership=await membershipClient.ReadAsync(true,token);var goal=CommunityGoalCatalogue.RequireTrade(membership.Goals,DateTimeOffset.UtcNow,true);
var membership=await Membership(membershipClient);var goal=CommunityGoalCatalogue.RequireTrade(membership.Goals,DateTimeOffset.UtcNow,true);
var baseline=goal.Contribution??throw new InvalidDataException("CG个人贡献基线缺失。");
var leg=new TradeLeg{Number=1,From=configuration.Source,To=configuration.Destination,Orders=[order]};
activeJourney=Path.Combine(root,"journey-"+Guid.NewGuid().ToString("N"));Directory.CreateDirectory(activeJourney);
@@ -142,7 +248,12 @@ public partial class ToolboxViewModel
}
}
catch(OperationCanceledException)when(token.IsCancellationRequested){await State("stopped");}
catch(Exception error){await State("requires-recovery",error.Message);ShowError(error.Message);}
catch(Exception error)
{
await File.WriteAllTextAsync(Path.Combine(root,"failure-"+DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()+".json"),
JsonSerializer.Serialize(new{at=DateTimeOffset.UtcNow,phase,error=error.ToString()},ToolboxSettings.JsonOptions));
await State("requires-recovery",error.Message);ShowError(error.Message);
}
finally
{
if(driver is not null)await driver.ReleaseAllAsync();automaticTradeStop=null;RunActive=false;