using System.Text.Json;
using XFEEDToolBox.Core.Models;
namespace XFEEDToolBox.Core.Galaxy;
public enum GalacticPlaceKind { MaterialTrader,HighTech,Engineer,Ship,Module,Commodity,MaterialGathering }
public sealed record GalacticPlace(string Name,string SystemName,Coordinates Position,long? SystemAddress,double Distance,string Description,string Updated="",string Portrait="",string Source="")
{
public string DistanceText=>$"{Distance:N2} ly · {Description}";
public bool HasPortrait=>Portrait.Length>0;
public GalacticSystem System=>new(SystemName,Position,SystemAddress,Source:Source.Length>0?Source:HasPortrait?"工程师驻地目录":"Spansh 站点目录");
}
/// <summary>Nearest reported stations, ordered by real galactic distance. Never calculates a flight route.</summary>
public sealed class GalacticPlaces(HttpClient http)
{
private readonly Dictionary<string,(DateTimeOffset Expires,IReadOnlyList<GalacticPlace> Results)> searches=[];
private readonly Dictionary<GalacticPlaceKind,IReadOnlyList<string>> choices=[];
public static object Request(GalacticPlaceKind kind,string value,Coordinates origin,int? moduleClass=null,string rating="",bool largePad=false)
{
if(!origin.IsValid)throw new ArgumentException("尚未读取当前位置。");
value=value.Trim();if((kind is GalacticPlaceKind.Ship or GalacticPlaceKind.Module or GalacticPlaceKind.Commodity)&&(value.Length is <1 or >128))throw new ArgumentException("请先选择要查找的飞船、模块或商品。");
var filters=new Dictionary<string,object>();
object Group(string name)=>new{value=new[]{name}};
switch(kind)
{
case GalacticPlaceKind.MaterialTrader:
if(value is not ("Raw" or "Manufactured" or "Encoded"))throw new ArgumentException("请选择材料贸易商类型。");
filters["material_trader"]=Group(value);break;
case GalacticPlaceKind.HighTech:filters["system_primary_economy"]=Group("High Tech");break;
case GalacticPlaceKind.Ship:filters["ships"]=new[]{new{name=Group(value)}};break;
case GalacticPlaceKind.Module:
var module=new Dictionary<string,object>{{"name",Group(value)}};
if(moduleClass is >=0 and <=8)module["class"]=new{value=new[]{moduleClass.Value}};
if(rating.Length==1&&"ABCDEFGHI".Contains(rating))module["rating"]=Group(rating);
filters["modules"]=new[]{module};break;
case GalacticPlaceKind.Commodity:filters["marketplace"]=new[]{new{commodity=Group(value),supply=new{comparison="<=>",value=new[]{1,2147483647}}}};break;
default:throw new ArgumentException("工程师位置请使用工程师目录。");
}
if(largePad)filters["has_large_pad"]=new{value=true};
// The API includes every market item and module for every station. Only request the nearest few.
return new{filters,sort=new[]{new{distance=new{direction="asc"}}},size=5,page=0,reference_coords=new{x=origin.X,y=origin.Y,z=origin.Z}};
}
public async Task<IReadOnlyList<GalacticPlace>> SearchAsync(GalacticPlaceKind kind,string value,Coordinates origin,int? moduleClass=null,string rating="",bool largePad=false,CancellationToken token=default)
{
token.ThrowIfCancellationRequested();
var request=Request(kind,value,origin,moduleClass,rating,largePad);var key=JsonSerializer.Serialize(request);
lock(searches)if(searches.TryGetValue(key,out var cached)&&cached.Expires>DateTimeOffset.UtcNow)return cached.Results;
using var json=await PlaceSearchHttp.ReadAsync(http,"https://spansh.co.uk/api/stations/search",request,token).ConfigureAwait(false);
var results=Parse(json.RootElement,kind,value,origin);token.ThrowIfCancellationRequested();
lock(searches){if(searches.Count>=32)searches.Clear();searches[key]=(DateTimeOffset.UtcNow.AddMinutes(2),results);}
return results;
}
public static IReadOnlyList<GalacticPlace> Parse(JsonElement root,GalacticPlaceKind kind,string value,Coordinates origin)
{
if(!root.TryGetProperty("results",out var rows)||rows.ValueKind!=JsonValueKind.Array)throw new InvalidDataException("站点服务未返回有效结果。");
var found=new List<GalacticPlace>();
foreach(var row in rows.EnumerateArray().Take(40))
{
string S(string key)=>row.TryGetProperty(key,out var x)&&x.ValueKind==JsonValueKind.String?x.GetString()??"":"";
double? N(string key)=>row.TryGetProperty(key,out var x)&&x.ValueKind==JsonValueKind.Number&&x.TryGetDouble(out var n)&&double.IsFinite(n)?n:null;
if(N("system_x") is not {} x||N("system_y") is not {} y||N("system_z") is not {} z||S("system_name").Length==0)continue;
var position=new Coordinates(x,y,z);if(!position.IsValid)continue;
long? address=row.TryGetProperty("system_id64",out var id)&&id.TryGetInt64(out var a)&&a>0?a:null;
var service=kind switch{GalacticPlaceKind.MaterialTrader=>value switch{"Raw"=>"原材料贸易商","Encoded"=>"数据贸易商",_=>"人造材料贸易商"},GalacticPlaceKind.HighTech=>"高科技星系",_=>value};
var arrival=N("distance_to_arrival") is {} ls?$" · {ls:N0} ls":"";
var updated=S(kind switch{GalacticPlaceKind.Ship=>"shipyard_updated_at",GalacticPlaceKind.Module=>"outfitting_updated_at",GalacticPlaceKind.Commodity=>"market_updated_at",_=>"updated_at"});
var stamp=DateTimeOffset.TryParse(updated,out var at)?$"记录于 {at.ToLocalTime():MM-dd HH:mm}":"采集时间未记录";
found.Add(new(S("name"),S("system_name"),position,address,origin.DistanceTo(position),service+arrival,stamp));
}
return found.DistinctBy(p=>(p.SystemName,p.Name)).OrderBy(p=>p.Distance).Take(20).ToArray();
}
public async Task<IReadOnlyList<string>> ChoicesAsync(GalacticPlaceKind kind,CancellationToken token=default)
{
token.ThrowIfCancellationRequested();
lock(choices)if(choices.TryGetValue(kind,out var cached))return cached;
var field=kind switch{GalacticPlaceKind.Ship=>"ships",GalacticPlaceKind.Module=>"modules",GalacticPlaceKind.Commodity=>"marketplace",_=>throw new ArgumentException("该类别无需商品补全。")};
var key=kind==GalacticPlaceKind.Commodity?"commodity":"name";
using var json=await PlaceSearchHttp.ReadAsync(http,"https://spansh.co.uk/api/stations/field_values/"+field,null,token).ConfigureAwait(false);
var result=json.RootElement.GetProperty("values").GetProperty(key).EnumerateArray().Take(2000).Select(v=>v.GetString()).OfType<string>().Order(StringComparer.OrdinalIgnoreCase).ToArray();
token.ThrowIfCancellationRequested();lock(choices)choices[kind]=result;return result;
}
}
using System.Text.Json;
using XFEEDToolBox.Core.Models;
namespace XFEEDToolBox.Core.Galaxy;
public enum GalacticPlaceKind { MaterialTrader,HighTech,Engineer,Ship,Module,Commodity,MaterialGathering }
public sealed record GalacticPlace(string Name,string SystemName,Coordinates Position,long? SystemAddress,double Distance,string Description,string Updated="",string Portrait="",string Source="")
{
public string DistanceText=>$"{Distance:N2} ly · {Description}";
public bool HasPortrait=>Portrait.Length>0;
public GalacticSystem System=>new(SystemName,Position,SystemAddress,Source:Source.Length>0?Source:HasPortrait?"工程师驻地目录":"Spansh 站点目录");
}
/// <summary>Nearest reported stations, ordered by real galactic distance. Never calculates a flight route.</summary>
public sealed class GalacticPlaces(HttpClient http)
{
private readonly Dictionary<string,(DateTimeOffset Expires,IReadOnlyList<GalacticPlace> Results)> searches=[];
private readonly Dictionary<GalacticPlaceKind,IReadOnlyList<string>> choices=[];
public static object Request(GalacticPlaceKind kind,string value,Coordinates origin,int? moduleClass=null,string rating="",bool largePad=false)
{
if(!origin.IsValid)throw new ArgumentException("尚未读取当前位置。");
value=value.Trim();if((kind is GalacticPlaceKind.Ship or GalacticPlaceKind.Module or GalacticPlaceKind.Commodity)&&(value.Length is <1 or >128))throw new ArgumentException("请先选择要查找的飞船、模块或商品。");
var filters=new Dictionary<string,object>();
object Group(string name)=>new{value=new[]{name}};
switch(kind)
{
case GalacticPlaceKind.MaterialTrader:
if(value is not ("Raw" or "Manufactured" or "Encoded"))throw new ArgumentException("请选择材料贸易商类型。");
filters["material_trader"]=Group(value);break;
case GalacticPlaceKind.HighTech:filters["system_primary_economy"]=Group("High Tech");break;
case GalacticPlaceKind.Ship:filters["ships"]=new[]{new{name=Group(value)}};break;
case GalacticPlaceKind.Module:
var module=new Dictionary<string,object>{{"name",Group(value)}};
if(moduleClass is >=0 and <=8)module["class"]=new{value=new[]{moduleClass.Value}};
if(rating.Length==1&&"ABCDEFGHI".Contains(rating))module["rating"]=Group(rating);
filters["modules"]=new[]{module};break;
case GalacticPlaceKind.Commodity:filters["marketplace"]=new[]{new{commodity=Group(value),supply=new{comparison="<=>",value=new[]{1,2147483647}}}};break;
default:throw new ArgumentException("工程师位置请使用工程师目录。");
}
if(largePad)filters["has_large_pad"]=new{value=true};
// The API includes every market item and module for every station. Only request the nearest few.
return new{filters,sort=new[]{new{distance=new{direction="asc"}}},size=5,page=0,reference_coords=new{x=origin.X,y=origin.Y,z=origin.Z}};
}
public async Task<IReadOnlyList<GalacticPlace>> SearchAsync(GalacticPlaceKind kind,string value,Coordinates origin,int? moduleClass=null,string rating="",bool largePad=false,CancellationToken token=default)
{
token.ThrowIfCancellationRequested();
var request=Request(kind,value,origin,moduleClass,rating,largePad);var key=JsonSerializer.Serialize(request);
lock(searches)if(searches.TryGetValue(key,out var cached)&&cached.Expires>DateTimeOffset.UtcNow)return cached.Results;
using var json=await PlaceSearchHttp.ReadAsync(http,"https://spansh.co.uk/api/stations/search",request,token).ConfigureAwait(false);
var results=Parse(json.RootElement,kind,value,origin);token.ThrowIfCancellationRequested();
lock(searches){if(searches.Count>=32)searches.Clear();searches[key]=(DateTimeOffset.UtcNow.AddMinutes(2),results);}
return results;
}
public static IReadOnlyList<GalacticPlace> Parse(JsonElement root,GalacticPlaceKind kind,string value,Coordinates origin)
{
if(!root.TryGetProperty("results",out var rows)||rows.ValueKind!=JsonValueKind.Array)throw new InvalidDataException("站点服务未返回有效结果。");
var found=new List<GalacticPlace>();
foreach(var row in rows.EnumerateArray().Take(40))
{
string S(string key)=>row.TryGetProperty(key,out var x)&&x.ValueKind==JsonValueKind.String?x.GetString()??"":"";
double? N(string key)=>row.TryGetProperty(key,out var x)&&x.ValueKind==JsonValueKind.Number&&x.TryGetDouble(out var n)&&double.IsFinite(n)?n:null;
if(N("system_x") is not {} x||N("system_y") is not {} y||N("system_z") is not {} z||S("system_name").Length==0)continue;
var position=new Coordinates(x,y,z);if(!position.IsValid)continue;
long? address=row.TryGetProperty("system_id64",out var id)&&id.TryGetInt64(out var a)&&a>0?a:null;
var service=kind switch{GalacticPlaceKind.MaterialTrader=>value switch{"Raw"=>"原材料贸易商","Encoded"=>"数据贸易商",_=>"人造材料贸易商"},GalacticPlaceKind.HighTech=>"高科技星系",_=>value};
var arrival=N("distance_to_arrival") is {} ls?$" · {ls:N0} ls":"";
var updated=S(kind switch{GalacticPlaceKind.Ship=>"shipyard_updated_at",GalacticPlaceKind.Module=>"outfitting_updated_at",GalacticPlaceKind.Commodity=>"market_updated_at",_=>"updated_at"});
var stamp=DateTimeOffset.TryParse(updated,out var at)?$"记录于 {at.ToLocalTime():MM-dd HH:mm}":"采集时间未记录";
found.Add(new(S("name"),S("system_name"),position,address,origin.DistanceTo(position),service+arrival,stamp));
}
return found.DistinctBy(p=>(p.SystemName,p.Name)).OrderBy(p=>p.Distance).Take(20).ToArray();
}
public async Task<IReadOnlyList<string>> ChoicesAsync(GalacticPlaceKind kind,CancellationToken token=default)
{
token.ThrowIfCancellationRequested();
lock(choices)if(choices.TryGetValue(kind,out var cached))return cached;
var field=kind switch{GalacticPlaceKind.Ship=>"ships",GalacticPlaceKind.Module=>"modules",GalacticPlaceKind.Commodity=>"marketplace",_=>throw new ArgumentException("该类别无需商品补全。")};
var key=kind==GalacticPlaceKind.Commodity?"commodity":"name";
using var json=await PlaceSearchHttp.ReadAsync(http,"https://spansh.co.uk/api/stations/field_values/"+field,null,token).ConfigureAwait(false);
var result=json.RootElement.GetProperty("values").GetProperty(key).EnumerateArray().Take(2000).Select(v=>v.GetString()).OfType<string>().Order(StringComparer.OrdinalIgnoreCase).ToArray();
token.ThrowIfCancellationRequested();lock(choices)choices[kind]=result;return result;
}
}