using System.Text.Json;
using XFEEDToolBox.Core.Models;
using XFEEDToolBox.Core.Navigation;
namespace XFEEDToolBox.Core.Execution;
public sealed class FlightSessionRestartRequiredException():InvalidOperationException("飞船保护已触发客户端重启,等待原模式和原订单恢复。");
/// <summary>Does not authorize inputs: the continuous native health guard is required separately.</summary>
public sealed class AutomaticTradeSafetyGate(PlayerState initial)
{
private DateTimeOffset? safeSince;
private long? safeMarket;
public TimeSpan StableDuration{get;init;}=TimeSpan.FromSeconds(10);
public bool RequireDocked{get;init;}=true;
public void Reset(){safeSince=null;safeMarket=null;}
public void RequireSameIdentity(PlayerState current)
{
if(current.CommanderId!=initial.CommanderId||current.CurrentShipId!=initial.CurrentShipId||current.CargoCapacity!=initial.CargoCapacity
||current.LoadGameAt!=initial.LoadGameAt)
throw new InvalidOperationException("安全等待期间身份、飞船、货舱或会话已改变,不能恢复旧控制。");
}
public bool Observe(PlayerState current,DateTimeOffset now,bool continuousHealthReady)
{
RequireSameIdentity(current);
if(!current.InGame||RequireDocked&&(!current.Docked||current.MarketId is not >0)||!current.CargoReliable||!current.CreditsReliable
||current.StatusReadAt is not{} read||now-read>TimeSpan.FromSeconds(2)||read>now.AddSeconds(1)
||NavigationSafety.UnhealthyHull(current)||NavigationSafety.HasExternalThreat(current)||!continuousHealthReady)
{Reset();return false;}
if(safeMarket!=current.MarketId){Reset();safeMarket=current.MarketId;}
safeSince??=now;return now-safeSince>=StableDuration;
}
}
public sealed record TradeSourceThreat(long MarketId,string StationName,DateTimeOffset AttackedAt,DateTimeOffset AvoidUntil)
{
public int Incidents{get;init;}=1;
public static TradeSourceThreat Merge(TradeSourceThreat? previous,TradeSourceThreat incoming)
{
if(previous is not null&&previous.AttackedAt>=incoming.AttackedAt)return previous;
var count=previous is null?1:incoming.AttackedAt-previous.AttackedAt<TimeSpan.FromMinutes(2)?previous.Incidents:Math.Min(32,previous.Incidents+1);
var until=incoming.AttackedAt.AddHours(count==1?1:count==2?24:72);
return incoming with{Incidents=count,AvoidUntil=previous is not null&&previous.AvoidUntil>until?previous.AvoidUntil:until};
}
}
/// <summary>Bounded, read-only evidence of attacks during a granted station approach, scoped to this commander and ship.</summary>
public static class TradeSourceThreatJournal
{
public static IReadOnlyList<TradeSourceThreat> Read(string directory,PlayerState player,DateTimeOffset now)
{
if(!Directory.Exists(directory))return [];
var incidents=new List<TradeSourceThreat>();
foreach(var path in Directory.EnumerateFiles(directory,"Journal*.log").OrderByDescending(File.GetLastWriteTimeUtc).Take(8))
{
try
{
using var file=new FileStream(path,FileMode.Open,FileAccess.Read,FileShare.ReadWrite|FileShare.Delete);
using var header=new StreamReader(file,leaveOpen:true);string? commander=null;long? ship=null;
for(var lineIndex=0;lineIndex<20&&header.ReadLine() is{} line;lineIndex++)
{
using var doc=JsonDocument.Parse(line);var row=doc.RootElement;
if(row.TryGetProperty("event",out var kind)&&kind.GetString()=="LoadGame")
{commander=row.GetProperty("FID").GetString();ship=row.GetProperty("ShipID").GetInt64();break;}
}
if(commander!=player.CommanderId||ship!=player.CurrentShipId)continue;
file.Seek(Math.Max(0,file.Length-262144),SeekOrigin.Begin);
using var tail=new StreamReader(file,leaveOpen:true);if(file.Position>0)tail.ReadLine();
long market=0;string station="";DateTimeOffset? granted=null;
while(tail.ReadLine() is{} line)
{
try
{
using var doc=JsonDocument.Parse(line);var row=doc.RootElement;
var kind=row.GetProperty("event").GetString();var at=row.GetProperty("timestamp").GetDateTimeOffset();
if(kind is "FSDJump" or "StartJump"){market=0;granted=null;}
if(kind=="Loadout"&&row.TryGetProperty("ShipID",out var id))ship=id.GetInt64();
if(kind is "DockingGranted" or "Undocked")
{market=row.GetProperty("MarketID").GetInt64();station=row.GetProperty("StationName").GetString()??"";granted=at;}
if(kind=="UnderAttack"&&ship==player.CurrentShipId&&market>0&&row.TryGetProperty("Target",out var target)&&target.GetString()=="You"
&&granted is{} permission&&at>=permission&&at-permission<TimeSpan.FromMinutes(10)&&at>now.AddHours(-1)&&at<=now.AddMinutes(1))
incidents.Add(new(market,station,at,at.AddHours(1)));
}
catch(Exception error)when(error is JsonException or KeyNotFoundException or FormatException or InvalidOperationException){/* Invalid appends are not combat witnesses. */}
}
}
catch(Exception error)when(error is IOException or UnauthorizedAccessException or JsonException or KeyNotFoundException or FormatException or InvalidOperationException){/* Missing evidence cannot authorize recovery. */}
}
return incidents.GroupBy(i=>i.MarketId).Select(g=>g.MaxBy(i=>i.AttackedAt)!).ToArray();
}
}
using System.Text.Json;
using XFEEDToolBox.Core.Models;
using XFEEDToolBox.Core.Navigation;
namespace XFEEDToolBox.Core.Execution;
public sealed class FlightSessionRestartRequiredException():InvalidOperationException("飞船保护已触发客户端重启,等待原模式和原订单恢复。");
/// <summary>Does not authorize inputs: the continuous native health guard is required separately.</summary>
public sealed class AutomaticTradeSafetyGate(PlayerState initial)
{
private DateTimeOffset? safeSince;
private long? safeMarket;
public TimeSpan StableDuration{get;init;}=TimeSpan.FromSeconds(10);
public bool RequireDocked{get;init;}=true;
public void Reset(){safeSince=null;safeMarket=null;}
public void RequireSameIdentity(PlayerState current)
{
if(current.CommanderId!=initial.CommanderId||current.CurrentShipId!=initial.CurrentShipId||current.CargoCapacity!=initial.CargoCapacity
||current.LoadGameAt!=initial.LoadGameAt)
throw new InvalidOperationException("安全等待期间身份、飞船、货舱或会话已改变,不能恢复旧控制。");
}
public bool Observe(PlayerState current,DateTimeOffset now,bool continuousHealthReady)
{
RequireSameIdentity(current);
if(!current.InGame||RequireDocked&&(!current.Docked||current.MarketId is not >0)||!current.CargoReliable||!current.CreditsReliable
||current.StatusReadAt is not{} read||now-read>TimeSpan.FromSeconds(2)||read>now.AddSeconds(1)
||NavigationSafety.UnhealthyHull(current)||NavigationSafety.HasExternalThreat(current)||!continuousHealthReady)
{Reset();return false;}
if(safeMarket!=current.MarketId){Reset();safeMarket=current.MarketId;}
safeSince??=now;return now-safeSince>=StableDuration;
}
}
public sealed record TradeSourceThreat(long MarketId,string StationName,DateTimeOffset AttackedAt,DateTimeOffset AvoidUntil)
{
public int Incidents{get;init;}=1;
public static TradeSourceThreat Merge(TradeSourceThreat? previous,TradeSourceThreat incoming)
{
if(previous is not null&&previous.AttackedAt>=incoming.AttackedAt)return previous;
var count=previous is null?1:incoming.AttackedAt-previous.AttackedAt<TimeSpan.FromMinutes(2)?previous.Incidents:Math.Min(32,previous.Incidents+1);
var until=incoming.AttackedAt.AddHours(count==1?1:count==2?24:72);
return incoming with{Incidents=count,AvoidUntil=previous is not null&&previous.AvoidUntil>until?previous.AvoidUntil:until};
}
}
/// <summary>Bounded, read-only evidence of attacks during a granted station approach, scoped to this commander and ship.</summary>
public static class TradeSourceThreatJournal
{
public static IReadOnlyList<TradeSourceThreat> Read(string directory,PlayerState player,DateTimeOffset now)
{
if(!Directory.Exists(directory))return [];
var incidents=new List<TradeSourceThreat>();
foreach(var path in Directory.EnumerateFiles(directory,"Journal*.log").OrderByDescending(File.GetLastWriteTimeUtc).Take(8))
{
try
{
using var file=new FileStream(path,FileMode.Open,FileAccess.Read,FileShare.ReadWrite|FileShare.Delete);
using var header=new StreamReader(file,leaveOpen:true);string? commander=null;long? ship=null;
for(var lineIndex=0;lineIndex<20&&header.ReadLine() is{} line;lineIndex++)
{
using var doc=JsonDocument.Parse(line);var row=doc.RootElement;
if(row.TryGetProperty("event",out var kind)&&kind.GetString()=="LoadGame")
{commander=row.GetProperty("FID").GetString();ship=row.GetProperty("ShipID").GetInt64();break;}
}
if(commander!=player.CommanderId||ship!=player.CurrentShipId)continue;
file.Seek(Math.Max(0,file.Length-262144),SeekOrigin.Begin);
using var tail=new StreamReader(file,leaveOpen:true);if(file.Position>0)tail.ReadLine();
long market=0;string station="";DateTimeOffset? granted=null;
while(tail.ReadLine() is{} line)
{
try
{
using var doc=JsonDocument.Parse(line);var row=doc.RootElement;
var kind=row.GetProperty("event").GetString();var at=row.GetProperty("timestamp").GetDateTimeOffset();
if(kind is "FSDJump" or "StartJump"){market=0;granted=null;}
if(kind=="Loadout"&&row.TryGetProperty("ShipID",out var id))ship=id.GetInt64();
if(kind is "DockingGranted" or "Undocked")
{market=row.GetProperty("MarketID").GetInt64();station=row.GetProperty("StationName").GetString()??"";granted=at;}
if(kind=="UnderAttack"&&ship==player.CurrentShipId&&market>0&&row.TryGetProperty("Target",out var target)&&target.GetString()=="You"
&&granted is{} permission&&at>=permission&&at-permission<TimeSpan.FromMinutes(10)&&at>now.AddHours(-1)&&at<=now.AddMinutes(1))
incidents.Add(new(market,station,at,at.AddHours(1)));
}
catch(Exception error)when(error is JsonException or KeyNotFoundException or FormatException or InvalidOperationException){/* Invalid appends are not combat witnesses. */}
}
}
catch(Exception error)when(error is IOException or UnauthorizedAccessException or JsonException or KeyNotFoundException or FormatException or InvalidOperationException){/* Missing evidence cannot authorize recovery. */}
}
return incidents.GroupBy(i=>i.MarketId).Select(g=>g.MaxBy(i=>i.AttackedAt)!).ToArray();
}
}