using System.Text.Json; using XFEEDToolBox.Core.Models; using XFEEDToolBox.Core.Navigation; namespace XFEEDToolBox.Core.Execution; public sealed class FlightSessionRestartRequiredException():InvalidOperationException("飞船保护已触发客户端重启,等待原模式和原订单恢复。"); /// Does not authorize inputs: the continuous native health guard is required separately. 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.AttackedAtuntil?previous.AvoidUntil:until}; } } /// Bounded, read-only evidence of attacks during a granted station approach, scoped to this commander and ship. public static class TradeSourceThreatJournal { public static IReadOnlyList Read(string directory,PlayerState player,DateTimeOffset now) { if(!Directory.Exists(directory))return []; var incidents=new List(); 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-permissionnow.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(); } }