using System.Text.Json;
using XFEEDToolBox.Core.Data;
using XFEEDToolBox.Core.Models;
namespace XFEEDToolBox.Core.Navigation;
public static class ScoopRules
{
public static bool CanRefuel(string starClass)
{
var value=starClass.Trim().ToUpperInvariant();
// Public catalogues include descriptions such as "K (Yellow-Orange) Star".
// Only an exact class or its explicit parenthesized description is accepted.
return value.Length>0&&"OBAFGKM".Contains(value[0])&&(value.Length==1||value.StartsWith(value[0]+" (",StringComparison.Ordinal));
}
}
public sealed record NavWaypoint(long SystemAddress,string Name,Coordinates Position,string StarClass)
{
public bool Scoopable=>ScoopRules.CanRefuel(StarClass);
public string RefuelText=>Scoopable?"可采集氢燃料":"不可采集氢燃料";
}
public sealed record NavigationJournalState
{
public IReadOnlyList<NavWaypoint> Route { get; init; }=[];
public DateTimeOffset? RouteAt { get; init; }
public long? CurrentSystemAddress { get; init; }
public DateTimeOffset? CurrentSystemAt { get; init; }
public long? TargetSystemAddress { get; init; }
public string TargetSystem { get; init; }="";
public string TargetStarClass { get; init; }="";
public int? RemainingJumps { get; init; }
public DateTimeOffset? TargetAt { get; init; }
public double? LastFuelUsed { get; init; }
public double? MainFuel { get; init; }
public DateTimeOffset? FuelAt { get; init; }
public bool HeatAlert { get; init; }
public DateTimeOffset? HeatAlertAt { get; init; }
public bool ExternalThreat { get; init; }
public DateTimeOffset? ExternalThreatAt { get; init; }
public DateTimeOffset? InterdictedAt { get; init; }
public bool JumpInProgress { get; init; }
public long? JumpTargetSystemAddress { get; init; }
public string JumpTargetSystem { get; init; }="";
public DateTimeOffset? JumpStartedAt { get; init; }
public DateTimeOffset? JumpStateAt { get; init; }
public DateTimeOffset? LastJumpAt { get; init; }
public DateTimeOffset? SupercruiseTransitionAt { get; init; }
public DateTimeOffset? SupercruiseEnteredAt { get; init; }
public DateTimeOffset? SupercruiseExitedAt { get; init; }
public DateTimeOffset? UndockedAt { get; init; }
public long? UndockedMarketId { get; init; }
public string UndockedStationName { get; init; }="";
public long? UndockedSystemAddress { get; init; }
public bool DockingComputerActive { get; init; }
public DateTimeOffset? DockingComputerAt { get; init; }
}
/// <summary>File/journal observations only; no inferred pose, heat percentage or native control.</summary>
public sealed class NavigationProjector
{
private bool cleared;
public NavigationJournalState State { get; private set; }=new();
public void Reset(){State=new();cleared=false;}
public void Apply(JsonElement e)
{
var at=e.Time()??DateTimeOffset.MinValue;var s=State;
switch(e.Text("event"))
{
case "NavRoute" when e.Get("Route").ValueKind!=JsonValueKind.Array:
if(!(s.RouteAt>at))cleared=false;break;
case "NavRoute" when e.Get("Route").ValueKind==JsonValueKind.Array && !(s.RouteAt>at) && !(cleared&&s.RouteAt==at):
var route=new List<NavWaypoint>();
foreach(var item in e.Get("Route").Items())
{
var coordinates=item.Position();var address=item.Long("SystemAddress");var name=item.Text("StarSystem");
if(address<=0||name.Length==0||coordinates is not {IsValid:true})throw new InvalidDataException("游戏航线包含无效星系标识或坐标,未替换原航线。");
route.Add(new(address,name,coordinates,item.Text("StarClass")));
if(route.Count>25000)throw new InvalidDataException("游戏航线超出读取上限。");
}
s=s with{Route=route,RouteAt=at};cleared=false;break;
case "NavRouteClear" when !(s.RouteAt>at):s=s with{Route=[],RouteAt=at,TargetSystemAddress=null,TargetSystem="",RemainingJumps=null,TargetAt=at};cleared=true;break;
case "FSDTarget" when !(s.TargetAt>at):
s=s with{TargetSystemAddress=e.Get("SystemAddress").ValueKind==JsonValueKind.Number?e.Long("SystemAddress"):null,TargetSystem=e.Text("Name"),TargetStarClass=e.Text("StarClass"),RemainingJumps=e.Number("RemainingJumpsInRoute") is {} left?(int)left:null,TargetAt=at};break;
case "Location" or "FSDJump" or "CarrierJump" when !(s.CurrentSystemAt>at) && !(s.JumpStateAt>at):
s=s with{CurrentSystemAddress=e.Get("SystemAddress").ValueKind==JsonValueKind.Number?e.Long("SystemAddress"):s.CurrentSystemAddress,CurrentSystemAt=at,JumpInProgress=false,JumpStateAt=at};
if(e.Text("event")=="FSDJump")
{
s=s with{LastFuelUsed=e.Number("FuelUsed"),LastJumpAt=at};
if(!(s.FuelAt>at))s=s with{MainFuel=e.Number("FuelLevel"),FuelAt=at};
}
break;
case "StartJump" when !(s.JumpStateAt>at) && !(s.CurrentSystemAt>at) && !(s.LastJumpAt>=at):
var hyperspace=e.Text("JumpType")=="Hyperspace";
s=s with{JumpInProgress=hyperspace,JumpTargetSystemAddress=hyperspace&&e.Long("SystemAddress")>0?e.Long("SystemAddress"):null,JumpTargetSystem=hyperspace?e.Text("StarSystem"):"",JumpStartedAt=hyperspace?at:null,JumpStateAt=at,SupercruiseTransitionAt=hyperspace?s.SupercruiseTransitionAt:at};break;
case "SupercruiseEntry" when !(s.SupercruiseEnteredAt>at):s=s with{SupercruiseEnteredAt=at};break;
case "SupercruiseExit" when !(s.SupercruiseExitedAt>at):s=s with{SupercruiseExitedAt=at};break;
case "Undocked" when !(s.UndockedAt>at):s=s with{UndockedAt=at,UndockedMarketId=e.Long("MarketID") is >0 and var undockedMarket?undockedMarket:null,
UndockedStationName=e.Text("StationName"),UndockedSystemAddress=s.CurrentSystemAddress};break;
case "Music" when !(s.DockingComputerAt>at):s=s with{DockingComputerActive=e.Text("MusicTrack")=="DockingComputer",DockingComputerAt=at};break;
case "FuelScoop" when !(s.FuelAt>at):s=s with{MainFuel=e.Number("Total")??s.MainFuel,FuelAt=at};break;
case "Status":
if(!(s.FuelAt>at)&&e.Get("Fuel").Number("FuelMain") is {} fuel)s=s with{MainFuel=fuel,FuelAt=at};
var dangerFlags=e.Long("Flags");
if(!(s.ExternalThreatAt>at)&&(dangerFlags&(1L<<23))!=0)s=s with{ExternalThreat=true,ExternalThreatAt=at};
else if((s.ExternalThreatAt is null||at>s.ExternalThreatAt)&&(dangerFlags&((1L<<22)|(1L<<23)))==0)
s=s with{ExternalThreat=false,ExternalThreatAt=at};
if(!(s.HeatAlertAt>at))s=s with{HeatAlert=(e.Long("Flags")&(1L<<20))!=0,HeatAlertAt=at};break;
case "Interdicted" when !(s.ExternalThreatAt>at):s=s with{ExternalThreat=true,ExternalThreatAt=at,InterdictedAt=at};break;
case "UnderAttack" when !(s.ExternalThreatAt>at):s=s with{ExternalThreat=true,ExternalThreatAt=at};break;
case "LoadGame" when !(s.ExternalThreatAt>at):s=s with{ExternalThreat=false,ExternalThreatAt=at,InterdictedAt=null,DockingComputerActive=false,DockingComputerAt=at};break;
case "HeatWarning" or "HeatDamage" when !(s.HeatAlertAt>at):s=s with{HeatAlert=true,HeatAlertAt=at};break;
case "Shutdown" when !(s.JumpStateAt>at):s=s with{JumpInProgress=false,JumpStateAt=at};break;
}
State=s;
}
public static IReadOnlyList<NavWaypoint> RemainingRoute(NavigationJournalState state,string destination)
{
if(state.Route.Count<1)throw new InvalidOperationException("游戏尚未生成航路,请先规划航线。");
if(!state.Route[^1].Name.Equals(destination.Trim(),StringComparison.OrdinalIgnoreCase))throw new InvalidOperationException("游戏航线终点与软件中设定的目标星系不一致。");
var index=state.Route.ToList().FindIndex(w=>w.SystemAddress==state.CurrentSystemAddress);
if(index<0)throw new InvalidOperationException("当前星系不在已读取的游戏航线中,请重新规划。");
var remaining=state.Route.Skip(index+1).ToArray();
if(remaining.Length>0&&state.TargetSystemAddress is {} target&&target!=remaining[0].SystemAddress)throw new InvalidOperationException("游戏当前锁定的下一跳与航线不一致。");
return remaining;
}
}
using System.Text.Json;
using XFEEDToolBox.Core.Data;
using XFEEDToolBox.Core.Models;
namespace XFEEDToolBox.Core.Navigation;
public static class ScoopRules
{
public static bool CanRefuel(string starClass)
{
var value=starClass.Trim().ToUpperInvariant();
// Public catalogues include descriptions such as "K (Yellow-Orange) Star".
// Only an exact class or its explicit parenthesized description is accepted.
return value.Length>0&&"OBAFGKM".Contains(value[0])&&(value.Length==1||value.StartsWith(value[0]+" (",StringComparison.Ordinal));
}
}
public sealed record NavWaypoint(long SystemAddress,string Name,Coordinates Position,string StarClass)
{
public bool Scoopable=>ScoopRules.CanRefuel(StarClass);
public string RefuelText=>Scoopable?"可采集氢燃料":"不可采集氢燃料";
}
public sealed record NavigationJournalState
{
public IReadOnlyList<NavWaypoint> Route { get; init; }=[];
public DateTimeOffset? RouteAt { get; init; }
public long? CurrentSystemAddress { get; init; }
public DateTimeOffset? CurrentSystemAt { get; init; }
public long? TargetSystemAddress { get; init; }
public string TargetSystem { get; init; }="";
public string TargetStarClass { get; init; }="";
public int? RemainingJumps { get; init; }
public DateTimeOffset? TargetAt { get; init; }
public double? LastFuelUsed { get; init; }
public double? MainFuel { get; init; }
public DateTimeOffset? FuelAt { get; init; }
public bool HeatAlert { get; init; }
public DateTimeOffset? HeatAlertAt { get; init; }
public bool ExternalThreat { get; init; }
public DateTimeOffset? ExternalThreatAt { get; init; }
public DateTimeOffset? InterdictedAt { get; init; }
public bool JumpInProgress { get; init; }
public long? JumpTargetSystemAddress { get; init; }
public string JumpTargetSystem { get; init; }="";
public DateTimeOffset? JumpStartedAt { get; init; }
public DateTimeOffset? JumpStateAt { get; init; }
public DateTimeOffset? LastJumpAt { get; init; }
public DateTimeOffset? SupercruiseTransitionAt { get; init; }
public DateTimeOffset? SupercruiseEnteredAt { get; init; }
public DateTimeOffset? SupercruiseExitedAt { get; init; }
public DateTimeOffset? UndockedAt { get; init; }
public long? UndockedMarketId { get; init; }
public string UndockedStationName { get; init; }="";
public long? UndockedSystemAddress { get; init; }
public bool DockingComputerActive { get; init; }
public DateTimeOffset? DockingComputerAt { get; init; }
}
/// <summary>File/journal observations only; no inferred pose, heat percentage or native control.</summary>
public sealed class NavigationProjector
{
private bool cleared;
public NavigationJournalState State { get; private set; }=new();
public void Reset(){State=new();cleared=false;}
public void Apply(JsonElement e)
{
var at=e.Time()??DateTimeOffset.MinValue;var s=State;
switch(e.Text("event"))
{
case "NavRoute" when e.Get("Route").ValueKind!=JsonValueKind.Array:
if(!(s.RouteAt>at))cleared=false;break;
case "NavRoute" when e.Get("Route").ValueKind==JsonValueKind.Array && !(s.RouteAt>at) && !(cleared&&s.RouteAt==at):
var route=new List<NavWaypoint>();
foreach(var item in e.Get("Route").Items())
{
var coordinates=item.Position();var address=item.Long("SystemAddress");var name=item.Text("StarSystem");
if(address<=0||name.Length==0||coordinates is not {IsValid:true})throw new InvalidDataException("游戏航线包含无效星系标识或坐标,未替换原航线。");
route.Add(new(address,name,coordinates,item.Text("StarClass")));
if(route.Count>25000)throw new InvalidDataException("游戏航线超出读取上限。");
}
s=s with{Route=route,RouteAt=at};cleared=false;break;
case "NavRouteClear" when !(s.RouteAt>at):s=s with{Route=[],RouteAt=at,TargetSystemAddress=null,TargetSystem="",RemainingJumps=null,TargetAt=at};cleared=true;break;
case "FSDTarget" when !(s.TargetAt>at):
s=s with{TargetSystemAddress=e.Get("SystemAddress").ValueKind==JsonValueKind.Number?e.Long("SystemAddress"):null,TargetSystem=e.Text("Name"),TargetStarClass=e.Text("StarClass"),RemainingJumps=e.Number("RemainingJumpsInRoute") is {} left?(int)left:null,TargetAt=at};break;
case "Location" or "FSDJump" or "CarrierJump" when !(s.CurrentSystemAt>at) && !(s.JumpStateAt>at):
s=s with{CurrentSystemAddress=e.Get("SystemAddress").ValueKind==JsonValueKind.Number?e.Long("SystemAddress"):s.CurrentSystemAddress,CurrentSystemAt=at,JumpInProgress=false,JumpStateAt=at};
if(e.Text("event")=="FSDJump")
{
s=s with{LastFuelUsed=e.Number("FuelUsed"),LastJumpAt=at};
if(!(s.FuelAt>at))s=s with{MainFuel=e.Number("FuelLevel"),FuelAt=at};
}
break;
case "StartJump" when !(s.JumpStateAt>at) && !(s.CurrentSystemAt>at) && !(s.LastJumpAt>=at):
var hyperspace=e.Text("JumpType")=="Hyperspace";
s=s with{JumpInProgress=hyperspace,JumpTargetSystemAddress=hyperspace&&e.Long("SystemAddress")>0?e.Long("SystemAddress"):null,JumpTargetSystem=hyperspace?e.Text("StarSystem"):"",JumpStartedAt=hyperspace?at:null,JumpStateAt=at,SupercruiseTransitionAt=hyperspace?s.SupercruiseTransitionAt:at};break;
case "SupercruiseEntry" when !(s.SupercruiseEnteredAt>at):s=s with{SupercruiseEnteredAt=at};break;
case "SupercruiseExit" when !(s.SupercruiseExitedAt>at):s=s with{SupercruiseExitedAt=at};break;
case "Undocked" when !(s.UndockedAt>at):s=s with{UndockedAt=at,UndockedMarketId=e.Long("MarketID") is >0 and var undockedMarket?undockedMarket:null,
UndockedStationName=e.Text("StationName"),UndockedSystemAddress=s.CurrentSystemAddress};break;
case "Music" when !(s.DockingComputerAt>at):s=s with{DockingComputerActive=e.Text("MusicTrack")=="DockingComputer",DockingComputerAt=at};break;
case "FuelScoop" when !(s.FuelAt>at):s=s with{MainFuel=e.Number("Total")??s.MainFuel,FuelAt=at};break;
case "Status":
if(!(s.FuelAt>at)&&e.Get("Fuel").Number("FuelMain") is {} fuel)s=s with{MainFuel=fuel,FuelAt=at};
var dangerFlags=e.Long("Flags");
if(!(s.ExternalThreatAt>at)&&(dangerFlags&(1L<<23))!=0)s=s with{ExternalThreat=true,ExternalThreatAt=at};
else if((s.ExternalThreatAt is null||at>s.ExternalThreatAt)&&(dangerFlags&((1L<<22)|(1L<<23)))==0)
s=s with{ExternalThreat=false,ExternalThreatAt=at};
if(!(s.HeatAlertAt>at))s=s with{HeatAlert=(e.Long("Flags")&(1L<<20))!=0,HeatAlertAt=at};break;
case "Interdicted" when !(s.ExternalThreatAt>at):s=s with{ExternalThreat=true,ExternalThreatAt=at,InterdictedAt=at};break;
case "UnderAttack" when !(s.ExternalThreatAt>at):s=s with{ExternalThreat=true,ExternalThreatAt=at};break;
case "LoadGame" when !(s.ExternalThreatAt>at):s=s with{ExternalThreat=false,ExternalThreatAt=at,InterdictedAt=null,DockingComputerActive=false,DockingComputerAt=at};break;
case "HeatWarning" or "HeatDamage" when !(s.HeatAlertAt>at):s=s with{HeatAlert=true,HeatAlertAt=at};break;
case "Shutdown" when !(s.JumpStateAt>at):s=s with{JumpInProgress=false,JumpStateAt=at};break;
}
State=s;
}
public static IReadOnlyList<NavWaypoint> RemainingRoute(NavigationJournalState state,string destination)
{
if(state.Route.Count<1)throw new InvalidOperationException("游戏尚未生成航路,请先规划航线。");
if(!state.Route[^1].Name.Equals(destination.Trim(),StringComparison.OrdinalIgnoreCase))throw new InvalidOperationException("游戏航线终点与软件中设定的目标星系不一致。");
var index=state.Route.ToList().FindIndex(w=>w.SystemAddress==state.CurrentSystemAddress);
if(index<0)throw new InvalidOperationException("当前星系不在已读取的游戏航线中,请重新规划。");
var remaining=state.Route.Skip(index+1).ToArray();
if(remaining.Length>0&&state.TargetSystemAddress is {} target&&target!=remaining[0].SystemAddress)throw new InvalidOperationException("游戏当前锁定的下一跳与航线不一致。");
return remaining;
}
}