using System.Text.Json;
using XFEEDToolBox.Core.Models;
namespace XFEEDToolBox.Core.Galaxy;
public sealed record GalaxyQueryResult(IReadOnlyList<GalacticSystem> Systems,int ReportedCount,bool Truncated,string Source)
{
public GalaxyQueryBounds? Bounds { get; init; }
public int PendingQueries { get; init; }
public int CompletedQueries { get; init; }
public bool CoverageComplete { get; init; }
}
public sealed record GalaxyCatalogQueryPlan(string Index,IReadOnlyDictionary<string,object> Filters,bool CertifiesMatch=true);
/// <summary>Viewport-scoped queries using the catalogue's published searchable_fields/field_values schema.</summary>
public sealed class GalaxyQueryService(HttpClient http)
{
public const int MaximumSystems=1500;
private readonly System.Collections.Concurrent.ConcurrentDictionary<string,IReadOnlyList<string>> fields=new(StringComparer.Ordinal);
public static bool UsesPublicCatalog(GalaxyFilterCategory category)=>category is GalaxyFilterCategory.Allegiance or GalaxyFilterCategory.Government or GalaxyFilterCategory.Economy or GalaxyFilterCategory.Security or GalaxyFilterCategory.Population or GalaxyFilterCategory.State or GalaxyFilterCategory.Services or GalaxyFilterCategory.Civilization or GalaxyFilterCategory.Commodity or GalaxyFilterCategory.FleetCarrier or GalaxyFilterCategory.Power or GalaxyFilterCategory.PowerState or GalaxyFilterCategory.PowerActivity or GalaxyFilterCategory.Colonisation or GalaxyFilterCategory.ThargoidWar or GalaxyFilterCategory.Markers;
private static string? Field(GalaxyFilterCategory category)=>category switch
{GalaxyFilterCategory.Allegiance=>"allegiance",GalaxyFilterCategory.Government=>"government",GalaxyFilterCategory.Economy=>"primary_economy",GalaxyFilterCategory.Security=>"security",GalaxyFilterCategory.State=>"controlling_minor_faction_state",GalaxyFilterCategory.Power=>"controlling_power",GalaxyFilterCategory.PowerState=>"power_state",GalaxyFilterCategory.ThargoidWar=>"thargoid_war_state",_=>null};
public async Task<IReadOnlyList<GalaxyFilterChoice>> ChoicesAsync(GalaxyFilterCategory category,CancellationToken token=default)
{
var field=Field(category);var index="systems";
if(category==GalaxyFilterCategory.Commodity){index="stations";field="marketplace";}
if(category==GalaxyFilterCategory.Services){index="stations";field="services";}
if(field is null)return [];
var values=await ValuesAsync(index,field,category==GalaxyFilterCategory.Commodity?"commodity":category==GalaxyFilterCategory.Services?"name":null,token);
return values.Select(v=>new GalaxyFilterChoice(category==GalaxyFilterCategory.Services?ServiceKey(v):GalaxyMapFilter.Normalize(v),v)).OrderBy(x=>x.Label).ToArray();
}
private async Task<IReadOnlyList<string>> ValuesAsync(string index,string field,string? subfield,CancellationToken token)
{
var key=index+"/"+field+"/"+subfield;if(fields.TryGetValue(key,out var cached))return cached;
using var json=await PlaceSearchHttp.ReadAsync(http,$"https://spansh.co.uk/api/{index}/field_values/{field}",null,token);
var values=json.RootElement.GetProperty("values");if(subfield is not null)values=values.GetProperty(subfield);
if(values.ValueKind!=JsonValueKind.Array)throw new InvalidDataException("星图查询选项格式无效。");
var result=values.EnumerateArray().Where(v=>v.ValueKind==JsonValueKind.String).Select(v=>v.GetString()!).Take(2000).ToArray();fields[key]=result;return result;
}
public async Task<IReadOnlyList<GalaxyCatalogQueryPlan>> PlansAsync(GalaxyMapFilter filter,CancellationToken token=default)
{
if(filter.UsesPowerOuter||filter.Category==GalaxyFilterCategory.Commodity&&filter.RequiresOriginalCommodity||!UsesPublicCatalog(filter.Category))return [];
if(filter.Values is {Count:0})return [];
object Group(IEnumerable<string> values)=>new{value=values.ToArray()};
var plans=new List<GalaxyCatalogQueryPlan>();
void Add(string field,object value,string index="systems",bool certify=true)=>plans.Add(new(index,new Dictionary<string,object>{{field,value}},certify));
// Original masks also include unoccupied power flags and intermediate
// stages, which a public controlling_power/state filter cannot express.
if(filter.UsesPowerTerritory&&(filter.TerritoryPowerMask is not null||filter.TerritoryStateMask is not null))
{plans.Add(new("systems",new Dictionary<string,object>(),false));return plans;}
if(Field(filter.Category) is {} groupField)
{
string[]? powerStates=null;
if(filter.Category==GalaxyFilterCategory.Power&&filter.Mode==GalaxyMapMode.Powerplay&&filter.PowerStateValues is {} stateSelection)
{powerStates=(await ValuesAsync("systems","power_state",null,token)).Where(v=>stateSelection.Contains(GalaxyMapFilter.Normalize(v))).ToArray();if(powerStates.Length==0)return plans;}
void ApplyPowerStates()
{if(powerStates is null)return;for(var i=0;i<plans.Count;i++)if(!plans[i].Filters.ContainsKey("power_state"))plans[i]=plans[i] with{Filters=new Dictionary<string,object>(plans[i].Filters){{"power_state",Group(powerStates)}}};}
if(filter.Values is null){plans.Add(new("systems",new Dictionary<string,object>(),false));ApplyPowerStates();return plans;}
if(filter.Category==GalaxyFilterCategory.Power&&filter.Values.Contains("none")&&(powerStates is null||powerStates.Contains("Unoccupied")))Add("power_state",Group(["Unoccupied"]));
var choices=await ValuesAsync("systems",groupField,null,token);
var chosen=choices.Where(v=>filter.Values.Contains(GalaxyMapFilter.Normalize(v))).ToArray();
if(chosen.Length>0){Add(groupField,Group(chosen));if(filter.Category==GalaxyFilterCategory.Economy)Add("secondary_economy",Group(chosen));}
ApplyPowerStates();return plans;
}
if(filter.Category==GalaxyFilterCategory.Population)
{
var minimum=filter.PopulationMinimum>=long.MaxValue?long.MaxValue:(long)Math.Max(0,Math.Ceiling(filter.PopulationMinimum));
if(filter.Values is null){Add("population",new{comparison="<=>",value=new[]{minimum,long.MaxValue}});return plans;}
var bins=new Dictionary<string,(long Min,long Max)>{{"uninhabited",(0,0)},{"1-999",(1,999)},{"1k-1m",(1000,999999)},{"1m-1b",(1000000,999999999)},{"1b+",(1000000000,long.MaxValue)}};
foreach(var entry in bins.Where(v=>filter.Values.Contains(v.Key)&&v.Value.Max>=minimum))Add("population",new{comparison="<=>",value=new[]{Math.Max(minimum,entry.Value.Min),entry.Value.Max}});
}
else if(filter.Category==GalaxyFilterCategory.Services)
{
if(filter.Values is null){plans.Add(new("systems",new Dictionary<string,object>(),false));return plans;}
var source=await ValuesAsync("systems","stations","services",token);var selected=source.Where(s=>filter.Values.Contains(ServiceKey(s))||ServiceKey(s)=="facilitator"&&filter.Values.Contains("interstellarfactors")).ToArray();
if(selected.Length>0)Add("stations",new[]{new{services=Group(selected)}});
foreach(var entry in new[]{("material_trader","materialtrader",new[]{"Raw","Manufactured","Encoded"}),("technology_broker","techbroker",new[]{"Human","Guardian"})})
{
var values=entry.Item3.Where(v=>filter.Values.Contains(entry.Item2+GalaxyMapFilter.Normalize(v))).ToArray();
if(values.Length>0)Add(entry.Item1,Group(values),"stations");
}
}
else if(filter.Category==GalaxyFilterCategory.Commodity)
{
var options=await ValuesAsync("stations","marketplace","commodity",token);var chosen=options.Where(v=>filter.Values is null||filter.Values.Contains(GalaxyMapFilter.Normalize(v))).ToArray();
if(chosen.Length>0)foreach(var field in filter.CommodityDirection==GalaxyCommodityDirection.Both?new[]{"supply","demand"}:new[]{filter.CommodityDirection==GalaxyCommodityDirection.Exports?"supply":"demand"})Add("marketplace",new[]{new Dictionary<string,object>{{"commodity",Group(chosen)},{field,new{comparison="<=>",value=new long[]{1,long.MaxValue}}}}},"stations");
}
else if(filter.Category==GalaxyFilterCategory.FleetCarrier)
{
if(filter.Values?.Contains("present")==true)Add("stations",new[]{new{type=Group(["Drake-Class Carrier"])}});
if(filter.Values is null||filter.Values.Contains("absent"))plans.Add(new("systems",new Dictionary<string,object>(),false));
}
else if(filter.Category==GalaxyFilterCategory.Civilization)
{
var types=filter.Values is null||filter.Values.Contains("guardian")||filter.Values.Contains("thargoid")?await ValuesAsync("systems","landmarks","type",token):[];
var chosen=types.Where(t=>(filter.Values is null||filter.Values.Contains("guardian"))&&t.StartsWith("Guardian",StringComparison.Ordinal)||(filter.Values is null||filter.Values.Contains("thargoid"))&&t.StartsWith("Thargoid",StringComparison.Ordinal)).ToArray();
if(chosen.Length>0)Add("landmarks",new[]{new{type=Group(chosen)}});
if(filter.Values is null||filter.Values.Contains("human"))Add("population",new{comparison="<=>",value=new long[]{1,long.MaxValue}});
}
else if(filter.Category==GalaxyFilterCategory.PowerActivity)
{
if(filter.Values is null||filter.Values.Contains("reinforcement"))Add("power_state_reinforcement",new{comparison="<=>",value=new long[]{1,long.MaxValue}});
if(filter.Values is null||filter.Values.Contains("undermining"))Add("power_state_undermining",new{comparison="<=>",value=new long[]{1,long.MaxValue}});
}
else if(filter.Category==GalaxyFilterCategory.Colonisation)
{
if(filter.Values is null||filter.Values.Contains("alreadypopulated"))Add("population",new{comparison="<=>",value=new long[]{1,long.MaxValue}},certify:false);
if(filter.Values is null||filter.Values.Contains("colonised"))Add("is_colonised",new{value=true},certify:false);
if(filter.Values is null||filter.Values.Contains("beingcolonised"))Add("is_being_colonised",new{value=true},certify:false);
// Claim eligibility and ownership are player-specific native facts, never inferred from population.
}
else if(filter.Category==GalaxyFilterCategory.Markers)
{
bool Selected(string key)=>filter.Values is null||filter.Values.Contains(key);
if(Selected("fleetcarrier"))Add("stations",new[]{new{type=Group(["Drake-Class Carrier"])}});
if(Selected("galacticregion"))plans.Add(new("systems",new Dictionary<string,object>(),false));
if(Selected("poweractivity")){Add("power_state_reinforcement",new{comparison="<=>",value=new long[]{1,long.MaxValue}});Add("power_state_undermining",new{comparison="<=>",value=new long[]{1,long.MaxValue}});}
var states=new Dictionary<string,string[]>{{"damaged",["Damaged","DamagedHuman"]},{"repairing",["UnderRepairs"]},{"underattack",["UnderAttack"]},{"abandoned",["Abandoned"]}};
if(states.Keys.Any(Selected))
{var known=await ValuesAsync("stations","state",null,token);var chosen=states.Where(x=>Selected(x.Key)).SelectMany(x=>x.Value).Where(known.Contains).Distinct().ToArray();if(chosen.Length>0)Add("state",Group(chosen),"stations");}
if(Selected("drydock"))Add("services",new[]{new{name=Group(["Fleet Carrier Vendor"])}},"stations");
if(Selected("rescuemegaship"))plans.Add(new("stations",new Dictionary<string,object>{{"type",Group(["Mega ship"])},{"name",new{value="Rescue Ship"}}},false));
if(Selected("titan")||Selected("counterstrike"))
{var known=await ValuesAsync("systems","thargoid_war_state",null,token);var chosen=known.Where(s=>Selected(GalaxyMapFilter.Normalize(s))&&GalaxyMapFilter.Normalize(s) is "titan" or "counterstrike").ToArray();if(chosen.Length>0)Add("thargoid_war_state",Group(chosen));}
}
return plans;
}
public async Task<GalaxyQueryResult> QueryAsync(GalaxyMapFilter filter,Coordinates centre,double radius,CancellationToken token=default,Action<GalaxyQueryResult>? progress=null)
{
if(!centre.IsValid||!double.IsFinite(radius)||radius is <1 or >10000)throw new ArgumentException("星图查询范围无效。");
var plans=await PlansAsync(filter,token);var found=new Dictionary<string,GalacticSystem>(StringComparer.OrdinalIgnoreCase);var reported=0;var truncated=false;
foreach(var plan in plans)
{
var size=plan.Index=="stations"?5:25;var remaining=MaximumSystems-found.Count;
for(var page=0;page<Math.Min(60,(remaining+size-1)/size);page++)
{
token.ThrowIfCancellationRequested();
var filters=new Dictionary<string,object>(plan.Filters){{"distance",new{min=0,max=radius}}};
var payload=new{filters,sort=new[]{new{distance=new{direction="asc"}}},size,page,reference_coords=new{x=centre.X,y=centre.Y,z=centre.Z}};
using var json=await PlaceSearchHttp.ReadAsync(http,$"https://spansh.co.uk/api/{plan.Index}/search",payload,token);
var root=json.RootElement;var count=root.TryGetProperty("count",out var c)&&c.TryGetInt32(out var total)?total:0;reported+=page==0?count:0;
var batch=Parse(root,plan.Index,filter,plan.CertifiesMatch,centre,radius);foreach(var system in batch)
{if(found.TryGetValue(system.Name,out var old))found[system.Name]=old with{Data=old.Data.Merge(system.Data)};else found[system.Name]=system;}
token.ThrowIfCancellationRequested();progress?.Invoke(new(found.Values.Where(filter.Matches).OrderBy(s=>s.Position.DistanceTo(centre)).Take(MaximumSystems).ToArray(),reported,false,"Spansh 查询中"));
var raw=root.GetProperty("results").GetArrayLength();if(raw<size||(page+1)*size>=count)break;
if(found.Count>=MaximumSystems||page==59){truncated=true;break;}
}
if(found.Count>=MaximumSystems){truncated=true;break;}
}
token.ThrowIfCancellationRequested();return new(found.Values.Where(filter.Matches).OrderBy(s=>s.Position.DistanceTo(centre)).Take(MaximumSystems).ToArray(),reported,truncated,"Spansh 已上报星系/站点资料");
}
public async Task<GalaxyQueryResult> QueryViewportAsync(GalaxyMapFilter filter,GalaxyViewport viewport,CancellationToken token=default,Action<GalaxyQueryResult>? progress=null)
{
if(!viewport.Focus.IsValid)throw new ArgumentException("星图查询视野无效。");
var plans=await PlansAsync(filter,token);var bounds=GalaxyQueryBounds.FromViewport(viewport);
var found=new Dictionary<string,GalacticSystem>(StringComparer.OrdinalIgnoreCase);
var queue=new PriorityQueue<(GalaxyCatalogQueryPlan Plan,GalaxyQueryBounds Bounds,int Depth),double>();
foreach(var plan in plans)queue.Enqueue((plan,bounds,0),0);
var completed=0;var reported=0;
var unresolved=filter.Category==GalaxyFilterCategory.Markers&&(filter.Values is null||filter.Values.Any(v=>v is not("fleetcarrier" or "galacticregion" or "poweractivity" or "damaged" or "repairing" or "underattack" or "abandoned" or "drydock" or "rescuemegaship" or "titan" or "counterstrike")))
||filter.Category==GalaxyFilterCategory.Colonisation&&(filter.Values is null||filter.Values.Any(v=>v is "localplayerclaimed" or "unclaimed" or "excluded"))
||filter.Category==GalaxyFilterCategory.Civilization&&(filter.Values is null||filter.Values.Contains("none"))
||plans.Count==0&&(filter.Values is null||filter.Values.Count>0)
||filter.Category==GalaxyFilterCategory.FleetCarrier&&(filter.Values is null||filter.Values.Contains("absent"));
if(Field(filter.Category) is {} selectedField&&filter.Values is {} selectedValues)
{
var supported=(await ValuesAsync("systems",selectedField,null,token)).Select(GalaxyMapFilter.Normalize).ToHashSet(StringComparer.Ordinal);
if(filter.Category==GalaxyFilterCategory.Power)supported.Add("none");
unresolved|=selectedValues.Any(v=>!supported.Contains(v));
}
if(filter.Mode==GalaxyMapMode.Powerplay&&filter.Category==GalaxyFilterCategory.Power&&filter.PowerStateValues is {} selectedStates)
{var supported=(await ValuesAsync("systems","power_state",null,token)).Select(GalaxyMapFilter.Normalize).ToHashSet(StringComparer.Ordinal);unresolved|=selectedStates.Any(v=>!supported.Contains(v));}
if(filter.Category==GalaxyFilterCategory.Services&&filter.Values is {} selectedServices)
{
var reportedServices=await ValuesAsync("systems","stations","services",token);
var supported=reportedServices.Select(ServiceKey).Concat(new[]{"materialtraderraw","materialtradermanufactured","materialtraderencoded","techbrokerhuman","techbrokerguardian"}).ToHashSet(StringComparer.Ordinal);
if(supported.Contains("facilitator"))supported.Add("interstellarfactors");
unresolved|=selectedServices.Any(v=>!supported.Contains(v));
}
var timer=System.Diagnostics.Stopwatch.StartNew();var lastPublish=-1000L;
GalaxyQueryResult Snapshot(bool finished)=>new(found.Values.Where(filter.Matches).ToArray(),reported,!finished||unresolved,plans.Count>0?"Spansh 当前视锥目录查询":"此条件暂无可用公共查询,使用已知游戏记录")
{Bounds=bounds,CompletedQueries=completed,PendingQueries=queue.Count,CoverageComplete=finished&&!unresolved};
void Publish(bool force=false){if(progress is not null&&(force||timer.ElapsedMilliseconds-lastPublish>=120)){token.ThrowIfCancellationRequested();lastPublish=timer.ElapsedMilliseconds;progress(Snapshot(false));}}
while(queue.TryDequeue(out var work,out _))
{
token.ThrowIfCancellationRequested();var size=work.Plan.Index=="stations"?5:25;var split=false;
for(var page=0;;page++)
{
var filters=new Dictionary<string,object>(work.Plan.Filters);
// Numeric XYZ fields are part of the published searchable_fields schema.
// The complete perspective frustum, including its far plane, drives this bound.
if(work.Plan.Index=="systems")
{
filters["x"]=new{comparison="<=>",value=new[]{work.Bounds.Minimum.X,work.Bounds.Maximum.X}};
filters["y"]=new{comparison="<=>",value=new[]{work.Bounds.Minimum.Y,work.Bounds.Maximum.Y}};
filters["z"]=new{comparison="<=>",value=new[]{work.Bounds.Minimum.Z,work.Bounds.Maximum.Z}};
}
else filters["distance"]=new{min=0,max=work.Bounds.Radius};
var reference=work.Plan.Index=="stations"?work.Bounds.Centre:viewport.Focus;
var payload=new{filters,sort=new[]{new{distance=new{direction="asc"}}},size,page,reference_coords=new{x=reference.X,y=reference.Y,z=reference.Z}};
using var json=await PlaceSearchHttp.ReadAsync(http,$"https://spansh.co.uk/api/{work.Plan.Index}/search",payload,token);
var root=json.RootElement;var count=root.TryGetProperty("count",out var c)&&c.TryGetInt32(out var total)?total:0;reported=Math.Max(reported,count);
foreach(var system in Parse(root,work.Plan.Index,filter,work.Plan.CertifiesMatch,work.Bounds.Centre,work.Bounds.Radius+.01))
{
if(!work.Bounds.Contains(system.Position)||!viewport.Contains(system.Position,1.000001))continue;
if(found.TryGetValue(system.Name,out var old))found[system.Name]=old with{Data=old.Data.Merge(system.Data)};else found[system.Name]=system;
}
Publish();
// Search engines cap deep pagination. Refine before that cap instead
// of silently stopping at a fixed row count or a pivot-centred sphere.
if(page==0&&count>500&&work.Depth<40&&work.Bounds.Radius>.0000001)
{split=true;break;}
var raw=root.GetProperty("results").GetArrayLength();if(raw<size||(page+1)*size>=count)break;
if((page+1)*size>=9500){unresolved=true;break;}
}
completed++;
if(split)for(var child=0;child<8;child++)
{
var next=work.Bounds.Child(child);if(!Intersects(viewport,next))continue;
queue.Enqueue((work.Plan,next,work.Depth+1),next.Centre.DistanceTo(viewport.Focus));
}
Publish(true);
}
token.ThrowIfCancellationRequested();var result=Snapshot(true);progress?.Invoke(result);return result;
}
private static bool Intersects(GalaxyViewport viewport,GalaxyQueryBounds bounds)
{
if(viewport.Camera is not {} camera)return true;
var half=new XFEEDToolBox.Core.Navigation.Vector3d((bounds.Maximum.X-bounds.Minimum.X)/2,(bounds.Maximum.Y-bounds.Minimum.Y)/2,(bounds.Maximum.Z-bounds.Minimum.Z)/2);
var delta=new XFEEDToolBox.Core.Navigation.Vector3d(bounds.Centre.X-camera.Eye.X,bounds.Centre.Y-camera.Eye.Y,bounds.Centre.Z-camera.Eye.Z);
double Support(XFEEDToolBox.Core.Navigation.Vector3d n)=>Math.Abs(n.X)*half.X+Math.Abs(n.Y)*half.Y+Math.Abs(n.Z)*half.Z;
var depth=XFEEDToolBox.Core.Navigation.Vector3d.Dot(delta,camera.Forward);var reach=Support(camera.Forward);
if(depth+reach<camera.Near||depth-reach>camera.QueryFar)return false;
var tx=camera.Width/(2*camera.FocalLength);var ty=camera.Height/(2*camera.FocalLength);
foreach(var n in new[]{camera.Forward*tx+camera.Right,camera.Forward*tx-camera.Right,camera.Forward*ty+camera.Up,camera.Forward*ty-camera.Up})
if(XFEEDToolBox.Core.Navigation.Vector3d.Dot(delta,n)+Support(n)<0)return false;
return true;
}
public static IReadOnlyList<GalacticSystem> Parse(JsonElement root,string index,GalaxyMapFilter filter,bool certify,Coordinates centre,double radius)
{
if(!root.TryGetProperty("results",out var rows)||rows.ValueKind!=JsonValueKind.Array)throw new InvalidDataException("星图查询未返回有效结果。");
var systems=new List<GalacticSystem>();
foreach(var row in rows.EnumerateArray())
{
string? Text(string key)=>row.TryGetProperty(key,out var x)&&x.ValueKind==JsonValueKind.String?x.GetString():null;
double? Number(string key)=>row.TryGetProperty(key,out var x)&&x.ValueKind==JsonValueKind.Number&&x.TryGetDouble(out var value)&&double.IsFinite(value)?value:null;
bool? Boolean(string key)=>row.TryGetProperty(key,out var x)&&x.ValueKind is JsonValueKind.True or JsonValueKind.False?x.GetBoolean():null;
var station=index=="stations";var prefix=station?"system_":"";
if(Number(prefix+"x") is not {} x||Number(prefix+"y") is not {} y||Number(prefix+"z") is not {} z)continue;
var position=new Coordinates(x,y,z);if(position.DistanceTo(centre)>radius+.01)continue;
var name=Text(station?"system_name":"name");if(string.IsNullOrWhiteSpace(name))continue;
long? address=null;if(row.TryGetProperty(station?"system_id64":"id64",out var id)&&long.TryParse(id.ToString(),out var value)&&value>0)address=value;
var services=new HashSet<string>();var civilizations=new HashSet<string>();var activities=new List<string>();var markers=new HashSet<string>();var stationStates=new Dictionary<string,GalaxyStationState>(StringComparer.OrdinalIgnoreCase);bool? carrier=null;var star="";
void Station(JsonElement s)
{
if(s.TryGetProperty("services",out var list)&&list.ValueKind==JsonValueKind.Array)foreach(var v in list.EnumerateArray())
{var service=v.ValueKind==JsonValueKind.String?v.GetString():v.ValueKind==JsonValueKind.Object&&v.TryGetProperty("name",out var n)?n.GetString():null;if(service is not null)services.Add(ServiceKey(service));}
if(s.TryGetProperty("type",out var type)&&type.GetString()=="Drake-Class Carrier")carrier=true;
if(s.TryGetProperty("state",out var state)&&state.ValueKind==JsonValueKind.String&&!string.IsNullOrWhiteSpace(state.GetString()))
{
if(s.TryGetProperty("name",out var actualName)&&actualName.ValueKind==JsonValueKind.String&&!string.IsNullOrWhiteSpace(actualName.GetString()))stationStates[actualName.GetString()!]=new(state.GetString()!,s.TryGetProperty("updated_at",out var updated)&&updated.ValueKind==JsonValueKind.String&&DateTimeOffset.TryParse(updated.GetString(),out var at)?at:null);
else if(GalaxySystemData.StationMarker(state.GetString()!) is {} marker)markers.Add(marker);
}
if(s.TryGetProperty("name",out var stationName)&&stationName.ValueKind==JsonValueKind.String&&type.ValueKind==JsonValueKind.String&&type.GetString()=="Mega ship"&&stationName.GetString()!.StartsWith("Rescue Ship",StringComparison.OrdinalIgnoreCase))markers.Add("rescuemegaship");
if(s.TryGetProperty("material_trader",out var mt)&&mt.ValueKind==JsonValueKind.String)services.Add("materialtrader"+GalaxyMapFilter.Normalize(mt.GetString()!));
if(s.TryGetProperty("technology_broker",out var tb)&&tb.ValueKind==JsonValueKind.String)services.Add("techbroker"+GalaxyMapFilter.Normalize(tb.GetString()!));
}
if(station)Station(row);
else if(row.TryGetProperty("stations",out var stations)&&stations.ValueKind==JsonValueKind.Array)foreach(var s in stations.EnumerateArray())Station(s);
if(row.TryGetProperty("bodies",out var bodies)&&bodies.ValueKind==JsonValueKind.Array)foreach(var body in bodies.EnumerateArray())
{
if(body.TryGetProperty("is_main_star",out var main)&&main.ValueKind==JsonValueKind.True&&body.TryGetProperty("subtype",out var subtype))star=subtype.GetString()??"";
if(body.TryGetProperty("landmarks",out var landmarks)&&landmarks.ValueKind==JsonValueKind.Array)foreach(var landmark in landmarks.EnumerateArray())
if(landmark.TryGetProperty("type",out var t)&&t.GetString() is {} type){if(type.StartsWith("Guardian",StringComparison.Ordinal))civilizations.Add("guardian");if(type.StartsWith("Thargoid",StringComparison.Ordinal))civilizations.Add("thargoid");}
}
var population=Number(prefix+"population");if(population>0)civilizations.Add("human");
if(Number("power_state_reinforcement")>0)activities.Add("reinforcement");if(Number("power_state_undermining")>0)activities.Add("undermining");
if(carrier==true)markers.Add("fleetcarrier");if(Text("region") is not null)markers.Add("galacticregion");if(activities.Count>0)markers.Add("poweractivity");if(services.Contains("carriervendor"))markers.Add("drydock");
if(Text("thargoid_war_state") is {} war&&GalaxyMapFilter.Normalize(war) is "titan" or "counterstrike")markers.Add(GalaxyMapFilter.Normalize(war));
string[]? MarketValues(string metric)=>station&&row.TryGetProperty("market",out var market)&&market.ValueKind==JsonValueKind.Array?market.EnumerateArray().Where(m=>m.TryGetProperty(metric,out var quantity)&&quantity.TryGetInt64(out var n)&&n>0&&m.TryGetProperty("commodity",out var commodity)&&commodity.ValueKind==JsonValueKind.String&&!string.IsNullOrWhiteSpace(commodity.GetString())).Select(m=>m.GetProperty("commodity").GetString()!).Order(StringComparer.OrdinalIgnoreCase).ToArray():null;
var commodities=MarketValues("supply");var demands=MarketValues("demand");
IReadOnlyDictionary<string,GalaxyStationMarket>? stationMarkets=commodities is not null&&demands is not null&&Text("name") is {Length:>0} marketName?
new Dictionary<string,GalaxyStationMarket>(StringComparer.OrdinalIgnoreCase){{marketName,new(commodities,demands,DateTimeOffset.TryParse(Text("market_updated_at"),out var marketAt)?marketAt:null)}}:null;
var data=new GalaxySystemData{Allegiance=Text("allegiance"),Government=Text("government"),Economy=Text(prefix+"primary_economy"),SecondaryEconomy=Text(prefix+"secondary_economy"),Security=Text("security"),State=Text("controlling_minor_faction_state"),Population=population is >=0 and <=long.MaxValue?(long)population.Value:null,
Services=services.Count>0?services.ToArray():null,FleetCarrier=carrier,Power=Text(prefix+"controlling_power")??(Text(prefix+"power_state")=="Unoccupied"?"None":null),PowerState=Text(prefix+"power_state"),Civilizations=civilizations.ToArray(),PowerActivities=activities,
Colonisation=Boolean(prefix+"is_colonised")==true?"colonised":Boolean(prefix+"is_being_colonised")==true?"beingcolonised":population>0?"alreadypopulated":null,
ThargoidWar=Text("thargoid_war_state"),Commodities=commodities,CommodityDemands=demands,StationMarkets=stationMarkets,Markers=markers.Count>0?markers.ToArray():null,StationStates=stationStates.Count>0?stationStates:null,Region=Text("region"),ConfirmedQueries=certify?new HashSet<string>{filter.QueryKey}:new HashSet<string>()};
systems.Add(new(name,position,address,star,"Spansh 查询目录"){Data=data});
}
return systems;
}
public static string ServiceKey(string value)=>GalaxyMapFilter.Normalize(value) switch
{"market"=>"commodities","restock"=>"rearm","universalcartographics"=>"exploration","interstellarfactorscontact"=>"facilitator","technologybroker"=>"techbroker","redemptionoffice"=>"voucherredemption","refinerycontact"=>"refinery","systemcolonisation"=>"registeringcolonisation","constructionservices"=>"colonisationcontribution","fleetcarrierfuel"=>"carrierfuel","fleetcarriermanagement"=>"carriermanagement","fleetcarrieradministration"=>"modulepacks","fleetcarriervendor"=>"carriervendor",var key=>key};
}
using System.Text.Json;
using XFEEDToolBox.Core.Models;
namespace XFEEDToolBox.Core.Galaxy;
public sealed record GalaxyQueryResult(IReadOnlyList<GalacticSystem> Systems,int ReportedCount,bool Truncated,string Source)
{
public GalaxyQueryBounds? Bounds { get; init; }
public int PendingQueries { get; init; }
public int CompletedQueries { get; init; }
public bool CoverageComplete { get; init; }
}
public sealed record GalaxyCatalogQueryPlan(string Index,IReadOnlyDictionary<string,object> Filters,bool CertifiesMatch=true);
/// <summary>Viewport-scoped queries using the catalogue's published searchable_fields/field_values schema.</summary>
public sealed class GalaxyQueryService(HttpClient http)
{
public const int MaximumSystems=1500;
private readonly System.Collections.Concurrent.ConcurrentDictionary<string,IReadOnlyList<string>> fields=new(StringComparer.Ordinal);
public static bool UsesPublicCatalog(GalaxyFilterCategory category)=>category is GalaxyFilterCategory.Allegiance or GalaxyFilterCategory.Government or GalaxyFilterCategory.Economy or GalaxyFilterCategory.Security or GalaxyFilterCategory.Population or GalaxyFilterCategory.State or GalaxyFilterCategory.Services or GalaxyFilterCategory.Civilization or GalaxyFilterCategory.Commodity or GalaxyFilterCategory.FleetCarrier or GalaxyFilterCategory.Power or GalaxyFilterCategory.PowerState or GalaxyFilterCategory.PowerActivity or GalaxyFilterCategory.Colonisation or GalaxyFilterCategory.ThargoidWar or GalaxyFilterCategory.Markers;
private static string? Field(GalaxyFilterCategory category)=>category switch
{GalaxyFilterCategory.Allegiance=>"allegiance",GalaxyFilterCategory.Government=>"government",GalaxyFilterCategory.Economy=>"primary_economy",GalaxyFilterCategory.Security=>"security",GalaxyFilterCategory.State=>"controlling_minor_faction_state",GalaxyFilterCategory.Power=>"controlling_power",GalaxyFilterCategory.PowerState=>"power_state",GalaxyFilterCategory.ThargoidWar=>"thargoid_war_state",_=>null};
public async Task<IReadOnlyList<GalaxyFilterChoice>> ChoicesAsync(GalaxyFilterCategory category,CancellationToken token=default)
{
var field=Field(category);var index="systems";
if(category==GalaxyFilterCategory.Commodity){index="stations";field="marketplace";}
if(category==GalaxyFilterCategory.Services){index="stations";field="services";}
if(field is null)return [];
var values=await ValuesAsync(index,field,category==GalaxyFilterCategory.Commodity?"commodity":category==GalaxyFilterCategory.Services?"name":null,token);
return values.Select(v=>new GalaxyFilterChoice(category==GalaxyFilterCategory.Services?ServiceKey(v):GalaxyMapFilter.Normalize(v),v)).OrderBy(x=>x.Label).ToArray();
}
private async Task<IReadOnlyList<string>> ValuesAsync(string index,string field,string? subfield,CancellationToken token)
{
var key=index+"/"+field+"/"+subfield;if(fields.TryGetValue(key,out var cached))return cached;
using var json=await PlaceSearchHttp.ReadAsync(http,$"https://spansh.co.uk/api/{index}/field_values/{field}",null,token);
var values=json.RootElement.GetProperty("values");if(subfield is not null)values=values.GetProperty(subfield);
if(values.ValueKind!=JsonValueKind.Array)throw new InvalidDataException("星图查询选项格式无效。");
var result=values.EnumerateArray().Where(v=>v.ValueKind==JsonValueKind.String).Select(v=>v.GetString()!).Take(2000).ToArray();fields[key]=result;return result;
}
public async Task<IReadOnlyList<GalaxyCatalogQueryPlan>> PlansAsync(GalaxyMapFilter filter,CancellationToken token=default)
{
if(filter.UsesPowerOuter||filter.Category==GalaxyFilterCategory.Commodity&&filter.RequiresOriginalCommodity||!UsesPublicCatalog(filter.Category))return [];
if(filter.Values is {Count:0})return [];
object Group(IEnumerable<string> values)=>new{value=values.ToArray()};
var plans=new List<GalaxyCatalogQueryPlan>();
void Add(string field,object value,string index="systems",bool certify=true)=>plans.Add(new(index,new Dictionary<string,object>{{field,value}},certify));
// Original masks also include unoccupied power flags and intermediate
// stages, which a public controlling_power/state filter cannot express.
if(filter.UsesPowerTerritory&&(filter.TerritoryPowerMask is not null||filter.TerritoryStateMask is not null))
{plans.Add(new("systems",new Dictionary<string,object>(),false));return plans;}
if(Field(filter.Category) is {} groupField)
{
string[]? powerStates=null;
if(filter.Category==GalaxyFilterCategory.Power&&filter.Mode==GalaxyMapMode.Powerplay&&filter.PowerStateValues is {} stateSelection)
{powerStates=(await ValuesAsync("systems","power_state",null,token)).Where(v=>stateSelection.Contains(GalaxyMapFilter.Normalize(v))).ToArray();if(powerStates.Length==0)return plans;}
void ApplyPowerStates()
{if(powerStates is null)return;for(var i=0;i<plans.Count;i++)if(!plans[i].Filters.ContainsKey("power_state"))plans[i]=plans[i] with{Filters=new Dictionary<string,object>(plans[i].Filters){{"power_state",Group(powerStates)}}};}
if(filter.Values is null){plans.Add(new("systems",new Dictionary<string,object>(),false));ApplyPowerStates();return plans;}
if(filter.Category==GalaxyFilterCategory.Power&&filter.Values.Contains("none")&&(powerStates is null||powerStates.Contains("Unoccupied")))Add("power_state",Group(["Unoccupied"]));
var choices=await ValuesAsync("systems",groupField,null,token);
var chosen=choices.Where(v=>filter.Values.Contains(GalaxyMapFilter.Normalize(v))).ToArray();
if(chosen.Length>0){Add(groupField,Group(chosen));if(filter.Category==GalaxyFilterCategory.Economy)Add("secondary_economy",Group(chosen));}
ApplyPowerStates();return plans;
}
if(filter.Category==GalaxyFilterCategory.Population)
{
var minimum=filter.PopulationMinimum>=long.MaxValue?long.MaxValue:(long)Math.Max(0,Math.Ceiling(filter.PopulationMinimum));
if(filter.Values is null){Add("population",new{comparison="<=>",value=new[]{minimum,long.MaxValue}});return plans;}
var bins=new Dictionary<string,(long Min,long Max)>{{"uninhabited",(0,0)},{"1-999",(1,999)},{"1k-1m",(1000,999999)},{"1m-1b",(1000000,999999999)},{"1b+",(1000000000,long.MaxValue)}};
foreach(var entry in bins.Where(v=>filter.Values.Contains(v.Key)&&v.Value.Max>=minimum))Add("population",new{comparison="<=>",value=new[]{Math.Max(minimum,entry.Value.Min),entry.Value.Max}});
}
else if(filter.Category==GalaxyFilterCategory.Services)
{
if(filter.Values is null){plans.Add(new("systems",new Dictionary<string,object>(),false));return plans;}
var source=await ValuesAsync("systems","stations","services",token);var selected=source.Where(s=>filter.Values.Contains(ServiceKey(s))||ServiceKey(s)=="facilitator"&&filter.Values.Contains("interstellarfactors")).ToArray();
if(selected.Length>0)Add("stations",new[]{new{services=Group(selected)}});
foreach(var entry in new[]{("material_trader","materialtrader",new[]{"Raw","Manufactured","Encoded"}),("technology_broker","techbroker",new[]{"Human","Guardian"})})
{
var values=entry.Item3.Where(v=>filter.Values.Contains(entry.Item2+GalaxyMapFilter.Normalize(v))).ToArray();
if(values.Length>0)Add(entry.Item1,Group(values),"stations");
}
}
else if(filter.Category==GalaxyFilterCategory.Commodity)
{
var options=await ValuesAsync("stations","marketplace","commodity",token);var chosen=options.Where(v=>filter.Values is null||filter.Values.Contains(GalaxyMapFilter.Normalize(v))).ToArray();
if(chosen.Length>0)foreach(var field in filter.CommodityDirection==GalaxyCommodityDirection.Both?new[]{"supply","demand"}:new[]{filter.CommodityDirection==GalaxyCommodityDirection.Exports?"supply":"demand"})Add("marketplace",new[]{new Dictionary<string,object>{{"commodity",Group(chosen)},{field,new{comparison="<=>",value=new long[]{1,long.MaxValue}}}}},"stations");
}
else if(filter.Category==GalaxyFilterCategory.FleetCarrier)
{
if(filter.Values?.Contains("present")==true)Add("stations",new[]{new{type=Group(["Drake-Class Carrier"])}});
if(filter.Values is null||filter.Values.Contains("absent"))plans.Add(new("systems",new Dictionary<string,object>(),false));
}
else if(filter.Category==GalaxyFilterCategory.Civilization)
{
var types=filter.Values is null||filter.Values.Contains("guardian")||filter.Values.Contains("thargoid")?await ValuesAsync("systems","landmarks","type",token):[];
var chosen=types.Where(t=>(filter.Values is null||filter.Values.Contains("guardian"))&&t.StartsWith("Guardian",StringComparison.Ordinal)||(filter.Values is null||filter.Values.Contains("thargoid"))&&t.StartsWith("Thargoid",StringComparison.Ordinal)).ToArray();
if(chosen.Length>0)Add("landmarks",new[]{new{type=Group(chosen)}});
if(filter.Values is null||filter.Values.Contains("human"))Add("population",new{comparison="<=>",value=new long[]{1,long.MaxValue}});
}
else if(filter.Category==GalaxyFilterCategory.PowerActivity)
{
if(filter.Values is null||filter.Values.Contains("reinforcement"))Add("power_state_reinforcement",new{comparison="<=>",value=new long[]{1,long.MaxValue}});
if(filter.Values is null||filter.Values.Contains("undermining"))Add("power_state_undermining",new{comparison="<=>",value=new long[]{1,long.MaxValue}});
}
else if(filter.Category==GalaxyFilterCategory.Colonisation)
{
if(filter.Values is null||filter.Values.Contains("alreadypopulated"))Add("population",new{comparison="<=>",value=new long[]{1,long.MaxValue}},certify:false);
if(filter.Values is null||filter.Values.Contains("colonised"))Add("is_colonised",new{value=true},certify:false);
if(filter.Values is null||filter.Values.Contains("beingcolonised"))Add("is_being_colonised",new{value=true},certify:false);
// Claim eligibility and ownership are player-specific native facts, never inferred from population.
}
else if(filter.Category==GalaxyFilterCategory.Markers)
{
bool Selected(string key)=>filter.Values is null||filter.Values.Contains(key);
if(Selected("fleetcarrier"))Add("stations",new[]{new{type=Group(["Drake-Class Carrier"])}});
if(Selected("galacticregion"))plans.Add(new("systems",new Dictionary<string,object>(),false));
if(Selected("poweractivity")){Add("power_state_reinforcement",new{comparison="<=>",value=new long[]{1,long.MaxValue}});Add("power_state_undermining",new{comparison="<=>",value=new long[]{1,long.MaxValue}});}
var states=new Dictionary<string,string[]>{{"damaged",["Damaged","DamagedHuman"]},{"repairing",["UnderRepairs"]},{"underattack",["UnderAttack"]},{"abandoned",["Abandoned"]}};
if(states.Keys.Any(Selected))
{var known=await ValuesAsync("stations","state",null,token);var chosen=states.Where(x=>Selected(x.Key)).SelectMany(x=>x.Value).Where(known.Contains).Distinct().ToArray();if(chosen.Length>0)Add("state",Group(chosen),"stations");}
if(Selected("drydock"))Add("services",new[]{new{name=Group(["Fleet Carrier Vendor"])}},"stations");
if(Selected("rescuemegaship"))plans.Add(new("stations",new Dictionary<string,object>{{"type",Group(["Mega ship"])},{"name",new{value="Rescue Ship"}}},false));
if(Selected("titan")||Selected("counterstrike"))
{var known=await ValuesAsync("systems","thargoid_war_state",null,token);var chosen=known.Where(s=>Selected(GalaxyMapFilter.Normalize(s))&&GalaxyMapFilter.Normalize(s) is "titan" or "counterstrike").ToArray();if(chosen.Length>0)Add("thargoid_war_state",Group(chosen));}
}
return plans;
}
public async Task<GalaxyQueryResult> QueryAsync(GalaxyMapFilter filter,Coordinates centre,double radius,CancellationToken token=default,Action<GalaxyQueryResult>? progress=null)
{
if(!centre.IsValid||!double.IsFinite(radius)||radius is <1 or >10000)throw new ArgumentException("星图查询范围无效。");
var plans=await PlansAsync(filter,token);var found=new Dictionary<string,GalacticSystem>(StringComparer.OrdinalIgnoreCase);var reported=0;var truncated=false;
foreach(var plan in plans)
{
var size=plan.Index=="stations"?5:25;var remaining=MaximumSystems-found.Count;
for(var page=0;page<Math.Min(60,(remaining+size-1)/size);page++)
{
token.ThrowIfCancellationRequested();
var filters=new Dictionary<string,object>(plan.Filters){{"distance",new{min=0,max=radius}}};
var payload=new{filters,sort=new[]{new{distance=new{direction="asc"}}},size,page,reference_coords=new{x=centre.X,y=centre.Y,z=centre.Z}};
using var json=await PlaceSearchHttp.ReadAsync(http,$"https://spansh.co.uk/api/{plan.Index}/search",payload,token);
var root=json.RootElement;var count=root.TryGetProperty("count",out var c)&&c.TryGetInt32(out var total)?total:0;reported+=page==0?count:0;
var batch=Parse(root,plan.Index,filter,plan.CertifiesMatch,centre,radius);foreach(var system in batch)
{if(found.TryGetValue(system.Name,out var old))found[system.Name]=old with{Data=old.Data.Merge(system.Data)};else found[system.Name]=system;}
token.ThrowIfCancellationRequested();progress?.Invoke(new(found.Values.Where(filter.Matches).OrderBy(s=>s.Position.DistanceTo(centre)).Take(MaximumSystems).ToArray(),reported,false,"Spansh 查询中"));
var raw=root.GetProperty("results").GetArrayLength();if(raw<size||(page+1)*size>=count)break;
if(found.Count>=MaximumSystems||page==59){truncated=true;break;}
}
if(found.Count>=MaximumSystems){truncated=true;break;}
}
token.ThrowIfCancellationRequested();return new(found.Values.Where(filter.Matches).OrderBy(s=>s.Position.DistanceTo(centre)).Take(MaximumSystems).ToArray(),reported,truncated,"Spansh 已上报星系/站点资料");
}
public async Task<GalaxyQueryResult> QueryViewportAsync(GalaxyMapFilter filter,GalaxyViewport viewport,CancellationToken token=default,Action<GalaxyQueryResult>? progress=null)
{
if(!viewport.Focus.IsValid)throw new ArgumentException("星图查询视野无效。");
var plans=await PlansAsync(filter,token);var bounds=GalaxyQueryBounds.FromViewport(viewport);
var found=new Dictionary<string,GalacticSystem>(StringComparer.OrdinalIgnoreCase);
var queue=new PriorityQueue<(GalaxyCatalogQueryPlan Plan,GalaxyQueryBounds Bounds,int Depth),double>();
foreach(var plan in plans)queue.Enqueue((plan,bounds,0),0);
var completed=0;var reported=0;
var unresolved=filter.Category==GalaxyFilterCategory.Markers&&(filter.Values is null||filter.Values.Any(v=>v is not("fleetcarrier" or "galacticregion" or "poweractivity" or "damaged" or "repairing" or "underattack" or "abandoned" or "drydock" or "rescuemegaship" or "titan" or "counterstrike")))
||filter.Category==GalaxyFilterCategory.Colonisation&&(filter.Values is null||filter.Values.Any(v=>v is "localplayerclaimed" or "unclaimed" or "excluded"))
||filter.Category==GalaxyFilterCategory.Civilization&&(filter.Values is null||filter.Values.Contains("none"))
||plans.Count==0&&(filter.Values is null||filter.Values.Count>0)
||filter.Category==GalaxyFilterCategory.FleetCarrier&&(filter.Values is null||filter.Values.Contains("absent"));
if(Field(filter.Category) is {} selectedField&&filter.Values is {} selectedValues)
{
var supported=(await ValuesAsync("systems",selectedField,null,token)).Select(GalaxyMapFilter.Normalize).ToHashSet(StringComparer.Ordinal);
if(filter.Category==GalaxyFilterCategory.Power)supported.Add("none");
unresolved|=selectedValues.Any(v=>!supported.Contains(v));
}
if(filter.Mode==GalaxyMapMode.Powerplay&&filter.Category==GalaxyFilterCategory.Power&&filter.PowerStateValues is {} selectedStates)
{var supported=(await ValuesAsync("systems","power_state",null,token)).Select(GalaxyMapFilter.Normalize).ToHashSet(StringComparer.Ordinal);unresolved|=selectedStates.Any(v=>!supported.Contains(v));}
if(filter.Category==GalaxyFilterCategory.Services&&filter.Values is {} selectedServices)
{
var reportedServices=await ValuesAsync("systems","stations","services",token);
var supported=reportedServices.Select(ServiceKey).Concat(new[]{"materialtraderraw","materialtradermanufactured","materialtraderencoded","techbrokerhuman","techbrokerguardian"}).ToHashSet(StringComparer.Ordinal);
if(supported.Contains("facilitator"))supported.Add("interstellarfactors");
unresolved|=selectedServices.Any(v=>!supported.Contains(v));
}
var timer=System.Diagnostics.Stopwatch.StartNew();var lastPublish=-1000L;
GalaxyQueryResult Snapshot(bool finished)=>new(found.Values.Where(filter.Matches).ToArray(),reported,!finished||unresolved,plans.Count>0?"Spansh 当前视锥目录查询":"此条件暂无可用公共查询,使用已知游戏记录")
{Bounds=bounds,CompletedQueries=completed,PendingQueries=queue.Count,CoverageComplete=finished&&!unresolved};
void Publish(bool force=false){if(progress is not null&&(force||timer.ElapsedMilliseconds-lastPublish>=120)){token.ThrowIfCancellationRequested();lastPublish=timer.ElapsedMilliseconds;progress(Snapshot(false));}}
while(queue.TryDequeue(out var work,out _))
{
token.ThrowIfCancellationRequested();var size=work.Plan.Index=="stations"?5:25;var split=false;
for(var page=0;;page++)
{
var filters=new Dictionary<string,object>(work.Plan.Filters);
// Numeric XYZ fields are part of the published searchable_fields schema.
// The complete perspective frustum, including its far plane, drives this bound.
if(work.Plan.Index=="systems")
{
filters["x"]=new{comparison="<=>",value=new[]{work.Bounds.Minimum.X,work.Bounds.Maximum.X}};
filters["y"]=new{comparison="<=>",value=new[]{work.Bounds.Minimum.Y,work.Bounds.Maximum.Y}};
filters["z"]=new{comparison="<=>",value=new[]{work.Bounds.Minimum.Z,work.Bounds.Maximum.Z}};
}
else filters["distance"]=new{min=0,max=work.Bounds.Radius};
var reference=work.Plan.Index=="stations"?work.Bounds.Centre:viewport.Focus;
var payload=new{filters,sort=new[]{new{distance=new{direction="asc"}}},size,page,reference_coords=new{x=reference.X,y=reference.Y,z=reference.Z}};
using var json=await PlaceSearchHttp.ReadAsync(http,$"https://spansh.co.uk/api/{work.Plan.Index}/search",payload,token);
var root=json.RootElement;var count=root.TryGetProperty("count",out var c)&&c.TryGetInt32(out var total)?total:0;reported=Math.Max(reported,count);
foreach(var system in Parse(root,work.Plan.Index,filter,work.Plan.CertifiesMatch,work.Bounds.Centre,work.Bounds.Radius+.01))
{
if(!work.Bounds.Contains(system.Position)||!viewport.Contains(system.Position,1.000001))continue;
if(found.TryGetValue(system.Name,out var old))found[system.Name]=old with{Data=old.Data.Merge(system.Data)};else found[system.Name]=system;
}
Publish();
// Search engines cap deep pagination. Refine before that cap instead
// of silently stopping at a fixed row count or a pivot-centred sphere.
if(page==0&&count>500&&work.Depth<40&&work.Bounds.Radius>.0000001)
{split=true;break;}
var raw=root.GetProperty("results").GetArrayLength();if(raw<size||(page+1)*size>=count)break;
if((page+1)*size>=9500){unresolved=true;break;}
}
completed++;
if(split)for(var child=0;child<8;child++)
{
var next=work.Bounds.Child(child);if(!Intersects(viewport,next))continue;
queue.Enqueue((work.Plan,next,work.Depth+1),next.Centre.DistanceTo(viewport.Focus));
}
Publish(true);
}
token.ThrowIfCancellationRequested();var result=Snapshot(true);progress?.Invoke(result);return result;
}
private static bool Intersects(GalaxyViewport viewport,GalaxyQueryBounds bounds)
{
if(viewport.Camera is not {} camera)return true;
var half=new XFEEDToolBox.Core.Navigation.Vector3d((bounds.Maximum.X-bounds.Minimum.X)/2,(bounds.Maximum.Y-bounds.Minimum.Y)/2,(bounds.Maximum.Z-bounds.Minimum.Z)/2);
var delta=new XFEEDToolBox.Core.Navigation.Vector3d(bounds.Centre.X-camera.Eye.X,bounds.Centre.Y-camera.Eye.Y,bounds.Centre.Z-camera.Eye.Z);
double Support(XFEEDToolBox.Core.Navigation.Vector3d n)=>Math.Abs(n.X)*half.X+Math.Abs(n.Y)*half.Y+Math.Abs(n.Z)*half.Z;
var depth=XFEEDToolBox.Core.Navigation.Vector3d.Dot(delta,camera.Forward);var reach=Support(camera.Forward);
if(depth+reach<camera.Near||depth-reach>camera.QueryFar)return false;
var tx=camera.Width/(2*camera.FocalLength);var ty=camera.Height/(2*camera.FocalLength);
foreach(var n in new[]{camera.Forward*tx+camera.Right,camera.Forward*tx-camera.Right,camera.Forward*ty+camera.Up,camera.Forward*ty-camera.Up})
if(XFEEDToolBox.Core.Navigation.Vector3d.Dot(delta,n)+Support(n)<0)return false;
return true;
}
public static IReadOnlyList<GalacticSystem> Parse(JsonElement root,string index,GalaxyMapFilter filter,bool certify,Coordinates centre,double radius)
{
if(!root.TryGetProperty("results",out var rows)||rows.ValueKind!=JsonValueKind.Array)throw new InvalidDataException("星图查询未返回有效结果。");
var systems=new List<GalacticSystem>();
foreach(var row in rows.EnumerateArray())
{
string? Text(string key)=>row.TryGetProperty(key,out var x)&&x.ValueKind==JsonValueKind.String?x.GetString():null;
double? Number(string key)=>row.TryGetProperty(key,out var x)&&x.ValueKind==JsonValueKind.Number&&x.TryGetDouble(out var value)&&double.IsFinite(value)?value:null;
bool? Boolean(string key)=>row.TryGetProperty(key,out var x)&&x.ValueKind is JsonValueKind.True or JsonValueKind.False?x.GetBoolean():null;
var station=index=="stations";var prefix=station?"system_":"";
if(Number(prefix+"x") is not {} x||Number(prefix+"y") is not {} y||Number(prefix+"z") is not {} z)continue;
var position=new Coordinates(x,y,z);if(position.DistanceTo(centre)>radius+.01)continue;
var name=Text(station?"system_name":"name");if(string.IsNullOrWhiteSpace(name))continue;
long? address=null;if(row.TryGetProperty(station?"system_id64":"id64",out var id)&&long.TryParse(id.ToString(),out var value)&&value>0)address=value;
var services=new HashSet<string>();var civilizations=new HashSet<string>();var activities=new List<string>();var markers=new HashSet<string>();var stationStates=new Dictionary<string,GalaxyStationState>(StringComparer.OrdinalIgnoreCase);bool? carrier=null;var star="";
void Station(JsonElement s)
{
if(s.TryGetProperty("services",out var list)&&list.ValueKind==JsonValueKind.Array)foreach(var v in list.EnumerateArray())
{var service=v.ValueKind==JsonValueKind.String?v.GetString():v.ValueKind==JsonValueKind.Object&&v.TryGetProperty("name",out var n)?n.GetString():null;if(service is not null)services.Add(ServiceKey(service));}
if(s.TryGetProperty("type",out var type)&&type.GetString()=="Drake-Class Carrier")carrier=true;
if(s.TryGetProperty("state",out var state)&&state.ValueKind==JsonValueKind.String&&!string.IsNullOrWhiteSpace(state.GetString()))
{
if(s.TryGetProperty("name",out var actualName)&&actualName.ValueKind==JsonValueKind.String&&!string.IsNullOrWhiteSpace(actualName.GetString()))stationStates[actualName.GetString()!]=new(state.GetString()!,s.TryGetProperty("updated_at",out var updated)&&updated.ValueKind==JsonValueKind.String&&DateTimeOffset.TryParse(updated.GetString(),out var at)?at:null);
else if(GalaxySystemData.StationMarker(state.GetString()!) is {} marker)markers.Add(marker);
}
if(s.TryGetProperty("name",out var stationName)&&stationName.ValueKind==JsonValueKind.String&&type.ValueKind==JsonValueKind.String&&type.GetString()=="Mega ship"&&stationName.GetString()!.StartsWith("Rescue Ship",StringComparison.OrdinalIgnoreCase))markers.Add("rescuemegaship");
if(s.TryGetProperty("material_trader",out var mt)&&mt.ValueKind==JsonValueKind.String)services.Add("materialtrader"+GalaxyMapFilter.Normalize(mt.GetString()!));
if(s.TryGetProperty("technology_broker",out var tb)&&tb.ValueKind==JsonValueKind.String)services.Add("techbroker"+GalaxyMapFilter.Normalize(tb.GetString()!));
}
if(station)Station(row);
else if(row.TryGetProperty("stations",out var stations)&&stations.ValueKind==JsonValueKind.Array)foreach(var s in stations.EnumerateArray())Station(s);
if(row.TryGetProperty("bodies",out var bodies)&&bodies.ValueKind==JsonValueKind.Array)foreach(var body in bodies.EnumerateArray())
{
if(body.TryGetProperty("is_main_star",out var main)&&main.ValueKind==JsonValueKind.True&&body.TryGetProperty("subtype",out var subtype))star=subtype.GetString()??"";
if(body.TryGetProperty("landmarks",out var landmarks)&&landmarks.ValueKind==JsonValueKind.Array)foreach(var landmark in landmarks.EnumerateArray())
if(landmark.TryGetProperty("type",out var t)&&t.GetString() is {} type){if(type.StartsWith("Guardian",StringComparison.Ordinal))civilizations.Add("guardian");if(type.StartsWith("Thargoid",StringComparison.Ordinal))civilizations.Add("thargoid");}
}
var population=Number(prefix+"population");if(population>0)civilizations.Add("human");
if(Number("power_state_reinforcement")>0)activities.Add("reinforcement");if(Number("power_state_undermining")>0)activities.Add("undermining");
if(carrier==true)markers.Add("fleetcarrier");if(Text("region") is not null)markers.Add("galacticregion");if(activities.Count>0)markers.Add("poweractivity");if(services.Contains("carriervendor"))markers.Add("drydock");
if(Text("thargoid_war_state") is {} war&&GalaxyMapFilter.Normalize(war) is "titan" or "counterstrike")markers.Add(GalaxyMapFilter.Normalize(war));
string[]? MarketValues(string metric)=>station&&row.TryGetProperty("market",out var market)&&market.ValueKind==JsonValueKind.Array?market.EnumerateArray().Where(m=>m.TryGetProperty(metric,out var quantity)&&quantity.TryGetInt64(out var n)&&n>0&&m.TryGetProperty("commodity",out var commodity)&&commodity.ValueKind==JsonValueKind.String&&!string.IsNullOrWhiteSpace(commodity.GetString())).Select(m=>m.GetProperty("commodity").GetString()!).Order(StringComparer.OrdinalIgnoreCase).ToArray():null;
var commodities=MarketValues("supply");var demands=MarketValues("demand");
IReadOnlyDictionary<string,GalaxyStationMarket>? stationMarkets=commodities is not null&&demands is not null&&Text("name") is {Length:>0} marketName?
new Dictionary<string,GalaxyStationMarket>(StringComparer.OrdinalIgnoreCase){{marketName,new(commodities,demands,DateTimeOffset.TryParse(Text("market_updated_at"),out var marketAt)?marketAt:null)}}:null;
var data=new GalaxySystemData{Allegiance=Text("allegiance"),Government=Text("government"),Economy=Text(prefix+"primary_economy"),SecondaryEconomy=Text(prefix+"secondary_economy"),Security=Text("security"),State=Text("controlling_minor_faction_state"),Population=population is >=0 and <=long.MaxValue?(long)population.Value:null,
Services=services.Count>0?services.ToArray():null,FleetCarrier=carrier,Power=Text(prefix+"controlling_power")??(Text(prefix+"power_state")=="Unoccupied"?"None":null),PowerState=Text(prefix+"power_state"),Civilizations=civilizations.ToArray(),PowerActivities=activities,
Colonisation=Boolean(prefix+"is_colonised")==true?"colonised":Boolean(prefix+"is_being_colonised")==true?"beingcolonised":population>0?"alreadypopulated":null,
ThargoidWar=Text("thargoid_war_state"),Commodities=commodities,CommodityDemands=demands,StationMarkets=stationMarkets,Markers=markers.Count>0?markers.ToArray():null,StationStates=stationStates.Count>0?stationStates:null,Region=Text("region"),ConfirmedQueries=certify?new HashSet<string>{filter.QueryKey}:new HashSet<string>()};
systems.Add(new(name,position,address,star,"Spansh 查询目录"){Data=data});
}
return systems;
}
public static string ServiceKey(string value)=>GalaxyMapFilter.Normalize(value) switch
{"market"=>"commodities","restock"=>"rearm","universalcartographics"=>"exploration","interstellarfactorscontact"=>"facilitator","technologybroker"=>"techbroker","redemptionoffice"=>"voucherredemption","refinerycontact"=>"refinery","systemcolonisation"=>"registeringcolonisation","constructionservices"=>"colonisationcontribution","fleetcarrierfuel"=>"carrierfuel","fleetcarriermanagement"=>"carriermanagement","fleetcarrieradministration"=>"modulepacks","fleetcarriervendor"=>"carriervendor",var key=>key};
}