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 Calls=new(); public readonly TaskCompletionSource Killed=new(TaskCreationOptions.RunContinuationsAsynchronously); public TaskCompletionSource? LaunchGate; public bool MissingLauncher,WrongProcess; public GameSessionTarget Expected=Target(now); public Task FindTargetAsync(CancellationToken token)=>Task.FromResult(Expected); public Task 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 HasExitedAsync(GameSessionTarget actual,CancellationToken token)=>Task.FromResult(true); public Task LaunchAsync(GameSessionTarget actual,CancellationToken token){Calls.Enqueue("launch");return Task.CompletedTask;} public Task FindReplacementAsync(GameSessionTarget actual,CancellationToken token)=>Task.FromResult(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")); } }