XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet
公开
关注 0 Fork 0 Star 0
UTF-8
using XFEEliteDangerous.Client;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Xml;

namespace XFEEliteDangerous.Core;

public sealed class SharedGalaxyState
{
    public int Schema{get;set;}=1;
    public long Revision{get;set;}
    public Dictionary<string,string> Definitions{get;set;}=new(StringComparer.Ordinal);
    public Dictionary<ulong,string> Systems{get;set;}=[];
}

/// <summary>Server-wide public world data lives outside commander state.json, so
/// a legacy offline runtime cannot erase it when committing its own saved game.</summary>
public sealed class SharedGalaxyStore
{
    public static IReadOnlyList<string> DefinitionOperations{get;}=Array.AsReadOnly(new[]{
        "/2.0/elite/resources/dictionary","/2.0/elite/resources/finance","/2.0/elite/resources/loadingscreentips",
        "/2.0/elite/resources/microresources","/2.0/elite/resources/permits","/2.0/elite/resources/commodities",
        "/2.0/elite/resources/ussregion/list","/2.0/elite/synthesis/recipes","/2.0/elite/powerplay2/powers/list",
        "/2.0/elite/colonisation/resources/optionsfulllist","/2.0/elite/colonisation/resources/marketlink/weighting"});
    private readonly string root,path;
    public SharedGalaxyStore(string workspace){root=Path.GetFullPath(workspace);path=Path.Combine(root,"galaxy.json");}
    public SharedGalaxyState Snapshot()=>WithLease(Load);
    public NativeDataReply? Definition(string operation)
    {
        if(!DefinitionOperations.Contains(operation,StringComparer.Ordinal))return null;
        return WithLease(()=>Load().Definitions.TryGetValue(operation,out var payload)?new NativeDataReply(200,payload):null);
    }
    public NativeDataReply System(ulong address,NativeSystemMetadata? seed)
    {
        return WithLease(()=>
        {
            var state=Load();if(state.Systems.TryGetValue(address,out var payload))return new NativeDataReply(200,payload);
            if(seed is null||seed.Address!=address)return new(404,"{\"error\":\"UnknownSharedSystem\"}");
            payload=seed.Serialize(address);ValidateSystem(address,payload);state.Systems.Add(address,payload);state.Revision=checked(state.Revision+1);Save(state,false);
            return new(200,payload);
        });
    }
    public SharedGalaxyState Update(string version,string kind,string key,string payload)
    {
        return WithLease(()=>
        {
            var state=Load();if(version!=state.Revision.ToString(CultureInfo.InvariantCulture))throw new SaveConflictException();
            try
            {
                if(kind=="definition"){ValidateDefinition(key,payload);state.Definitions[key]=payload;}
                else if(kind=="system")
                {
                    if(!ulong.TryParse(key,NumberStyles.None,CultureInfo.InvariantCulture,out var address))throw new ArgumentException(AppText.Get("SharedGalaxyStore.Text365"));
                    ValidateSystem(address,payload);if(!state.Systems.ContainsKey(address)&&state.Systems.Count>=4096)throw new ArgumentException(AppText.Get("SharedGalaxyStore.Text366"));state.Systems[address]=payload;
                }
                else throw new ArgumentException(AppText.Get("SharedGalaxyStore.Text367"));
            }
            catch(Exception error)when(error is JsonException or XmlException or KeyNotFoundException or InvalidOperationException or FormatException)
            {throw new InvalidDataException(AppText.Get("SharedGalaxyStore.Text368"),error);}
            state.Revision=checked(state.Revision+1);Save(state,true);return state;
        });
    }
    private SharedGalaxyState Load()
    {
        if(!File.Exists(path))return new();
        if(new FileInfo(path).Length>64*1024*1024)throw new InvalidDataException(AppText.Get("SharedGalaxyStore.Text369"));
        var state=JsonSerializer.Deserialize(File.ReadAllBytes(path),CoreJsonContext.Default.SharedGalaxyState)??throw new InvalidDataException(AppText.Get("SharedGalaxyStore.Text370"));
        if(state.Schema!=1||state.Revision<0||state.Definitions is null||state.Systems is null||state.Definitions.Count>DefinitionOperations.Count||state.Systems.Count>4096)throw new InvalidDataException(AppText.Get("SharedGalaxyStore.Text371"));
        foreach(var item in state.Definitions)ValidateDefinition(item.Key,item.Value);foreach(var item in state.Systems)ValidateSystem(item.Key,item.Value);
        return state;
    }
    private void Save(SharedGalaxyState state,bool backup)
    {
        Directory.CreateDirectory(root);
        if(backup&&File.Exists(path))
        {
            var directory=Path.Combine(root,"galaxy-backups");Directory.CreateDirectory(directory);File.Copy(path,Path.Combine(directory,DateTimeOffset.UtcNow.ToString("yyyyMMdd-HHmmss",CultureInfo.InvariantCulture)+"-"+Guid.NewGuid().ToString("N")+".json"));
        }
        var temporary=Path.Combine(root,"galaxy-"+Guid.NewGuid().ToString("N")+".tmp");
        try
        {
            using(var file=new FileStream(temporary,FileMode.CreateNew,FileAccess.Write,FileShare.None)){JsonSerializer.Serialize(file,state,CoreJsonContext.Default.SharedGalaxyState);file.Flush(true);}
            if(File.Exists(path))File.Replace(temporary,path,path+".bak");else File.Move(temporary,path);
        }
        finally{if(File.Exists(temporary))File.Delete(temporary);}
    }
    private T WithLease<T>(Func<T> action)
    {
        Directory.CreateDirectory(root);var deadline=DateTimeOffset.UtcNow.AddSeconds(10);FileStream? lease=null;
        while(lease is null)
        {
            try{lease=new FileStream(Path.Combine(root,"galaxy.lock"),FileMode.OpenOrCreate,FileAccess.ReadWrite,FileShare.None);}
            catch(IOException){if(DateTimeOffset.UtcNow>deadline)throw;Thread.Sleep(5);}
        }
        using(lease)return action();
    }
    internal static void ValidateDefinition(string operation,string payload)
    {
        Bound(payload);if(!DefinitionOperations.Contains(operation,StringComparer.Ordinal))throw new ArgumentException(AppText.Get("SharedGalaxyStore.Text372"));
        if(operation is "/2.0/elite/synthesis/recipes" or "/2.0/elite/resources/ussregion/list")
        {
            using var reader=XmlReader.Create(new StringReader(payload),new(){DtdProcessing=DtdProcessing.Prohibit,XmlResolver=null,MaxCharactersInDocument=2*1024*1024});while(reader.Read()){}
        }
        else
        {
            using var document=JsonDocument.Parse(payload,new(){MaxDepth=64});var value=document.RootElement;
            if(value.ValueKind!=JsonValueKind.Object)throw new ArgumentException(AppText.Get("SharedGalaxyStore.Text373"));
            if(operation=="/2.0/elite/powerplay2/powers/list"&&(!value.TryGetProperty("powers",out var powers)||powers.ValueKind!=JsonValueKind.Array||powers.GetArrayLength() is 0 or >1024||powers.EnumerateArray().Any(item=>item.ValueKind!=JsonValueKind.Object||!item.EnumerateObject().Any())))throw new ArgumentException(AppText.Get("SharedGalaxyStore.Text374"));
            if(operation=="/2.0/elite/resources/commodities"&&(!value.TryGetProperty("commodities",out var commodities)||commodities.ValueKind is not(JsonValueKind.Array or JsonValueKind.Object)))throw new ArgumentException(AppText.Get("SharedGalaxyStore.Text375"));
            if(operation=="/2.0/elite/resources/permits")NativeReferenceLibrary.ValidatePermits(value);
        }
    }
    internal static void ValidateSystem(ulong address,string payload)
    {
        Bound(payload);if(address==0||address>0x007F_FFFF_FFFF_FFFFUL)throw new ArgumentException(AppText.Get("SharedGalaxyStore.Text376"));
        using var document=JsonDocument.Parse(payload,new(){MaxDepth=64});var outer=document.RootElement.GetProperty("starsystem");var system=outer.GetProperty("starsystem");
        if(system.GetProperty("address").GetUInt64()!=address||system.GetProperty("id").GetUInt64()==0||string.IsNullOrWhiteSpace(system.GetProperty("name").GetString())
            ||outer.GetProperty("polities").ValueKind!=JsonValueKind.Object||outer.GetProperty("unownedSettlements").ValueKind!=JsonValueKind.Object)throw new ArgumentException(AppText.Get("SharedGalaxyStore.Text377"));
        foreach(var name in new[]{"wealth","security","standardofliving","developmentlevel"})
        {var value=system.GetProperty(name).GetDouble();if(!double.IsFinite(value)||value<0||value>float.MaxValue)throw new ArgumentException(AppText.Get("SharedGalaxyStore.Text378"));}
        // Preserve nonempty polity documents exactly. Their detailed native
        // schema is not guessed or projected onto a smaller model.
    }
    private static void Bound(string payload){if(string.IsNullOrWhiteSpace(payload)||Encoding.UTF8.GetByteCount(payload)>2*1024*1024)throw new ArgumentException(AppText.Get("SharedGalaxyStore.Text379"));}
}