XFEstudio/XFEEDToolBox
Exit immediately on player-ship interdiction and resume original trade cargo
Use fresh local-main-ship Status or Interdicted journal evidence as an independent default protection trigger. Start the exact-process exit before worker cancellation or launcher checks; retain damage-edge protection and reject old-session, fighter and generic danger/heat witnesses. Recover through the official launcher and original game mode, preserve in-flight purchase checkpoints, and handle navigation-stop/event callback races without replaying monetary operations. Keep legacy route configuration compatible and remove escape-vector alignment as the default prerequisite. Validation: 37 protection, 51 session and 86 trade tests pass; self-contained WinUI 0.4.20 publish; original 752t Uranium checkpoint resumed in the existing game process. NativeAOT plugin and ABI unchanged.
9ecea92
代码差异
@@ -18,13 +18,15 @@ public sealed record AutomaticTradeConfiguration
public bool ReturnTrade{get;init;}=true;
public decimal ReserveCredits{get;init;}
public bool RequireSupercruiseOvercharge{get;init;}=true;
public bool RequireInterdictionEscape{get;init;}=true;
// Retained for existing configuration files; escape alignment is no longer the default policy.
public bool RequireInterdictionEscape{get;init;}
public bool ExitOnInterdiction{get;init;}=true;
public bool ValidateMenuReentry{get;init;}=false;
public string? ConfirmedCommunityGoalJourney{get;init;}
public void Validate()
{
if(!RequireSupercruiseOvercharge||!RequireInterdictionEscape)
throw new InvalidDataException("自动跑商需要启用SCO温度燃油控制与原始逃逸矢量保护。");
if(!RequireSupercruiseOvercharge||!ExitOnInterdiction)
throw new InvalidDataException("自动跑商需要启用SCO温度燃油控制与被拦截立即大退保护。");
if(ConfirmedCommunityGoalJourney is not null&&!Path.IsPathFullyQualified(ConfirmedCommunityGoalJourney))
throw new InvalidDataException("已确认CG旅程必须使用完整路径。");
if(Schema!=1||!Enum.IsDefined(Mode)||Source.MarketId<=0||Destination.MarketId<=0||Source.MarketId==Destination.MarketId
@@ -36,6 +36,7 @@ public sealed record NavigationJournalState
public DateTimeOffset? HeatAlertAt { get; init; }
public bool ExternalThreat { get; init; }
public DateTimeOffset? ExternalThreatAt { get; init; }
public DateTimeOffset? InterdictedAt { get; init; }
public bool JumpInProgress { get; init; }
public long? JumpTargetSystemAddress { get; init; }
public string JumpTargetSystem { get; init; }="";
@@ -99,8 +100,9 @@ public sealed class NavigationProjector
else if((s.ExternalThreatAt is null||at>s.ExternalThreatAt)&&(dangerFlags&((1L<<22)|(1L<<23)))==0)
s=s with{ExternalThreat=false,ExternalThreatAt=at};
if(!(s.HeatAlertAt>at))s=s with{HeatAlert=(e.Long("Flags")&(1L<<20))!=0,HeatAlertAt=at};break;
case "UnderAttack" or "Interdicted" when !(s.ExternalThreatAt>at):s=s with{ExternalThreat=true,ExternalThreatAt=at};break;
case "LoadGame" when !(s.ExternalThreatAt>at):s=s with{ExternalThreat=false,ExternalThreatAt=at,DockingComputerActive=false,DockingComputerAt=at};break;
case "Interdicted" when !(s.ExternalThreatAt>at):s=s with{ExternalThreat=true,ExternalThreatAt=at,InterdictedAt=at};break;
case "UnderAttack" when !(s.ExternalThreatAt>at):s=s with{ExternalThreat=true,ExternalThreatAt=at};break;
case "LoadGame" when !(s.ExternalThreatAt>at):s=s with{ExternalThreat=false,ExternalThreatAt=at,InterdictedAt=null,DockingComputerActive=false,DockingComputerAt=at};break;
case "HeatWarning" or "HeatDamage" when !(s.HeatAlertAt>at):s=s with{HeatAlert=true,HeatAlertAt=at};break;
case "Shutdown" when !(s.JumpStateAt>at):s=s with{JumpInProgress=false,JumpStateAt=at};break;
}
@@ -0,0 +1,39 @@
using XFEEDToolBox.Core.Data;
using XFEEDToolBox.Core.Models;
namespace XFEEDToolBox.Core.Session;
/// <summary>Only a fresh local-main-ship interdiction witness may request an immediate exit.</summary>
public sealed class InterdictionExitGuard
{
private DateTimeOffset enabledAt;
private long sequence;
private string? consumed;
public void Reset(DateTimeOffset at){enabledAt=at;sequence=0;consumed=null;}
private static string Identity(PlayerState state,GameSessionTarget target)=>$"{target.ProcessId}:{target.StartedUtcTicks}:{state.CommanderId}:{state.CurrentShipId}:{state.LoadGameAt:O}";
private static bool Eligible(PlayerState state,GameSessionTarget target,DateTimeOffset now)
{
if(!state.InGame||state.Docked||state.CommanderId.Length==0||state.CurrentShipId is null||state.LoadGameAt is not{} loaded
||loaded.UtcTicks<target.StartedUtcTicks-TimeSpan.FromSeconds(5).Ticks||loaded>now||state.Status is not{} status)return false;
return (status.Flags&(1L<<24))!=0&&(status.Flags&((1L<<0)|(1L<<1)|(1L<<25)|(1L<<26)))==0&&(status.Flags2&7)==0
&&state.Ships.Any(ship=>ship.ShipId==state.CurrentShipId);
}
public bool ObserveStatus(PlayerState state,GameSessionTarget target,DateTimeOffset now)
{
if(!Eligible(state,target,now))return false;
var status=state.StatusAt is{} changed&&changed>=state.LoadGameAt&&changed<=now.AddSeconds(2)
&&(state.StatusReadAt??changed)<=now.AddSeconds(2)&&now-(state.StatusReadAt??changed)<=TimeSpan.FromSeconds(2)&&(state.Status!.Flags&(1L<<23))!=0;
var journal=state.Navigation.InterdictedAt is{} at&&at>=enabledAt.AddSeconds(-1)&&at>=state.LoadGameAt&&at<=now.AddSeconds(2)&&now-at<=TimeSpan.FromSeconds(5);
if(!status&&!journal)return false;
return Consume(state,target);
}
public bool ObserveJournal(ObservedJournalEvent observed,PlayerState state,GameSessionTarget target,DateTimeOffset now)
{
if(observed.Data.Text("event")!="Interdicted"||!Eligible(state,target,now)||observed.Sequence<=sequence
||observed.Data.Time() is not{} at||at<enabledAt.AddSeconds(-1)||at<state.LoadGameAt||at>now.AddSeconds(2)
||now-at>TimeSpan.FromSeconds(5)||observed.ObservedAt-at>TimeSpan.FromSeconds(5))return false;
sequence=observed.Sequence;return Consume(state,target);
}
private bool Consume(PlayerState state,GameSessionTarget target)
{var identity=Identity(state,target);if(consumed==identity)return false;consumed=identity;return true;}
}
@@ -81,6 +81,7 @@ public sealed class NativeFlightTestProtection:IAsyncDisposable
{
var protection=Volatile.Read(ref active);
if(protection is null)return;
protection.monitor.ObserveInterdiction(protection.player(),DateTimeOffset.UtcNow);
protection.externalProtectionCheck?.Invoke();
if(protection.target.ProcessId!=pid||Volatile.Read(ref protection.unsafeState)!=0||protection.monitor.EmergencyActive||protection.monitor.TestingBlocked)
throw new InvalidOperationException("飞船保护已停止测试,禁止继续发送飞行输入。");
@@ -124,6 +125,7 @@ public sealed class NativeFlightTestProtection:IAsyncDisposable
{
while(!stop.IsCancellationRequested)
{
monitor.ObserveInterdiction(player(),DateTimeOffset.UtcNow);
try
{
var health=await ReadAsync(stop.Token);monitor.Observe(health,DateTimeOffset.UtcNow);
@@ -3,10 +3,10 @@ using XFEEDToolBox.Core.Models;
namespace XFEEDToolBox.Core.Session;
public enum ShipProtectionReason { None,ShieldsLost,HullBelowHalf }
public enum ShipProtectionReason { None,ShieldsLost,HullBelowHalf,Interdicted }
public sealed record ShipProtectionTrigger(ShipProtectionReason Reason,double? HullHealth,DateTimeOffset At)
{
public string Message=>Reason==ShipProtectionReason.HullBelowHalf?$"受损后船体完整度低于 50%({HullHealth:P1})":"受损后护盾失效";
public string Message=>Reason switch{ShipProtectionReason.Interdicted=>"检测到正在被拦截",ShipProtectionReason.HullBelowHalf=>$"受损后船体完整度低于 50%({HullHealth:P1})",_=>"受损后护盾失效"};
}
public sealed record ShipHealthSample(long Sequence,DateTimeOffset At,int ProcessId,long ProcessStartedUtcTicks,ulong EntityId,long ShipId,
double HullHealth,double? ShieldHealth,bool DamageObserved);
@@ -10,6 +10,9 @@ public sealed class ShipProtectionMonitor(IGameSessionDriver driver,Func<PlayerS
{
private readonly object gate=new();
private readonly ShieldRestartGuard guard=new();
private readonly InterdictionExitGuard interdiction=new();
private ShipProtectionTrigger? lastTrigger;
private GameSessionTarget? replacement;
private readonly CancellationTokenSource lifetime=new();
private GameSessionTarget? target;
private bool enabled;
@@ -17,6 +20,9 @@ public sealed class ShipProtectionMonitor(IGameSessionDriver driver,Func<PlayerS
private NativeFlightTestProtection? nativeProtection;
private readonly SemaphoreSlim nativeGate=new(1,1);
public GameSessionTarget? BoundTarget{get{lock(gate)return target;}}
public ShipProtectionTrigger? LastTrigger{get{lock(gate)return lastTrigger;}}
public GameSessionTarget? RestartTarget{get{lock(gate)return replacement;}}
public bool ExitOnInterdiction{get;init;}=true;
public bool ContinuousHealthReady=>nativeProtection?.Fresh==true;
public bool AutomaticExitEnabled=>automaticExit;
public event Action<ShipProtectionProgress>? Progress;
@@ -34,10 +40,10 @@ public sealed class ShipProtectionMonitor(IGameSessionDriver driver,Func<PlayerS
lock(gate)
{
if(lifetime.IsCancellationRequested)return;
target=next;enabled=true;guard.SetEnabled(true,DateTimeOffset.UtcNow);guard.Prime(player(),next);
target=next;enabled=true;lastTrigger=null;replacement=null;interdiction.Reset(DateTimeOffset.UtcNow);guard.SetEnabled(true,DateTimeOffset.UtcNow);guard.Prime(player(),next);
}
if(TestingBlocked)StopAutomation();
Report(false,TestingBlocked?"船体低于 50% · 自动操作和测试已停止,等待修复":automaticExit?"日志监测中 · 破盾或低血量时大退":"人工观察模式 · 异常只停自动输入,不退出游戏");
Report(false,TestingBlocked?"船体低于 50% · 自动操作和测试已停止,等待修复":automaticExit?"保护监测中 · 被拦截立即大退;受损后破盾或低血量大退":"人工观察模式 · 异常只停自动输入,不退出游戏");
}
public async Task AttachNativeAsync(string gameHash,CancellationToken token=default,bool exitForFailedHandoff=true)
{
@@ -48,7 +54,7 @@ public sealed class ShipProtectionMonitor(IGameSessionDriver driver,Func<PlayerS
if(nativeProtection is {Stopped:false})return;
if(nativeProtection is {} previous){nativeProtection=null;await previous.DisposeAsync();}
nativeProtection=await NativeFlightTestProtection.ArmAsync(expected.ProcessId,gameHash,driver,player,StopAutomation,token,this,automaticExit,exitForFailedHandoff);
Report(false,"连续船体与护盾监测中 · 受损后破盾或低血量立即大退");
Report(false,"连续保护中 · 被拦截立即大退;受损后破盾或低血量立即大退");
}
finally{nativeGate.Release();}
}
@@ -60,11 +66,12 @@ public sealed class ShipProtectionMonitor(IGameSessionDriver driver,Func<PlayerS
}
public void Observe(ObservedJournalEvent observed,PlayerState state)
{
if(ObserveInterdiction(state,DateTimeOffset.UtcNow,observed))return;
GameSessionTarget? restart=null;ShipProtectionTrigger? trigger=null;bool blocked;
lock(gate)
{
if(!enabled||target is null||emergency is {IsCompleted:false})return;
if(guard.Observe(observed,state,target)){restart=target;trigger=guard.LastTrigger;}
if(guard.Observe(observed,state,target)){restart=target;trigger=lastTrigger=guard.LastTrigger;}
blocked=guard.TestingBlocked;
if(restart is not null)
{
@@ -80,12 +87,25 @@ public sealed class ShipProtectionMonitor(IGameSessionDriver driver,Func<PlayerS
lock(gate)
{
if(!enabled||target is null||emergency is {IsCompleted:false})return;
if(guard.ObserveHealth(sample,player(),target,now)){restart=target;trigger=guard.LastTrigger;}
if(guard.ObserveHealth(sample,player(),target,now)){restart=target;trigger=lastTrigger=guard.LastTrigger;}
blocked=guard.TestingBlocked;
if(restart is not null){StopAutomation();if(automaticExit)emergency=Task.Run(()=>ProtectAsync(restart,trigger));}
}
if(blocked)StopAutomation();
}
public bool ObserveInterdiction(PlayerState state,DateTimeOffset now,ObservedJournalEvent? observed=null)
{
lock(gate)
{
if(!enabled||target is null||!automaticExit||!ExitOnInterdiction||emergency is {IsCompleted:false})return false;
if(!(observed is null?interdiction.ObserveStatus(state,target,now):interdiction.ObserveJournal(observed,state,target,now)))return false;
var expected=target;var trigger=new ShipProtectionTrigger(ShipProtectionReason.Interdicted,null,now);lastTrigger=trigger;
// The exit starts independently of worker cancellation, UI dispatch,
// health reads, escape-vector alignment and launcher validation.
emergency=Task.Run(()=>ProtectAsync(expected,trigger));
}
StopAutomation();return true;
}
private async Task ProtectAsync(GameSessionTarget expected,ShipProtectionTrigger? trigger)
{
Report(true,(trigger?.Message??"飞船受损")+" · 正在大退");
@@ -94,13 +114,14 @@ public sealed class ShipProtectionMonitor(IGameSessionDriver driver,Func<PlayerS
var sessions=new GameSessionController(driver,()=>false);
sessions.Reentry+=entry=>
{
if(entry.Stage==SessionReentryStage.ProcessReady)lock(gate)replacement=entry.Target;
if(Reentry is not {} handlers)return;
foreach(Action<SessionReentryEvent> handler in handlers.GetInvocationList())
try{handler(entry);}catch{ /* A notification failure cannot affect emergency protection. */ }
};
await sessions.EmergencyRestartAsync(expected,lifetime.Token);
lock(gate){target=null;}
Report(false,TestingBlocked?"已大退重启 · 船体低于 50%,测试已停止,修复后再继续":"已大退重启 · 等待新会话;初始无盾不触发");
Report(false,TestingBlocked?"已大退重启 · 船体低于 50%,测试已停止,修复后再继续":trigger?.Reason==ShipProtectionReason.Interdicted?"拦截已大退保护 · 等待原模式恢复并继续原货物":"已大退重启 · 等待新会话;初始无盾不触发");
}
catch(OperationCanceledException)when(lifetime.IsCancellationRequested){Report(false,"已取消后续重启等待");}
catch(Exception error){Report(false,"飞船保护停止 · "+error.Message);}
@@ -16,6 +16,12 @@ public class AutomaticTradeTests
value.Validate();(value with{CargoCapacity=752}).Validate();(value with{CargoCapacity=2048}).Validate();
Check.Throws<InvalidDataException>(()=>(value with{Destination=Station(7)}).Validate());
}
[Test]public void InterdictionDefaultsToImmediateExitAndKeepsLegacyRoutesCompatible()
{
using var directory=new TemporaryDirectory();var value=new AutomaticTradeConfiguration{Source=Station(7),Destination=Station(98),WorkingDirectory=directory.Path};
Check.That(value.ExitOnInterdiction&&!value.RequireInterdictionEscape);value.Validate();(value with{RequireInterdictionEscape=true}).Validate();
Check.Throws<InvalidDataException>(()=>(value with{ExitOnInterdiction=false}).Validate());
}
[Test]public void OrdinaryCargoCanUseNonCgCommoditiesAndAllOfTheLiveHold()
{
var order=AutomaticTradePlanner.Select([Quote(1,"gold",100,90)],[Quote(2,"gold",50,120)],752,1000000,2000);
@@ -0,0 +1,99 @@
using System.Collections.Concurrent;
using XFEEDToolBox.Core.Data;
using XFEEDToolBox.Core.Models;
using XFEEDToolBox.Core.Session;
using XFEExtension.NetCore.XUnit.Attributes;
namespace XFEEDToolBox.Test;
[TestFixture]
public class InterdictionProtectionTests
{
private static PlayerState Ship(DateTimeOffset now,long flags=(1L<<24)|(1L<<23))=>new(){CommanderId="F1",CurrentShipId=7,InGame=true,LoadGameAt=now.AddMinutes(-1),StatusAt=now,StatusReadAt=now,
Status=new(flags,0,1,1,0,null,null,null,null,[]),Ships=[new(){ShipId=7,HullHealth=1,ConfigurationAt=now.AddMinutes(-1),Modules=[]}]};
private static GameSessionTarget Target(DateTimeOffset now)=>new(12,now.AddMinutes(-2).UtcTicks,"fixture-game","fixture-launcher",false);
private static InterdictionExitGuard Guard(DateTimeOffset now){var guard=new InterdictionExitGuard();guard.Reset(now.AddSeconds(-1));return guard;}
[Test] public void FreshInterdictionTriggersWithFullHullAndNoShieldAndOnlyOncePerSession()
{
var now=DateTimeOffset.UtcNow;var ship=Ship(now);var guard=Guard(now);
Check.That(guard.ObserveStatus(ship,Target(now),now));Check.That(!guard.ObserveStatus(ship,Target(now),now));
Check.That(guard.ObserveStatus(ship with{LoadGameAt=now.AddSeconds(-1)},Target(now) with{ProcessId=13,StartedUtcTicks=now.AddSeconds(-2).UtcTicks},now));
}
[Test] public void GeneralDangerHeatUnshieldedBaselineAndUnderAttackAreNotInterdictionWitnesses()
{
var now=DateTimeOffset.UtcNow;foreach(var flags in new[]{1L<<24,(1L<<24)|(1L<<22),(1L<<24)|(1L<<20)})
{var ship=Ship(now,flags);Check.That(!Guard(now).ObserveStatus(ship,Target(now),now));}
var attack=new ObservedJournalEvent(1,now,Check.Event(new{timestamp=now,@event="UnderAttack",Target="You"}));
Check.That(!Guard(now).ObserveJournal(attack,Ship(now,1L<<24),Target(now),now));
}
[Test] public void HistoricalOtherSessionDockedFighterAndStaleStatusCannotExitTheGame()
{
var now=DateTimeOffset.UtcNow;var ship=Ship(now);
foreach(var state in new[]{ship with{Docked=true},ship with{InGame=false},ship with{StatusReadAt=now.AddSeconds(-3)},ship with{StatusAt=now.AddMinutes(-3)},
ship with{CurrentShipId=99},ship with{LoadGameAt=now.AddMinutes(-3)},ship with{Status=ship.Status! with{Flags=(1L<<23)|(1L<<25)}}})
Check.That(!Guard(now).ObserveStatus(state,Target(now),now));
}
[Test] public void FreshVictimJournalIsAFallbackAndDoesNotRepeatAStatusExit()
{
var now=DateTimeOffset.UtcNow;var ship=Ship(now,1L<<24);var guard=Guard(now);
var witness=new ObservedJournalEvent(1,now,Check.Event(new{timestamp=now,@event="Interdicted",Submitted=true,Interdictor="Snout",IsPlayer=false}));
Check.That(guard.ObserveJournal(witness,ship,Target(now),now));Check.That(!guard.ObserveStatus(Ship(now),Target(now),now));
Check.That(!Guard(now).ObserveJournal(witness with{Data=Check.Event(new{timestamp=now.AddMinutes(-1),@event="Interdicted"})},ship,Target(now),now));
}
[Test] public void JournalProjectionCanTriggerBeforeTheLiveEventCallbackButAttackAloneCannot()
{
var now=DateTimeOffset.UtcNow;var projector=new XFEEDToolBox.Core.Navigation.NavigationProjector();
projector.Apply(Check.Event(new{timestamp=now,@event="Interdicted",Submitted=true}));
var ship=Ship(now,1L<<24) with{Navigation=projector.State};Check.That(Guard(now).ObserveStatus(ship,Target(now),now));
projector.Apply(Check.Event(new{timestamp=now.AddMilliseconds(1),@event="LoadGame"}));Check.That(projector.State.InterdictedAt is null);
projector.Apply(Check.Event(new{timestamp=now.AddMilliseconds(2),@event="UnderAttack"}));
Check.That(!Guard(now).ObserveStatus(ship with{Navigation=projector.State},Target(now),now));
}
private sealed class Sessions(DateTimeOffset now):IGameSessionDriver
{
public bool CanRelog=>false;
public readonly ConcurrentQueue<string> Calls=new();
public readonly TaskCompletionSource Killed=new(TaskCreationOptions.RunContinuationsAsynchronously);
public TaskCompletionSource? LaunchGate;
public bool MissingLauncher,WrongProcess;
public GameSessionTarget Expected=Target(now);
public Task<GameSessionTarget> FindTargetAsync(CancellationToken token)=>Task.FromResult(Expected);
public Task<GameSessionTarget> FindLaunchTargetAsync(CancellationToken token)=>throw new NotSupportedException();
public Task ValidateExitAsync(GameSessionTarget actual,CancellationToken token){Calls.Enqueue("validate-exit");Check.Equal(Expected,actual);if(WrongProcess)throw new InvalidOperationException("process changed");return Task.CompletedTask;}
public Task ValidateRestartAsync(GameSessionTarget actual,CancellationToken token)=>throw new NotSupportedException();
public async Task ValidateLaunchAsync(GameSessionTarget actual,CancellationToken token){Calls.Enqueue("validate-launch");if(MissingLauncher)throw new IOException("missing launcher");if(LaunchGate is{} gate)await gate.Task.WaitAsync(token);}
public Task TerminateAsync(GameSessionTarget actual,CancellationToken token){Calls.Enqueue("exit");Killed.TrySetResult();return Task.CompletedTask;}
public Task<bool> HasExitedAsync(GameSessionTarget actual,CancellationToken token)=>Task.FromResult(true);
public Task LaunchAsync(GameSessionTarget actual,CancellationToken token){Calls.Enqueue("launch");return Task.CompletedTask;}
public Task<GameSessionTarget?> FindReplacementAsync(GameSessionTarget actual,CancellationToken token)=>Task.FromResult<GameSessionTarget?>(actual with{ProcessId=13,StartedUtcTicks=now.UtcTicks});
public Task RelogAsync(GameSessionTarget actual,CancellationToken token)=>throw new InvalidOperationException("Interdiction must not use menu relog");
}
[Test] public async Task ImmediateExitPrecedesLauncherChecksAndDoesNotWaitForWorkerOrUiCallbacks()
{
var now=DateTimeOffset.UtcNow;var player=Ship(now);var sessions=new Sessions(now){LaunchGate=new(TaskCreationOptions.RunContinuationsAsynchronously)};
using var releaseWorker=new ManualResetEventSlim();await using var monitor=new ShipProtectionMonitor(sessions,()=>player,()=>releaseWorker.Wait());
monitor.Progress+=_=>throw new InvalidOperationException("busy UI fixture");await monitor.EnableAsync();
var trigger=Task.Run(()=>monitor.ObserveInterdiction(player,now));
try{await sessions.Killed.Task.WaitAsync(TimeSpan.FromSeconds(2));Check.That(!trigger.IsCompleted);Check.Equal("validate-exit,exit",string.Join(',',sessions.Calls.Take(2)));}
finally{releaseWorker.Set();sessions.LaunchGate.TrySetResult();}
Check.That(await trigger);await monitor.Completion.WaitAsync(TimeSpan.FromSeconds(2));
Check.Equal(ShipProtectionReason.Interdicted,monitor.LastTrigger!.Reason);Check.Equal(13,monitor.RestartTarget!.ProcessId);
Check.That(!monitor.ObserveInterdiction(player,now));Check.Equal(1,sessions.Calls.Count(call=>call=="exit"));
}
[Test] public async Task MissingLauncherStillExitsButAChangedProcessIdentityCannotBeTerminated()
{
foreach(var wrongProcess in new[]{false,true})
{
var now=DateTimeOffset.UtcNow;var player=Ship(now);var sessions=new Sessions(now){MissingLauncher=true,WrongProcess=wrongProcess};
await using var monitor=new ShipProtectionMonitor(sessions,()=>player,()=>{});await monitor.EnableAsync();Check.That(monitor.ObserveInterdiction(player,now));await monitor.Completion;
Check.Equal(!wrongProcess,sessions.Calls.Contains("exit"));Check.That(!sessions.Calls.Contains("launch"));
}
}
[Test] public async Task ExplicitObserverOnlyModeDoesNotTerminateAndDefaultPolicyDoes()
{
var now=DateTimeOffset.UtcNow;var player=Ship(now);var sessions=new Sessions(now);
await using(var observer=new ShipProtectionMonitor(sessions,()=>player,()=>{},automaticExit:false))
{await observer.EnableAsync();Check.That(!observer.ObserveInterdiction(player,now));Check.Equal(0,sessions.Calls.Count);}
await using var active=new ShipProtectionMonitor(sessions,()=>player,()=>{});await active.EnableAsync();Check.That(active.ExitOnInterdiction);Check.That(active.ObserveInterdiction(player,now));await active.Completion;Check.That(sessions.Calls.Contains("exit"));
}
}
@@ -10,6 +10,7 @@ namespace XFEEDToolBox.Services;
public sealed class WindowsTradingBootstrap(Func<PlayerState> player,string executable,string packageDirectory,string? operationDirectory=null)
{
public bool ReconcileOriginalOrders{get;init;}
public bool ResumeExistingCargo{get;init;}
public event Action<string>? Progress;
public async Task UpgradeAndEnterAsync(CancellationToken token)
{
@@ -31,7 +32,7 @@ public sealed class WindowsTradingBootstrap(Func<PlayerState> player,string exec
var entry=new NativeSessionEntryWorkflow(new NativeRelogChannel(target.ProcessId,NativeRelogChannel.SupportedGameSha256,entryOnly:true),player,mode,commander);
entry.Progress+=text=>Progress?.Invoke(text);await entry.RunAsync(token).ConfigureAwait(false);
}
if(player().CommanderId!=commander||player().CurrentShipId!=original.CurrentShipId||player().CargoCount!=0||player().GameMode!=mode)
if(player().CommanderId!=commander||player().CurrentShipId!=original.CurrentShipId||!ResumeExistingCargo&&player().CargoCount!=0||player().GameMode!=mode||!player().CargoReliable)
throw new InvalidDataException("恢复后的指挥官、飞船、空舱或游戏模式不一致,未继续自动贸易。");
return;
}
@@ -112,12 +112,24 @@ public partial class ToolboxViewModel
using var stop=CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);automaticTradeStop=stop;RunActive=true;HasError=false;
NativeTradeCommandDriver? driver=null;
AutomaticTradeRunSession? financialSession=null;
DateTimeOffset lastInterdictionRecovery=default;
bool InterdictionRecoveryPending()
{
// Navigation may reject the new Status before the health poll's
// next tick. Consume that same fresh witness before handling failure.
shipProtection?.ObserveInterdiction(runtime.Player,DateTimeOffset.UtcNow);
return shipProtection?.LastTrigger is {Reason:ShipProtectionReason.Interdicted} trigger&&trigger.At>lastInterdictionRecovery;
}
try
{
while(!stop.IsCancellationRequested)
{
try
{
var existingSession=new WindowsTradingBootstrap(()=>runtime.Player,PluginGameExecutable,Path.Combine(AppContext.BaseDirectory,"PluginPackage"),Path.Combine(parameters.WorkingDirectory,"operations")){ResumeExistingCargo=true};
existingSession.Progress+=text=>dispatcher.TryEnqueue(()=>{RunMessage=text;runtime.Log("跑商会话恢复",text);});
await Task.Run(()=>existingSession.UpgradeAndEnterAsync(stop.Token),stop.Token);
await ReadPluginStatusAsync();
while(!runtime.Player.InGame||runtime.Player.CurrentShipId is null||runtime.Player.CargoCapacity<=0||runtime.Player.Rebuy<=0||!runtime.Player.CargoReliable||!runtime.Player.CreditsReliable)
{
RunStatus="等待游戏就绪";RunMessage="正在等待游戏载入及当前飞船、货舱和余额就绪,完成后自动继续。";HasError=false;
@@ -159,6 +171,16 @@ public partial class ToolboxViewModel
await Task.Run(()=>engine.RunAsync(stop.Token),stop.Token);
break;
}
catch(Exception)when(!stop.IsCancellationRequested&&InterdictionRecoveryPending())
{
var protection=shipProtection!;lastInterdictionRecovery=protection.LastTrigger!.At;
if(driver is not null){try{await driver.ReleaseAllAsync();}catch(Exception error)when(error is IOException or InvalidOperationException or OperationCanceledException){}driver=null;}
RunStatus="拦截保护恢复中";RunMessage="已检测到拦截并大退,正在通过官方启动器恢复原模式和同一批货物。";HasError=false;
await protection.Completion.WaitAsync(stop.Token);
if(protection.RestartTarget is null)throw new IOException("拦截已保护退出,但尚未确认官方启动器的新游戏实例。");
// The next iteration enters the original mode in the verified
// replacement process and resumes its durable cargo checkpoint.
}
catch(Exception error)when(NativeOrderReconciliationRequiredException.Present(error)&&!stop.IsCancellationRequested)
{
if(driver is not null){await driver.ReleaseAllAsync();driver=null;}
@@ -2,7 +2,7 @@
<PropertyGroup>
<OutputType>WinExe</OutputType>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<Version>0.4.19</Version>
<Version>0.4.20</Version>
<TargetFramework>net10.0-windows10.0.22621.0</TargetFramework>
<TargetPlatformMinVersion>10.0.19041.0</TargetPlatformMinVersion>
<ApplicationManifest>app.manifest</ApplicationManifest>