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

XFEEDToolBox

【WinUI】XFE精英危险工具箱

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

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
XFE工作室室长 <mail@xfegzs.com>
提交于

代码差异

11 个文件 +209 -15
Modified XFEEDToolBox.Core/Execution/AutomaticTradeConfiguration.cs +5 -3
@@ -18,13 +18,15 @@ public sealed record AutomaticTradeConfiguration
18 18 public bool ReturnTrade{get;init;}=true;
19 19 public decimal ReserveCredits{get;init;}
20 20 public bool RequireSupercruiseOvercharge{get;init;}=true;
21 public bool RequireInterdictionEscape{get;init;}=true;
21 // Retained for existing configuration files; escape alignment is no longer the default policy.
22 public bool RequireInterdictionEscape{get;init;}
23 public bool ExitOnInterdiction{get;init;}=true;
22 24 public bool ValidateMenuReentry{get;init;}=false;
23 25 public string? ConfirmedCommunityGoalJourney{get;init;}
24 26 public void Validate()
25 27 {
26 if(!RequireSupercruiseOvercharge||!RequireInterdictionEscape)
27 throw new InvalidDataException("自动跑商需要启用SCO温度燃油控制与原始逃逸矢量保护。");
28 if(!RequireSupercruiseOvercharge||!ExitOnInterdiction)
29 throw new InvalidDataException("自动跑商需要启用SCO温度燃油控制与被拦截立即大退保护。");
28 30 if(ConfirmedCommunityGoalJourney is not null&&!Path.IsPathFullyQualified(ConfirmedCommunityGoalJourney))
29 31 throw new InvalidDataException("已确认CG旅程必须使用完整路径。");
30 32 if(Schema!=1||!Enum.IsDefined(Mode)||Source.MarketId<=0||Destination.MarketId<=0||Source.MarketId==Destination.MarketId
Modified XFEEDToolBox.Core/Navigation/NavigationJournal.cs +4 -2
@@ -36,6 +36,7 @@ public sealed record NavigationJournalState
36 36 public DateTimeOffset? HeatAlertAt { get; init; }
37 37 public bool ExternalThreat { get; init; }
38 38 public DateTimeOffset? ExternalThreatAt { get; init; }
39 public DateTimeOffset? InterdictedAt { get; init; }
39 40 public bool JumpInProgress { get; init; }
40 41 public long? JumpTargetSystemAddress { get; init; }
41 42 public string JumpTargetSystem { get; init; }="";
@@ -99,8 +100,9 @@ public sealed class NavigationProjector
99 100 else if((s.ExternalThreatAt is null||at>s.ExternalThreatAt)&&(dangerFlags&((1L<<22)|(1L<<23)))==0)
100 101 s=s with{ExternalThreat=false,ExternalThreatAt=at};
101 102 if(!(s.HeatAlertAt>at))s=s with{HeatAlert=(e.Long("Flags")&(1L<<20))!=0,HeatAlertAt=at};break;
102 case "UnderAttack" or "Interdicted" when !(s.ExternalThreatAt>at):s=s with{ExternalThreat=true,ExternalThreatAt=at};break;
103 case "LoadGame" when !(s.ExternalThreatAt>at):s=s with{ExternalThreat=false,ExternalThreatAt=at,DockingComputerActive=false,DockingComputerAt=at};break;
103 case "Interdicted" when !(s.ExternalThreatAt>at):s=s with{ExternalThreat=true,ExternalThreatAt=at,InterdictedAt=at};break;
104 case "UnderAttack" when !(s.ExternalThreatAt>at):s=s with{ExternalThreat=true,ExternalThreatAt=at};break;
105 case "LoadGame" when !(s.ExternalThreatAt>at):s=s with{ExternalThreat=false,ExternalThreatAt=at,InterdictedAt=null,DockingComputerActive=false,DockingComputerAt=at};break;
104 106 case "HeatWarning" or "HeatDamage" when !(s.HeatAlertAt>at):s=s with{HeatAlert=true,HeatAlertAt=at};break;
105 107 case "Shutdown" when !(s.JumpStateAt>at):s=s with{JumpInProgress=false,JumpStateAt=at};break;
106 108 }
Added XFEEDToolBox.Core/Session/InterdictionExitGuard.cs +39 -0
@@ -0,0 +1,39 @@
1 using XFEEDToolBox.Core.Data;
2 using XFEEDToolBox.Core.Models;
3
4 namespace XFEEDToolBox.Core.Session;
5
6 /// <summary>Only a fresh local-main-ship interdiction witness may request an immediate exit.</summary>
7 public sealed class InterdictionExitGuard
8 {
9 private DateTimeOffset enabledAt;
10 private long sequence;
11 private string? consumed;
12 public void Reset(DateTimeOffset at){enabledAt=at;sequence=0;consumed=null;}
13 private static string Identity(PlayerState state,GameSessionTarget target)=>$"{target.ProcessId}:{target.StartedUtcTicks}:{state.CommanderId}:{state.CurrentShipId}:{state.LoadGameAt:O}";
14 private static bool Eligible(PlayerState state,GameSessionTarget target,DateTimeOffset now)
15 {
16 if(!state.InGame||state.Docked||state.CommanderId.Length==0||state.CurrentShipId is null||state.LoadGameAt is not{} loaded
17 ||loaded.UtcTicks<target.StartedUtcTicks-TimeSpan.FromSeconds(5).Ticks||loaded>now||state.Status is not{} status)return false;
18 return (status.Flags&(1L<<24))!=0&&(status.Flags&((1L<<0)|(1L<<1)|(1L<<25)|(1L<<26)))==0&&(status.Flags2&7)==0
19 &&state.Ships.Any(ship=>ship.ShipId==state.CurrentShipId);
20 }
21 public bool ObserveStatus(PlayerState state,GameSessionTarget target,DateTimeOffset now)
22 {
23 if(!Eligible(state,target,now))return false;
24 var status=state.StatusAt is{} changed&&changed>=state.LoadGameAt&&changed<=now.AddSeconds(2)
25 &&(state.StatusReadAt??changed)<=now.AddSeconds(2)&&now-(state.StatusReadAt??changed)<=TimeSpan.FromSeconds(2)&&(state.Status!.Flags&(1L<<23))!=0;
26 var journal=state.Navigation.InterdictedAt is{} at&&at>=enabledAt.AddSeconds(-1)&&at>=state.LoadGameAt&&at<=now.AddSeconds(2)&&now-at<=TimeSpan.FromSeconds(5);
27 if(!status&&!journal)return false;
28 return Consume(state,target);
29 }
30 public bool ObserveJournal(ObservedJournalEvent observed,PlayerState state,GameSessionTarget target,DateTimeOffset now)
31 {
32 if(observed.Data.Text("event")!="Interdicted"||!Eligible(state,target,now)||observed.Sequence<=sequence
33 ||observed.Data.Time() is not{} at||at<enabledAt.AddSeconds(-1)||at<state.LoadGameAt||at>now.AddSeconds(2)
34 ||now-at>TimeSpan.FromSeconds(5)||observed.ObservedAt-at>TimeSpan.FromSeconds(5))return false;
35 sequence=observed.Sequence;return Consume(state,target);
36 }
37 private bool Consume(PlayerState state,GameSessionTarget target)
38 {var identity=Identity(state,target);if(consumed==identity)return false;consumed=identity;return true;}
39 }
Modified XFEEDToolBox.Core/Session/NativeFlightTestProtection.cs +2 -0
@@ -81,6 +81,7 @@ public sealed class NativeFlightTestProtection:IAsyncDisposable
81 81 {
82 82 var protection=Volatile.Read(ref active);
83 83 if(protection is null)return;
84 protection.monitor.ObserveInterdiction(protection.player(),DateTimeOffset.UtcNow);
84 85 protection.externalProtectionCheck?.Invoke();
85 86 if(protection.target.ProcessId!=pid||Volatile.Read(ref protection.unsafeState)!=0||protection.monitor.EmergencyActive||protection.monitor.TestingBlocked)
86 87 throw new InvalidOperationException("飞船保护已停止测试,禁止继续发送飞行输入。");
@@ -124,6 +125,7 @@ public sealed class NativeFlightTestProtection:IAsyncDisposable
124 125 {
125 126 while(!stop.IsCancellationRequested)
126 127 {
128 monitor.ObserveInterdiction(player(),DateTimeOffset.UtcNow);
127 129 try
128 130 {
129 131 var health=await ReadAsync(stop.Token);monitor.Observe(health,DateTimeOffset.UtcNow);
Modified XFEEDToolBox.Core/Session/ShieldRestartGuard.cs +2 -2
@@ -3,10 +3,10 @@ using XFEEDToolBox.Core.Models;
3 3
4 4 namespace XFEEDToolBox.Core.Session;
5 5
6 public enum ShipProtectionReason { None,ShieldsLost,HullBelowHalf }
6 public enum ShipProtectionReason { None,ShieldsLost,HullBelowHalf,Interdicted }
7 7 public sealed record ShipProtectionTrigger(ShipProtectionReason Reason,double? HullHealth,DateTimeOffset At)
8 8 {
9 public string Message=>Reason==ShipProtectionReason.HullBelowHalf?$"受损后船体完整度低于 50%({HullHealth:P1})":"受损后护盾失效";
9 public string Message=>Reason switch{ShipProtectionReason.Interdicted=>"检测到正在被拦截",ShipProtectionReason.HullBelowHalf=>$"受损后船体完整度低于 50%({HullHealth:P1})",_=>"受损后护盾失效"};
10 10 }
11 11 public sealed record ShipHealthSample(long Sequence,DateTimeOffset At,int ProcessId,long ProcessStartedUtcTicks,ulong EntityId,long ShipId,
12 12 double HullHealth,double? ShieldHealth,bool DamageObserved);
Modified XFEEDToolBox.Core/Session/ShipProtectionMonitor.cs +27 -6
@@ -10,6 +10,9 @@ public sealed class ShipProtectionMonitor(IGameSessionDriver driver,Func<PlayerS
10 10 {
11 11 private readonly object gate=new();
12 12 private readonly ShieldRestartGuard guard=new();
13 private readonly InterdictionExitGuard interdiction=new();
14 private ShipProtectionTrigger? lastTrigger;
15 private GameSessionTarget? replacement;
13 16 private readonly CancellationTokenSource lifetime=new();
14 17 private GameSessionTarget? target;
15 18 private bool enabled;
@@ -17,6 +20,9 @@ public sealed class ShipProtectionMonitor(IGameSessionDriver driver,Func<PlayerS
17 20 private NativeFlightTestProtection? nativeProtection;
18 21 private readonly SemaphoreSlim nativeGate=new(1,1);
19 22 public GameSessionTarget? BoundTarget{get{lock(gate)return target;}}
23 public ShipProtectionTrigger? LastTrigger{get{lock(gate)return lastTrigger;}}
24 public GameSessionTarget? RestartTarget{get{lock(gate)return replacement;}}
25 public bool ExitOnInterdiction{get;init;}=true;
20 26 public bool ContinuousHealthReady=>nativeProtection?.Fresh==true;
21 27 public bool AutomaticExitEnabled=>automaticExit;
22 28 public event Action<ShipProtectionProgress>? Progress;
@@ -34,10 +40,10 @@ public sealed class ShipProtectionMonitor(IGameSessionDriver driver,Func<PlayerS
34 40 lock(gate)
35 41 {
36 42 if(lifetime.IsCancellationRequested)return;
37 target=next;enabled=true;guard.SetEnabled(true,DateTimeOffset.UtcNow);guard.Prime(player(),next);
43 target=next;enabled=true;lastTrigger=null;replacement=null;interdiction.Reset(DateTimeOffset.UtcNow);guard.SetEnabled(true,DateTimeOffset.UtcNow);guard.Prime(player(),next);
38 44 }
39 45 if(TestingBlocked)StopAutomation();
40 Report(false,TestingBlocked?"船体低于 50% · 自动操作和测试已停止,等待修复":automaticExit?"日志监测中 · 破盾或低血量时大退":"人工观察模式 · 异常只停自动输入,不退出游戏");
46 Report(false,TestingBlocked?"船体低于 50% · 自动操作和测试已停止,等待修复":automaticExit?"保护监测中 · 被拦截立即大退;受损后破盾或低血量大退":"人工观察模式 · 异常只停自动输入,不退出游戏");
41 47 }
42 48 public async Task AttachNativeAsync(string gameHash,CancellationToken token=default,bool exitForFailedHandoff=true)
43 49 {
@@ -48,7 +54,7 @@ public sealed class ShipProtectionMonitor(IGameSessionDriver driver,Func<PlayerS
48 54 if(nativeProtection is {Stopped:false})return;
49 55 if(nativeProtection is {} previous){nativeProtection=null;await previous.DisposeAsync();}
50 56 nativeProtection=await NativeFlightTestProtection.ArmAsync(expected.ProcessId,gameHash,driver,player,StopAutomation,token,this,automaticExit,exitForFailedHandoff);
51 Report(false,"连续船体与护盾监测中 · 受损后破盾或低血量立即大退");
57 Report(false,"连续保护中 · 被拦截立即大退;受损后破盾或低血量立即大退");
52 58 }
53 59 finally{nativeGate.Release();}
54 60 }
@@ -60,11 +66,12 @@ public sealed class ShipProtectionMonitor(IGameSessionDriver driver,Func<PlayerS
60 66 }
61 67 public void Observe(ObservedJournalEvent observed,PlayerState state)
62 68 {
69 if(ObserveInterdiction(state,DateTimeOffset.UtcNow,observed))return;
63 70 GameSessionTarget? restart=null;ShipProtectionTrigger? trigger=null;bool blocked;
64 71 lock(gate)
65 72 {
66 73 if(!enabled||target is null||emergency is {IsCompleted:false})return;
67 if(guard.Observe(observed,state,target)){restart=target;trigger=guard.LastTrigger;}
74 if(guard.Observe(observed,state,target)){restart=target;trigger=lastTrigger=guard.LastTrigger;}
68 75 blocked=guard.TestingBlocked;
69 76 if(restart is not null)
70 77 {
@@ -80,12 +87,25 @@ public sealed class ShipProtectionMonitor(IGameSessionDriver driver,Func<PlayerS
80 87 lock(gate)
81 88 {
82 89 if(!enabled||target is null||emergency is {IsCompleted:false})return;
83 if(guard.ObserveHealth(sample,player(),target,now)){restart=target;trigger=guard.LastTrigger;}
90 if(guard.ObserveHealth(sample,player(),target,now)){restart=target;trigger=lastTrigger=guard.LastTrigger;}
84 91 blocked=guard.TestingBlocked;
85 92 if(restart is not null){StopAutomation();if(automaticExit)emergency=Task.Run(()=>ProtectAsync(restart,trigger));}
86 93 }
87 94 if(blocked)StopAutomation();
88 95 }
96 public bool ObserveInterdiction(PlayerState state,DateTimeOffset now,ObservedJournalEvent? observed=null)
97 {
98 lock(gate)
99 {
100 if(!enabled||target is null||!automaticExit||!ExitOnInterdiction||emergency is {IsCompleted:false})return false;
101 if(!(observed is null?interdiction.ObserveStatus(state,target,now):interdiction.ObserveJournal(observed,state,target,now)))return false;
102 var expected=target;var trigger=new ShipProtectionTrigger(ShipProtectionReason.Interdicted,null,now);lastTrigger=trigger;
103 // The exit starts independently of worker cancellation, UI dispatch,
104 // health reads, escape-vector alignment and launcher validation.
105 emergency=Task.Run(()=>ProtectAsync(expected,trigger));
106 }
107 StopAutomation();return true;
108 }
89 109 private async Task ProtectAsync(GameSessionTarget expected,ShipProtectionTrigger? trigger)
90 110 {
91 111 Report(true,(trigger?.Message??"飞船受损")+" · 正在大退");
@@ -94,13 +114,14 @@ public sealed class ShipProtectionMonitor(IGameSessionDriver driver,Func<PlayerS
94 114 var sessions=new GameSessionController(driver,()=>false);
95 115 sessions.Reentry+=entry=>
96 116 {
117 if(entry.Stage==SessionReentryStage.ProcessReady)lock(gate)replacement=entry.Target;
97 118 if(Reentry is not {} handlers)return;
98 119 foreach(Action<SessionReentryEvent> handler in handlers.GetInvocationList())
99 120 try{handler(entry);}catch{ /* A notification failure cannot affect emergency protection. */ }
100 121 };
101 122 await sessions.EmergencyRestartAsync(expected,lifetime.Token);
102 123 lock(gate){target=null;}
103 Report(false,TestingBlocked?"已大退重启 · 船体低于 50%,测试已停止,修复后再继续":"已大退重启 · 等待新会话;初始无盾不触发");
124 Report(false,TestingBlocked?"已大退重启 · 船体低于 50%,测试已停止,修复后再继续":trigger?.Reason==ShipProtectionReason.Interdicted?"拦截已大退保护 · 等待原模式恢复并继续原货物":"已大退重启 · 等待新会话;初始无盾不触发");
104 125 }
105 126 catch(OperationCanceledException)when(lifetime.IsCancellationRequested){Report(false,"已取消后续重启等待");}
106 127 catch(Exception error){Report(false,"飞船保护停止 · "+error.Message);}
Modified XFEEDToolBox.Test/AutomaticTradeTests.cs +6 -0
@@ -16,6 +16,12 @@ public class AutomaticTradeTests
16 16 value.Validate();(value with{CargoCapacity=752}).Validate();(value with{CargoCapacity=2048}).Validate();
17 17 Check.Throws<InvalidDataException>(()=>(value with{Destination=Station(7)}).Validate());
18 18 }
19 [Test]public void InterdictionDefaultsToImmediateExitAndKeepsLegacyRoutesCompatible()
20 {
21 using var directory=new TemporaryDirectory();var value=new AutomaticTradeConfiguration{Source=Station(7),Destination=Station(98),WorkingDirectory=directory.Path};
22 Check.That(value.ExitOnInterdiction&&!value.RequireInterdictionEscape);value.Validate();(value with{RequireInterdictionEscape=true}).Validate();
23 Check.Throws<InvalidDataException>(()=>(value with{ExitOnInterdiction=false}).Validate());
24 }
19 25 [Test]public void OrdinaryCargoCanUseNonCgCommoditiesAndAllOfTheLiveHold()
20 26 {
21 27 var order=AutomaticTradePlanner.Select([Quote(1,"gold",100,90)],[Quote(2,"gold",50,120)],752,1000000,2000);
Added XFEEDToolBox.Test/InterdictionProtectionTests.cs +99 -0
@@ -0,0 +1,99 @@
1 using System.Collections.Concurrent;
2 using XFEEDToolBox.Core.Data;
3 using XFEEDToolBox.Core.Models;
4 using XFEEDToolBox.Core.Session;
5 using XFEExtension.NetCore.XUnit.Attributes;
6
7 namespace XFEEDToolBox.Test;
8
9 [TestFixture]
10 public class InterdictionProtectionTests
11 {
12 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,
13 Status=new(flags,0,1,1,0,null,null,null,null,[]),Ships=[new(){ShipId=7,HullHealth=1,ConfigurationAt=now.AddMinutes(-1),Modules=[]}]};
14 private static GameSessionTarget Target(DateTimeOffset now)=>new(12,now.AddMinutes(-2).UtcTicks,"fixture-game","fixture-launcher",false);
15 private static InterdictionExitGuard Guard(DateTimeOffset now){var guard=new InterdictionExitGuard();guard.Reset(now.AddSeconds(-1));return guard;}
16 [Test] public void FreshInterdictionTriggersWithFullHullAndNoShieldAndOnlyOncePerSession()
17 {
18 var now=DateTimeOffset.UtcNow;var ship=Ship(now);var guard=Guard(now);
19 Check.That(guard.ObserveStatus(ship,Target(now),now));Check.That(!guard.ObserveStatus(ship,Target(now),now));
20 Check.That(guard.ObserveStatus(ship with{LoadGameAt=now.AddSeconds(-1)},Target(now) with{ProcessId=13,StartedUtcTicks=now.AddSeconds(-2).UtcTicks},now));
21 }
22 [Test] public void GeneralDangerHeatUnshieldedBaselineAndUnderAttackAreNotInterdictionWitnesses()
23 {
24 var now=DateTimeOffset.UtcNow;foreach(var flags in new[]{1L<<24,(1L<<24)|(1L<<22),(1L<<24)|(1L<<20)})
25 {var ship=Ship(now,flags);Check.That(!Guard(now).ObserveStatus(ship,Target(now),now));}
26 var attack=new ObservedJournalEvent(1,now,Check.Event(new{timestamp=now,@event="UnderAttack",Target="You"}));
27 Check.That(!Guard(now).ObserveJournal(attack,Ship(now,1L<<24),Target(now),now));
28 }
29 [Test] public void HistoricalOtherSessionDockedFighterAndStaleStatusCannotExitTheGame()
30 {
31 var now=DateTimeOffset.UtcNow;var ship=Ship(now);
32 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)},
33 ship with{CurrentShipId=99},ship with{LoadGameAt=now.AddMinutes(-3)},ship with{Status=ship.Status! with{Flags=(1L<<23)|(1L<<25)}}})
34 Check.That(!Guard(now).ObserveStatus(state,Target(now),now));
35 }
36 [Test] public void FreshVictimJournalIsAFallbackAndDoesNotRepeatAStatusExit()
37 {
38 var now=DateTimeOffset.UtcNow;var ship=Ship(now,1L<<24);var guard=Guard(now);
39 var witness=new ObservedJournalEvent(1,now,Check.Event(new{timestamp=now,@event="Interdicted",Submitted=true,Interdictor="Snout",IsPlayer=false}));
40 Check.That(guard.ObserveJournal(witness,ship,Target(now),now));Check.That(!guard.ObserveStatus(Ship(now),Target(now),now));
41 Check.That(!Guard(now).ObserveJournal(witness with{Data=Check.Event(new{timestamp=now.AddMinutes(-1),@event="Interdicted"})},ship,Target(now),now));
42 }
43 [Test] public void JournalProjectionCanTriggerBeforeTheLiveEventCallbackButAttackAloneCannot()
44 {
45 var now=DateTimeOffset.UtcNow;var projector=new XFEEDToolBox.Core.Navigation.NavigationProjector();
46 projector.Apply(Check.Event(new{timestamp=now,@event="Interdicted",Submitted=true}));
47 var ship=Ship(now,1L<<24) with{Navigation=projector.State};Check.That(Guard(now).ObserveStatus(ship,Target(now),now));
48 projector.Apply(Check.Event(new{timestamp=now.AddMilliseconds(1),@event="LoadGame"}));Check.That(projector.State.InterdictedAt is null);
49 projector.Apply(Check.Event(new{timestamp=now.AddMilliseconds(2),@event="UnderAttack"}));
50 Check.That(!Guard(now).ObserveStatus(ship with{Navigation=projector.State},Target(now),now));
51 }
52 private sealed class Sessions(DateTimeOffset now):IGameSessionDriver
53 {
54 public bool CanRelog=>false;
55 public readonly ConcurrentQueue<string> Calls=new();
56 public readonly TaskCompletionSource Killed=new(TaskCreationOptions.RunContinuationsAsynchronously);
57 public TaskCompletionSource? LaunchGate;
58 public bool MissingLauncher,WrongProcess;
59 public GameSessionTarget Expected=Target(now);
60 public Task<GameSessionTarget> FindTargetAsync(CancellationToken token)=>Task.FromResult(Expected);
61 public Task<GameSessionTarget> FindLaunchTargetAsync(CancellationToken token)=>throw new NotSupportedException();
62 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;}
63 public Task ValidateRestartAsync(GameSessionTarget actual,CancellationToken token)=>throw new NotSupportedException();
64 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);}
65 public Task TerminateAsync(GameSessionTarget actual,CancellationToken token){Calls.Enqueue("exit");Killed.TrySetResult();return Task.CompletedTask;}
66 public Task<bool> HasExitedAsync(GameSessionTarget actual,CancellationToken token)=>Task.FromResult(true);
67 public Task LaunchAsync(GameSessionTarget actual,CancellationToken token){Calls.Enqueue("launch");return Task.CompletedTask;}
68 public Task<GameSessionTarget?> FindReplacementAsync(GameSessionTarget actual,CancellationToken token)=>Task.FromResult<GameSessionTarget?>(actual with{ProcessId=13,StartedUtcTicks=now.UtcTicks});
69 public Task RelogAsync(GameSessionTarget actual,CancellationToken token)=>throw new InvalidOperationException("Interdiction must not use menu relog");
70 }
71 [Test] public async Task ImmediateExitPrecedesLauncherChecksAndDoesNotWaitForWorkerOrUiCallbacks()
72 {
73 var now=DateTimeOffset.UtcNow;var player=Ship(now);var sessions=new Sessions(now){LaunchGate=new(TaskCreationOptions.RunContinuationsAsynchronously)};
74 using var releaseWorker=new ManualResetEventSlim();await using var monitor=new ShipProtectionMonitor(sessions,()=>player,()=>releaseWorker.Wait());
75 monitor.Progress+=_=>throw new InvalidOperationException("busy UI fixture");await monitor.EnableAsync();
76 var trigger=Task.Run(()=>monitor.ObserveInterdiction(player,now));
77 try{await sessions.Killed.Task.WaitAsync(TimeSpan.FromSeconds(2));Check.That(!trigger.IsCompleted);Check.Equal("validate-exit,exit",string.Join(',',sessions.Calls.Take(2)));}
78 finally{releaseWorker.Set();sessions.LaunchGate.TrySetResult();}
79 Check.That(await trigger);await monitor.Completion.WaitAsync(TimeSpan.FromSeconds(2));
80 Check.Equal(ShipProtectionReason.Interdicted,monitor.LastTrigger!.Reason);Check.Equal(13,monitor.RestartTarget!.ProcessId);
81 Check.That(!monitor.ObserveInterdiction(player,now));Check.Equal(1,sessions.Calls.Count(call=>call=="exit"));
82 }
83 [Test] public async Task MissingLauncherStillExitsButAChangedProcessIdentityCannotBeTerminated()
84 {
85 foreach(var wrongProcess in new[]{false,true})
86 {
87 var now=DateTimeOffset.UtcNow;var player=Ship(now);var sessions=new Sessions(now){MissingLauncher=true,WrongProcess=wrongProcess};
88 await using var monitor=new ShipProtectionMonitor(sessions,()=>player,()=>{});await monitor.EnableAsync();Check.That(monitor.ObserveInterdiction(player,now));await monitor.Completion;
89 Check.Equal(!wrongProcess,sessions.Calls.Contains("exit"));Check.That(!sessions.Calls.Contains("launch"));
90 }
91 }
92 [Test] public async Task ExplicitObserverOnlyModeDoesNotTerminateAndDefaultPolicyDoes()
93 {
94 var now=DateTimeOffset.UtcNow;var player=Ship(now);var sessions=new Sessions(now);
95 await using(var observer=new ShipProtectionMonitor(sessions,()=>player,()=>{},automaticExit:false))
96 {await observer.EnableAsync();Check.That(!observer.ObserveInterdiction(player,now));Check.Equal(0,sessions.Calls.Count);}
97 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"));
98 }
99 }
Modified XFEEDToolBox/Services/WindowsTradingBootstrap.cs +2 -1
@@ -10,6 +10,7 @@ namespace XFEEDToolBox.Services;
10 10 public sealed class WindowsTradingBootstrap(Func<PlayerState> player,string executable,string packageDirectory,string? operationDirectory=null)
11 11 {
12 12 public bool ReconcileOriginalOrders{get;init;}
13 public bool ResumeExistingCargo{get;init;}
13 14 public event Action<string>? Progress;
14 15 public async Task UpgradeAndEnterAsync(CancellationToken token)
15 16 {
@@ -31,7 +32,7 @@ public sealed class WindowsTradingBootstrap(Func<PlayerState> player,string exec
31 32 var entry=new NativeSessionEntryWorkflow(new NativeRelogChannel(target.ProcessId,NativeRelogChannel.SupportedGameSha256,entryOnly:true),player,mode,commander);
32 33 entry.Progress+=text=>Progress?.Invoke(text);await entry.RunAsync(token).ConfigureAwait(false);
33 34 }
34 if(player().CommanderId!=commander||player().CurrentShipId!=original.CurrentShipId||player().CargoCount!=0||player().GameMode!=mode)
35 if(player().CommanderId!=commander||player().CurrentShipId!=original.CurrentShipId||!ResumeExistingCargo&&player().CargoCount!=0||player().GameMode!=mode||!player().CargoReliable)
35 36 throw new InvalidDataException("恢复后的指挥官、飞船、空舱或游戏模式不一致,未继续自动贸易。");
36 37 return;
37 38 }
Modified XFEEDToolBox/ViewModels/AutomaticTradeViewModel.cs +22 -0
@@ -112,12 +112,24 @@ public partial class ToolboxViewModel
112 112 using var stop=CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);automaticTradeStop=stop;RunActive=true;HasError=false;
113 113 NativeTradeCommandDriver? driver=null;
114 114 AutomaticTradeRunSession? financialSession=null;
115 DateTimeOffset lastInterdictionRecovery=default;
116 bool InterdictionRecoveryPending()
117 {
118 // Navigation may reject the new Status before the health poll's
119 // next tick. Consume that same fresh witness before handling failure.
120 shipProtection?.ObserveInterdiction(runtime.Player,DateTimeOffset.UtcNow);
121 return shipProtection?.LastTrigger is {Reason:ShipProtectionReason.Interdicted} trigger&&trigger.At>lastInterdictionRecovery;
122 }
115 123 try
116 124 {
117 125 while(!stop.IsCancellationRequested)
118 126 {
119 127 try
120 128 {
129 var existingSession=new WindowsTradingBootstrap(()=>runtime.Player,PluginGameExecutable,Path.Combine(AppContext.BaseDirectory,"PluginPackage"),Path.Combine(parameters.WorkingDirectory,"operations")){ResumeExistingCargo=true};
130 existingSession.Progress+=text=>dispatcher.TryEnqueue(()=>{RunMessage=text;runtime.Log("跑商会话恢复",text);});
131 await Task.Run(()=>existingSession.UpgradeAndEnterAsync(stop.Token),stop.Token);
132 await ReadPluginStatusAsync();
121 133 while(!runtime.Player.InGame||runtime.Player.CurrentShipId is null||runtime.Player.CargoCapacity<=0||runtime.Player.Rebuy<=0||!runtime.Player.CargoReliable||!runtime.Player.CreditsReliable)
122 134 {
123 135 RunStatus="等待游戏就绪";RunMessage="正在等待游戏载入及当前飞船、货舱和余额就绪,完成后自动继续。";HasError=false;
@@ -159,6 +171,16 @@ public partial class ToolboxViewModel
159 171 await Task.Run(()=>engine.RunAsync(stop.Token),stop.Token);
160 172 break;
161 173 }
174 catch(Exception)when(!stop.IsCancellationRequested&&InterdictionRecoveryPending())
175 {
176 var protection=shipProtection!;lastInterdictionRecovery=protection.LastTrigger!.At;
177 if(driver is not null){try{await driver.ReleaseAllAsync();}catch(Exception error)when(error is IOException or InvalidOperationException or OperationCanceledException){}driver=null;}
178 RunStatus="拦截保护恢复中";RunMessage="已检测到拦截并大退,正在通过官方启动器恢复原模式和同一批货物。";HasError=false;
179 await protection.Completion.WaitAsync(stop.Token);
180 if(protection.RestartTarget is null)throw new IOException("拦截已保护退出,但尚未确认官方启动器的新游戏实例。");
181 // The next iteration enters the original mode in the verified
182 // replacement process and resumes its durable cargo checkpoint.
183 }
162 184 catch(Exception error)when(NativeOrderReconciliationRequiredException.Present(error)&&!stop.IsCancellationRequested)
163 185 {
164 186 if(driver is not null){await driver.ReleaseAllAsync();driver=null;}
Modified XFEEDToolBox/XFEEDToolBox.csproj +1 -1
@@ -2,7 +2,7 @@
2 2 <PropertyGroup>
3 3 <OutputType>WinExe</OutputType>
4 4 <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
5 <Version>0.4.19</Version>
5 <Version>0.4.20</Version>
6 6 <TargetFramework>net10.0-windows10.0.22621.0</TargetFramework>
7 7 <TargetPlatformMinVersion>10.0.19041.0</TargetPlatformMinVersion>
8 8 <ApplicationManifest>app.manifest</ApplicationManifest>