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"));
}
}
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"));
}
}