using System.Text.Json;
using System.Text.RegularExpressions;
using Jint;
using XFEEDToolBox.Core.Engineering;
using XFEEDToolBox.Core.Models;
namespace XFEEDToolBox.Core.Outfitting;
/// <summary>EDSY's pinned headless codec, executed offline without a browser or CLR access.</summary>
public static partial class EdsyLinkCodec
{
public const string ReferenceCommit = "1fafc5d9f2d07e2e92443657c83d0c65b897bb00";
private static readonly object Gate = new();
private static Engine? engine;
private static Engine Engine => engine ??= CreateEngine();
private static Engine CreateEngine()
{
var result = new Engine(o => o.LimitMemory(256_000_000).TimeoutInterval(TimeSpan.FromSeconds(20)).MaxStatements(20_000_000));
result.Execute("var window={location:{protocol:'https:',host:'edsy.org',pathname:'/'}};var document={title:'XFEEDToolBox offline'};");
foreach (var name in new[] { "eddb.js", "edsy.js", "bridge.js" })
{
using var stream = typeof(EdsyLinkCodec).Assembly.GetManifestResourceStream("XFEEDToolBox.Core.Outfitting.Edsy." + name)!;
using var reader = new StreamReader(stream);
result.Execute(reader.ReadToEnd());
}
return result;
}
public static string ExtractHash(string value)
{
value = value.Trim();
if (value.Length > 48_000) throw new FormatException("EDSY 链接过长。");
if (Uri.TryCreate(value, UriKind.Absolute, out var url))
{
ValidateHost(url);
value = url.Fragment;
}
if (value.StartsWith('#')) value = value[1..];
if (value.StartsWith("/L=", StringComparison.OrdinalIgnoreCase)) value = value[3..];
else if (value.StartsWith("L=", StringComparison.OrdinalIgnoreCase)) value = value[2..];
value = Uri.UnescapeDataString(value).Split('&')[0];
if (!HashPattern().IsMatch(value)) throw new FormatException("请输入 EDSY 装配分享链接(#/L=…)或改船码。");
const string alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_-";
var version = alphabet.IndexOf(value[0]);
if (version < 0 || version > 20) throw new FormatException("此 EDSY 编码版本尚未支持,请在 EDSY 中另存为当前版本链接。");
var groups=value.Split(',');
if(groups.Length<(version>=10?6:version>=9?5:4)||groups.Length>8||groups[0].Length<7)
throw new FormatException("EDSY 改船码不完整,请复制完整链接。");
return value;
}
private static void ValidateHost(Uri url)
{
if (url.Scheme is not ("http" or "https") || url.Host.ToLowerInvariant() is not ("edsy.org" or "www.edsy.org") || !url.IsDefaultPort || url.UserInfo.Length > 0)
throw new FormatException("仅支持 edsy.org 的装配链接。");
}
public static async Task<ShipRefitDocument> ImportAsync(string value, CancellationToken token = default)
{
if (Uri.TryCreate(value.Trim(), UriKind.Absolute, out var url))
{
ValidateHost(url);
if (url.AbsolutePath.StartsWith("/s/", StringComparison.Ordinal) || url.AbsolutePath.StartsWith("/dev/s/", StringComparison.Ordinal))
{
using var handler = new HttpClientHandler { AllowAutoRedirect = false };
using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(20) };
using var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, token);
var location = response.Headers.Location;
if ((int)response.StatusCode is < 300 or >= 400 || location is null) throw new FormatException("EDSY 短链接无法展开,请使用原站的完整装配链接。");
value = (location.IsAbsoluteUri ? location : new Uri(url, location)).AbsoluteUri;
}
}
return await Task.Run(() => Import(value), token);
}
public static ShipRefitDocument Import(string value)
{
var hash = ExtractHash(value);
using var json = Invoke("xfeImport", hash);
var root = json.RootElement;
ThrowErrors(root);
var catalog = OutfittingCatalog.Default;
var slots = root.GetProperty("slots").EnumerateArray().Select(s =>
{
var name = s.GetProperty("slot").GetString()!;
var moduleJson = s.GetProperty("module");
FittedModule? module = moduleJson.ValueKind == JsonValueKind.Null ? null : ReadModule(moduleJson);
return new RefitSlot { Slot = name, Category = s.GetProperty("category").GetString()!, Capacity = s.GetProperty("capacity").GetInt32(),
MiningOnly = s.GetProperty("miningOnly").GetBoolean(), Module = module, Original = module,
Origin = "EDSY 导入", Family = module is null ? ShipRefit.CoreFamily(name) : EngineeringCatalog.ModuleType(module.Item) is { Length: >0 } family ? family : catalog.Find(module.Item)?.Name ?? "" };
}).ToArray();
return new(-1, root.GetProperty("ship").GetString()!, root.GetProperty("name").GetString()!, null, slots)
{ EdsyHash = root.GetProperty("hash").GetString()!, Ident = root.GetProperty("ident").GetString()! };
}
public static string Export(ShipRefitDocument document)
{
if(new[]{"Armour","PowerPlant","MainEngines","FrameShiftDrive","LifeSupport","PowerDistributor","Radar","FuelTank"}.Any(name=>document.Slots.FirstOrDefault(s=>s.Slot==name)?.Module is null))
throw new FormatException("EDSY 链接需要完整的核心模块配置,请先安装缺少的核心模块;不会用原厂模块替代空槽。");
var loadout = new { Ship = document.ShipType, ShipName = document.Name, ShipIdent = document.Ident,
Modules = document.Slots.Select(s => JournalModule(s)).ToArray() };
var changed = document.Slots.Where(s => s.Module != s.Original || s.Target is not null).Select(s => s.Slot).ToArray();
using var result = Invoke("xfeExport", JsonSerializer.Serialize(new { loadout, baselineHash = document.EdsyHash, changed }));
ThrowErrors(result.RootElement);
return "https://edsy.org/#/L=" + result.RootElement.GetProperty("hash").GetString();
}
private static object JournalModule(RefitSlot slot)
{
var module = slot.Module;
var engineering = module?.Engineering;
var type = EngineeringCatalog.ModuleType(module?.Item ?? "");
var recipes = EngineeringCatalog.Default.Recipes;
var blueprint = slot.Target?.Blueprint ?? engineering?.Blueprint ?? "";
var recipe = recipes.FirstOrDefault(r => r.Type == type && r.Grade > 0 && (EngineeringCatalog.Key(r.Name) == EngineeringCatalog.Key(blueprint) || EngineeringCatalog.Key(r.JournalName) == EngineeringCatalog.Key(blueprint)));
var effect=slot.Target is {} t?t.Experimental:EngineeringExperimental.Current(engineering);
var effectRecipe = recipes.FirstOrDefault(r => r.Type == type && r.Grade == 0 && (EngineeringCatalog.Key(r.Name) == EngineeringCatalog.Key(effect) || EngineeringCatalog.Key(r.JournalName) == EngineeringCatalog.Key(effect)));
return new { Slot = slot.Slot, Item = module?.Item ?? "", On = module?.Enabled ?? true, Priority = module?.Priority ?? 0,
Engineering = engineering is null && slot.Target is null ? null : new {
BlueprintName = recipe?.JournalName is { Length: >0 } journal ? journal : blueprint,
Level = slot.Target?.Grade ?? engineering?.Level ?? 0, Quality = slot.Target?.Quality ?? engineering?.Quality ?? 0,
ExperimentalEffect = effectRecipe?.JournalName is { Length: >0 } journalEffect ? journalEffect : effect,
Modifiers = slot.Target is null ? (engineering?.Modifiers ?? []).Where(m => m.Value is not null && m.ValueText.Length == 0).Select(m => new {m.Label,m.Value,m.OriginalValue}).ToArray() : [] } };
}
private static FittedModule ReadModule(JsonElement m)
{
ModuleEngineering? engineering = null;
if (m.TryGetProperty("Engineering", out var e))
{
engineering = new() { Blueprint = Text(e,"BlueprintName"), Level = e.TryGetProperty("Level",out var level)?level.GetInt32():0,
Quality = e.TryGetProperty("Quality",out var q)?q.GetDouble():null, ExperimentalEffectId = Text(e,"ExperimentalEffect"),
Modifiers = e.TryGetProperty("Modifiers",out var mods)?mods.EnumerateArray().Select(x => new EngineeringModifier(Text(x,"Label"),x.GetProperty("Value").GetDouble(),x.GetProperty("OriginalValue").GetDouble(),false)).ToArray():[] };
}
return new() {Slot=Text(m,"Slot"),Item=Text(m,"Item"),Name=OutfittingCatalog.Default.Find(Text(m,"Item"))?.Name??Text(m,"Item"),Enabled=m.GetProperty("On").GetBoolean(),Priority=m.GetProperty("Priority").GetInt32(),Engineering=engineering,Value=m.TryGetProperty("Value",out var v)?v.GetInt64():null};
}
private static string Text(JsonElement e,string name) => e.TryGetProperty(name,out var value)?value.GetString()??"":"";
private static JsonDocument Invoke(string name,string input)
{
lock(Gate)
{
try { return JsonDocument.Parse(Engine.Invoke(name,input).AsString()); }
catch(Exception e) when(e is not FormatException) { engine=null; throw new FormatException("EDSY 装配编码失败:"+e.Message,e); }
}
}
private static void ThrowErrors(JsonElement root)
{
var errors=root.GetProperty("errors").EnumerateArray().Select(e=>e.GetString()).ToArray();
if(errors.Length>0) throw new FormatException("EDSY 无法完整转换此配置:"+string.Join(";",errors));
}
[GeneratedRegex("^[0-9A-Za-z_-]{4,}(?:,[0-9A-Za-z_-]*)+$",RegexOptions.CultureInvariant)]
private static partial Regex HashPattern();
}
using System.Text.Json;
using System.Text.RegularExpressions;
using Jint;
using XFEEDToolBox.Core.Engineering;
using XFEEDToolBox.Core.Models;
namespace XFEEDToolBox.Core.Outfitting;
/// <summary>EDSY's pinned headless codec, executed offline without a browser or CLR access.</summary>
public static partial class EdsyLinkCodec
{
public const string ReferenceCommit = "1fafc5d9f2d07e2e92443657c83d0c65b897bb00";
private static readonly object Gate = new();
private static Engine? engine;
private static Engine Engine => engine ??= CreateEngine();
private static Engine CreateEngine()
{
var result = new Engine(o => o.LimitMemory(256_000_000).TimeoutInterval(TimeSpan.FromSeconds(20)).MaxStatements(20_000_000));
result.Execute("var window={location:{protocol:'https:',host:'edsy.org',pathname:'/'}};var document={title:'XFEEDToolBox offline'};");
foreach (var name in new[] { "eddb.js", "edsy.js", "bridge.js" })
{
using var stream = typeof(EdsyLinkCodec).Assembly.GetManifestResourceStream("XFEEDToolBox.Core.Outfitting.Edsy." + name)!;
using var reader = new StreamReader(stream);
result.Execute(reader.ReadToEnd());
}
return result;
}
public static string ExtractHash(string value)
{
value = value.Trim();
if (value.Length > 48_000) throw new FormatException("EDSY 链接过长。");
if (Uri.TryCreate(value, UriKind.Absolute, out var url))
{
ValidateHost(url);
value = url.Fragment;
}
if (value.StartsWith('#')) value = value[1..];
if (value.StartsWith("/L=", StringComparison.OrdinalIgnoreCase)) value = value[3..];
else if (value.StartsWith("L=", StringComparison.OrdinalIgnoreCase)) value = value[2..];
value = Uri.UnescapeDataString(value).Split('&')[0];
if (!HashPattern().IsMatch(value)) throw new FormatException("请输入 EDSY 装配分享链接(#/L=…)或改船码。");
const string alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_-";
var version = alphabet.IndexOf(value[0]);
if (version < 0 || version > 20) throw new FormatException("此 EDSY 编码版本尚未支持,请在 EDSY 中另存为当前版本链接。");
var groups=value.Split(',');
if(groups.Length<(version>=10?6:version>=9?5:4)||groups.Length>8||groups[0].Length<7)
throw new FormatException("EDSY 改船码不完整,请复制完整链接。");
return value;
}
private static void ValidateHost(Uri url)
{
if (url.Scheme is not ("http" or "https") || url.Host.ToLowerInvariant() is not ("edsy.org" or "www.edsy.org") || !url.IsDefaultPort || url.UserInfo.Length > 0)
throw new FormatException("仅支持 edsy.org 的装配链接。");
}
public static async Task<ShipRefitDocument> ImportAsync(string value, CancellationToken token = default)
{
if (Uri.TryCreate(value.Trim(), UriKind.Absolute, out var url))
{
ValidateHost(url);
if (url.AbsolutePath.StartsWith("/s/", StringComparison.Ordinal) || url.AbsolutePath.StartsWith("/dev/s/", StringComparison.Ordinal))
{
using var handler = new HttpClientHandler { AllowAutoRedirect = false };
using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(20) };
using var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, token);
var location = response.Headers.Location;
if ((int)response.StatusCode is < 300 or >= 400 || location is null) throw new FormatException("EDSY 短链接无法展开,请使用原站的完整装配链接。");
value = (location.IsAbsoluteUri ? location : new Uri(url, location)).AbsoluteUri;
}
}
return await Task.Run(() => Import(value), token);
}
public static ShipRefitDocument Import(string value)
{
var hash = ExtractHash(value);
using var json = Invoke("xfeImport", hash);
var root = json.RootElement;
ThrowErrors(root);
var catalog = OutfittingCatalog.Default;
var slots = root.GetProperty("slots").EnumerateArray().Select(s =>
{
var name = s.GetProperty("slot").GetString()!;
var moduleJson = s.GetProperty("module");
FittedModule? module = moduleJson.ValueKind == JsonValueKind.Null ? null : ReadModule(moduleJson);
return new RefitSlot { Slot = name, Category = s.GetProperty("category").GetString()!, Capacity = s.GetProperty("capacity").GetInt32(),
MiningOnly = s.GetProperty("miningOnly").GetBoolean(), Module = module, Original = module,
Origin = "EDSY 导入", Family = module is null ? ShipRefit.CoreFamily(name) : EngineeringCatalog.ModuleType(module.Item) is { Length: >0 } family ? family : catalog.Find(module.Item)?.Name ?? "" };
}).ToArray();
return new(-1, root.GetProperty("ship").GetString()!, root.GetProperty("name").GetString()!, null, slots)
{ EdsyHash = root.GetProperty("hash").GetString()!, Ident = root.GetProperty("ident").GetString()! };
}
public static string Export(ShipRefitDocument document)
{
if(new[]{"Armour","PowerPlant","MainEngines","FrameShiftDrive","LifeSupport","PowerDistributor","Radar","FuelTank"}.Any(name=>document.Slots.FirstOrDefault(s=>s.Slot==name)?.Module is null))
throw new FormatException("EDSY 链接需要完整的核心模块配置,请先安装缺少的核心模块;不会用原厂模块替代空槽。");
var loadout = new { Ship = document.ShipType, ShipName = document.Name, ShipIdent = document.Ident,
Modules = document.Slots.Select(s => JournalModule(s)).ToArray() };
var changed = document.Slots.Where(s => s.Module != s.Original || s.Target is not null).Select(s => s.Slot).ToArray();
using var result = Invoke("xfeExport", JsonSerializer.Serialize(new { loadout, baselineHash = document.EdsyHash, changed }));
ThrowErrors(result.RootElement);
return "https://edsy.org/#/L=" + result.RootElement.GetProperty("hash").GetString();
}
private static object JournalModule(RefitSlot slot)
{
var module = slot.Module;
var engineering = module?.Engineering;
var type = EngineeringCatalog.ModuleType(module?.Item ?? "");
var recipes = EngineeringCatalog.Default.Recipes;
var blueprint = slot.Target?.Blueprint ?? engineering?.Blueprint ?? "";
var recipe = recipes.FirstOrDefault(r => r.Type == type && r.Grade > 0 && (EngineeringCatalog.Key(r.Name) == EngineeringCatalog.Key(blueprint) || EngineeringCatalog.Key(r.JournalName) == EngineeringCatalog.Key(blueprint)));
var effect=slot.Target is {} t?t.Experimental:EngineeringExperimental.Current(engineering);
var effectRecipe = recipes.FirstOrDefault(r => r.Type == type && r.Grade == 0 && (EngineeringCatalog.Key(r.Name) == EngineeringCatalog.Key(effect) || EngineeringCatalog.Key(r.JournalName) == EngineeringCatalog.Key(effect)));
return new { Slot = slot.Slot, Item = module?.Item ?? "", On = module?.Enabled ?? true, Priority = module?.Priority ?? 0,
Engineering = engineering is null && slot.Target is null ? null : new {
BlueprintName = recipe?.JournalName is { Length: >0 } journal ? journal : blueprint,
Level = slot.Target?.Grade ?? engineering?.Level ?? 0, Quality = slot.Target?.Quality ?? engineering?.Quality ?? 0,
ExperimentalEffect = effectRecipe?.JournalName is { Length: >0 } journalEffect ? journalEffect : effect,
Modifiers = slot.Target is null ? (engineering?.Modifiers ?? []).Where(m => m.Value is not null && m.ValueText.Length == 0).Select(m => new {m.Label,m.Value,m.OriginalValue}).ToArray() : [] } };
}
private static FittedModule ReadModule(JsonElement m)
{
ModuleEngineering? engineering = null;
if (m.TryGetProperty("Engineering", out var e))
{
engineering = new() { Blueprint = Text(e,"BlueprintName"), Level = e.TryGetProperty("Level",out var level)?level.GetInt32():0,
Quality = e.TryGetProperty("Quality",out var q)?q.GetDouble():null, ExperimentalEffectId = Text(e,"ExperimentalEffect"),
Modifiers = e.TryGetProperty("Modifiers",out var mods)?mods.EnumerateArray().Select(x => new EngineeringModifier(Text(x,"Label"),x.GetProperty("Value").GetDouble(),x.GetProperty("OriginalValue").GetDouble(),false)).ToArray():[] };
}
return new() {Slot=Text(m,"Slot"),Item=Text(m,"Item"),Name=OutfittingCatalog.Default.Find(Text(m,"Item"))?.Name??Text(m,"Item"),Enabled=m.GetProperty("On").GetBoolean(),Priority=m.GetProperty("Priority").GetInt32(),Engineering=engineering,Value=m.TryGetProperty("Value",out var v)?v.GetInt64():null};
}
private static string Text(JsonElement e,string name) => e.TryGetProperty(name,out var value)?value.GetString()??"":"";
private static JsonDocument Invoke(string name,string input)
{
lock(Gate)
{
try { return JsonDocument.Parse(Engine.Invoke(name,input).AsString()); }
catch(Exception e) when(e is not FormatException) { engine=null; throw new FormatException("EDSY 装配编码失败:"+e.Message,e); }
}
}
private static void ThrowErrors(JsonElement root)
{
var errors=root.GetProperty("errors").EnumerateArray().Select(e=>e.GetString()).ToArray();
if(errors.Length>0) throw new FormatException("EDSY 无法完整转换此配置:"+string.Join(";",errors));
}
[GeneratedRegex("^[0-9A-Za-z_-]{4,}(?:,[0-9A-Za-z_-]*)+$",RegexOptions.CultureInvariant)]
private static partial Regex HashPattern();
}