XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEEDToolBox

【WinUI】XFE精英危险工具箱

公开
关注 0 Fork 0 Star 0
UTF-8
using XFEEDToolBox.Core.Models;
using XFEEDToolBox.Core.Navigation;
using System.Text.Json;
using System.Diagnostics;

namespace XFEEDToolBox.Core.Galaxy;

/// <summary>Bounded queries of the installed game's actual Stellar Forge catalogue.</summary>
public sealed partial class NativeGalaxyCatalog(int processId,TimeSpan? regionTimeout=null)
{
    private readonly NavigationPipeClient pipe=new(processId);
    private readonly SemaphoreSlim queries=new(1,1);
    public int ProcessId=>processId;
    private bool protocolInspected;
    public double MinimumRegionRadius { get; private set; }=5;
    public bool SupportsRegionCoverage { get; private set; }
    public bool SupportsCancellation { get; private set; }
    public bool SupportsCells { get; private set; }
    public bool SupportsCatalogueProfile { get; private set; }
    public bool SupportsPointOverrides { get; private set; }
    public async Task InspectProtocolAsync(CancellationToken token=default)
    {
        if(protocolInspected)return;
        var description=await pipe.CallAsync(new{op="describe"},token);
        ApplyProtocolDescription(description);
    }
    public void ApplyProtocolDescription(JsonElement description)
    {
        if(description.TryGetProperty("processId",out var id)&&id.GetInt32()!=ProcessId)throw new InvalidDataException("游戏银河目录进程已变化。");
        bool Flag(string name)=>description.TryGetProperty(name,out var value)&&value.ValueKind==JsonValueKind.True;
        SupportsRegionCoverage=Flag("galaxyRegionCoverage");SupportsCancellation=Flag("galaxyCancellation");
        SupportsCells=Flag("galaxyCellQueries");
        SupportsCatalogueProfile=Flag("galaxyCatalogueProfile");
        SupportsPointOverrides=Flag("galaxyPointOverrides");
        SupportsDustInputs=Flag("dustInputs");
        SupportsRouteFilterChoices=Flag("routeFilterChoices");
        MinimumRegionRadius=description.TryGetProperty("galaxyMinimumRadius",out var minimum)&&minimum.ValueKind==JsonValueKind.Number
            &&minimum.TryGetDouble(out var radius)&&double.IsFinite(radius)&&radius is >=.05 and <=700?radius:5;
        protocolInspected=true;
    }
    /// <summary>Invoked on the requesting method's synchronization context; rows are never speculative.</summary>
    public event Action<GalaxyNativeRegionProgress>? RegionProgress;
    public async Task<GalacticSystem> FindAsync(string name,CancellationToken token=default)
    {
        var found=(await QueryAsync("galaxy.lookup",name,token)).FirstOrDefault()??throw new KeyNotFoundException("游戏银河目录未找到该星系。");
        if(!found.Name.Equals(name.Trim(),StringComparison.OrdinalIgnoreCase))throw new InvalidDataException("游戏星系查询返回的名称与搜索目标不一致。");
        return found;
    }
    public Task<IReadOnlyList<GalacticSystem>> SuggestAsync(string prefix,CancellationToken token=default)=>QueryAsync("galaxy.suggest",prefix,token);
    public async Task<GalacticSystem> ResolveAddressAsync(long address,CancellationToken token=default)
    {
        if(address<=0||address>=1L<<55)throw new ArgumentException("游戏星系地址无效。",nameof(address));
        var rows=await QueryAsync("galaxy.resolve",address.ToString(System.Globalization.CultureInfo.InvariantCulture),token);
        if(rows.Count==0)throw new KeyNotFoundException("游戏银河目录尚未解析该星系地址。");
        if(rows.Count!=1||rows[0].SystemAddress!=address)throw new InvalidDataException("游戏地址查询返回了不同星系,已拒绝替换书签位置。");
        return rows[0];
    }
    public async Task<IReadOnlyList<GalacticSystem>> RegionAsync(Coordinates centre,double radius,CancellationToken token=default)
        => (await RegionPageAsync(centre,radius,token)).Systems;
    public Task<GalaxyRegionResult> RegionPageAsync(Coordinates centre,double radius,CancellationToken token=default)
    {
        if(!centre.IsValid||!double.IsFinite(radius)||radius is <.05 or >700)throw new ArgumentException("银河视野范围无效。");
        radius=Math.Max(radius,MinimumRegionRadius);
        return RegionRequestAsync(centre,radius,new{op="galaxy.region",centre=new[]{centre.X,centre.Y,centre.Z},radius},null,token);
    }
    /// <summary>Read complete canonical cells selected by the original eight-mass descriptor, with no point-distance cut.</summary>
    public Task<GalaxyRegionResult> CellsAsync(Coordinates centre,IReadOnlyList<double> massRadii,IReadOnlyList<ulong> cellAddresses,CancellationToken token=default)
    {
        if(protocolInspected&&!SupportsCells)throw new NotSupportedException("当前游戏插件尚不支持原质量层星区查询。");
        if(centre is not{IsValid:true}||Math.Max(Math.Abs(centre.X),Math.Max(Math.Abs(centre.Y),Math.Abs(centre.Z)))>200000||
           massRadii is not{Count:8}||massRadii.Any(r=>!double.IsFinite(r)||r<0||r>200000)||cellAddresses is not{Count:>=1 and <=512})
            throw new ArgumentException("原质量层星区查询范围无效。");
        var ids=cellAddresses.ToArray();var radii=massRadii.ToArray();
        if(ids.Any(id=>id>=1UL<<55||GalaxyCellQueryPlan.CellAddress(id)!=id)||ids.Distinct().Count()!=ids.Length)
            throw new ArgumentException("原星区编号无效或重复。",nameof(cellAddresses));
        var request=new{op="galaxy.cells",centre=new[]{centre.X,centre.Y,centre.Z},radii,
            cellAddresses=ids.Select(id=>id.ToString(System.Globalization.CultureInfo.InvariantCulture)).ToArray()};
        return RegionRequestAsync(centre,radii.Max(),request,ids,token);
    }
    private async Task<GalaxyRegionResult> RegionRequestAsync(Coordinates centre,double radius,object request,IReadOnlyList<ulong>? requestedCells,CancellationToken token)
    {
        await queries.WaitAsync(token);
        long generation=0;
        using var deadline=new CancellationTokenSource(regionTimeout??TimeSpan.FromSeconds(requestedCells?.Count==1?95:35));
        try
        {
            token.ThrowIfCancellationRequested();
            // Receive the generation even if the view changes during acceptance, so
            // cancellation can never leave an anonymous job occupying the game queue.
            var result=await pipe.CallAsync(request,deadline.Token);
            generation=result.GetProperty("generation").GetInt64();
            var cellSet=requestedCells?.ToHashSet();
            Coordinates? cellOrigin=null;var cellIdentities=new Dictionary<long,GalacticSystem>();
            var rows=new List<GalacticSystem>();
            var pending=new List<GalacticSystem>(512);
            var published=new HashSet<long>();
            var lastPublished=Stopwatch.GetTimestamp();var firstPublish=true;
            var previousTotal=0;var emptyPages=0;var requestedPage=false;int? acceptedRowLimit=null;
            while(true)
            {
                token.ThrowIfCancellationRequested();
                if(result.GetProperty("generation").GetInt64()!=generation)throw new InvalidDataException("游戏星区查询回执已变化。");
                var nativeDone=result.GetProperty("done").GetBoolean();
                var nativeError=result.TryGetProperty("error",out var errorValue)&&errorValue.ValueKind==JsonValueKind.String?errorValue.GetString()??"":"";
                var available=result.TryGetProperty("total",out var totalValue)?totalValue.GetInt32():rows.Count;
                var receipt=ReadCoverage(result,[],available);
                if(requestedCells is null&&receipt.RowLimit!=GalaxyRegionResult.NativeRowLimit||acceptedRowLimit is{} previousLimit&&receipt.RowLimit!=previousLimit)
                    throw new InvalidDataException("游戏星区分页上限在查询中发生变化。");
                acceptedRowLimit=receipt.RowLimit;
                if(available<0||available>receipt.RowLimit||available<previousTotal)throw new InvalidDataException("游戏星区分页数量回退或超出限制。");
                previousTotal=available;
                if(requestedCells is not null&&(receipt.Scope!="requested-cells"||receipt.RequestedCells!=requestedCells.Count||
                   receipt.TotalCells!=requestedCells.Count||receipt.MinimumMassCode!=requestedCells.Min(id=>(int)(id&7))))
                    throw new InvalidDataException("游戏返回的原星区查询范围已变化。");
                // A completed, started region can retain actual rows after a
                // failed cell/timeout. Return its failed coverage for refinement.
                // An unavailable service with no started cells is retryable UI failure.
                if(nativeError.Length>0&&(!nativeDone||!receipt.HasCoverageEvidence||receipt.TotalCells<=0))throw new IOException(nativeError);
                var page=ReadRows(result);
                if(cellSet is not null)
                {
                    if((page.Count>0||cellOrigin is not null)&&receipt.Origin is null)throw new InvalidDataException("游戏原星区数据缺少真实坐标原点。");
                    if(receipt.Origin is{} origin)
                    {
                        if(cellOrigin is{} previous&&(previous.X!=origin.X||previous.Y!=origin.Y||previous.Z!=origin.Z))
                            throw new InvalidDataException("游戏原星区分页坐标原点已变化。");
                        cellOrigin=origin;
                    }
                    foreach(var star in page)
                    {
                        if(star.SystemAddress is not{} address)continue;
                        if(cellIdentities.TryGetValue(address,out var previous)&&
                           (previous.Name!=star.Name||previous.Position.X!=star.Position.X||previous.Position.Y!=star.Position.Y||previous.Position.Z!=star.Position.Z))
                            throw new InvalidDataException("游戏原星区分页返回了冲突的星系身份。");
                        cellIdentities[address]=star;
                    }
                }
                if(cellSet is not null&&page.Any(star=>star.SystemAddress is not{} address||
                   !cellSet.Contains(GalaxyCellQueryPlan.CellAddress((ulong)address))))
                    throw new InvalidDataException("游戏返回了未请求星区内的恒星。");
                if(rows.Count+page.Count>available)throw new InvalidDataException("游戏星区分页返回数量超出回执。");
                if(page.Count==0&&requestedPage&&rows.Count<available)
                {
                    // Accept a metadata-only legacy reply once, then require
                    // the requested stable append page to make progress.
                    if(++emptyPages>1)throw new InvalidDataException("游戏星区分页返回数量不完整。");
                }
                else if(page.Count>0)emptyPages=0;
                rows.AddRange(page);
                foreach(var star in page)if(star.SystemAddress is {} address&&published.Add(address))pending.Add(star);
                var finished=nativeDone&&rows.Count==available;
                var coverage=receipt with{Complete=finished&&nativeError.Length==0&&receipt.Complete};
                var publishedFinished=false;
                var elapsed=Stopwatch.GetElapsedTime(lastPublished).TotalMilliseconds;
                if(pending.Count>0&&(firstPublish||finished||pending.Count>=512||elapsed>=100))
                {
                    while(pending.Count>0)
                    {
                        token.ThrowIfCancellationRequested();
                        var count=Math.Min(512,pending.Count);var delta=pending.GetRange(0,count).ToArray();pending.RemoveRange(0,count);
                        RegionProgress?.Invoke(new(generation,centre,radius,delta,coverage,finished&&pending.Count==0,available,rows.Count));
                        publishedFinished=finished&&pending.Count==0;
                        // Keep intermediate batches bounded without publishing
                        // every 96-row pipe page into the UI.
                        if(!finished&&pending.Count<512)break;
                    }
                    firstPublish=false;lastPublished=Stopwatch.GetTimestamp();
                }
                if(finished)
                {
                    token.ThrowIfCancellationRequested();
                    if(!publishedFinished)RegionProgress?.Invoke(new(generation,centre,radius,[],coverage,true,available,rows.Count));
                    return ReadCoverage(result,rows.DistinctBy(s=>s.SystemAddress).ToArray(),available);
                }
                using var pageStop=CancellationTokenSource.CreateLinkedTokenSource(token,deadline.Token);
                if(rows.Count==available)await Task.Delay(30,pageStop.Token);
                result=await pipe.CallAsync(new{op="galaxy.result",generation,offset=rows.Count,limit=96},pageStop.Token);
                requestedPage=true;
            }
        }
        catch(OperationCanceledException)
        {
            using var cleanup=new CancellationTokenSource(TimeSpan.FromSeconds(protocolInspected&&!SupportsCancellation?35:2));
            if(generation!=0)await RetireCancelledQueryAsync(generation,cleanup.Token);
            if(token.IsCancellationRequested)throw;
            throw new TimeoutException("游戏星区查询超时;已保留收到的星系,请移动或缩放后重试。");
        }
        catch
        {
            using var cleanup=new CancellationTokenSource(TimeSpan.FromSeconds(2));
            if(generation!=0)await RetireCancelledQueryAsync(generation,cleanup.Token);
            throw;
        }
        finally{queries.Release();}
    }
    public static GalaxyRegionResult ReadCoverage(JsonElement result,IReadOnlyList<GalacticSystem> rows,int? returnedCount=null)
    {
        int Number(string name)=>result.TryGetProperty(name,out var value)&&value.ValueKind==JsonValueKind.Number&&value.TryGetInt32(out var number)?number:-1;
        bool Flag(string name)=>result.TryGetProperty(name,out var value)&&value.ValueKind==JsonValueKind.True;
        var mass=Number("minimumMassCode");var processed=Number("processedCells");var total=Number("totalCells");var failed=Number("failedCells");
        if(mass is < -1 or >7||processed< -1||total< -1||processed>total&&total>=0||failed< -1)
            throw new InvalidDataException("游戏星区覆盖回执无效。");
        var error=result.TryGetProperty("error",out var errorValue)&&errorValue.ValueKind==JsonValueKind.String?errorValue.GetString()??"":"";
        var scope=result.TryGetProperty("scope",out var scopeValue)&&scopeValue.ValueKind==JsonValueKind.String?scopeValue.GetString()??"":"";
        var requested=Number("requestedCells");
        if(scope is not("" or "region-sphere" or "requested-cells")||requested is < -1 or >512||
           scope=="requested-cells"&&(requested<1||total!=requested))throw new InvalidDataException("游戏原星区覆盖范围无效。");
        var rowLimit=GalaxyRegionResult.NativeRowLimit;
        if(result.TryGetProperty("rowLimit",out var rowLimitValue)&&
           (rowLimitValue.ValueKind!=JsonValueKind.Number||!rowLimitValue.TryGetInt32(out rowLimit)))
            throw new InvalidDataException("游戏原星区分页上限无效。");
        if(rowLimit!=GalaxyRegionResult.NativeRowLimit&&!(rowLimit==GalaxyRegionResult.NativeSingleCellRowLimit&&scope=="requested-cells"&&requested==1))
            throw new InvalidDataException("游戏原星区分页上限无效。");
        var capReached=(returnedCount??rows.Count)>=rowLimit&&rowLimit==GalaxyRegionResult.NativeRowLimit;
        Coordinates? origin=null;
        if(result.TryGetProperty("origin",out var originValue)&&originValue.ValueKind!=JsonValueKind.Null)
        {
            if(originValue.ValueKind!=JsonValueKind.Array||originValue.GetArrayLength()!=3)throw new InvalidDataException("游戏银河坐标原点无效。");
            origin=new(originValue[0].GetDouble(),originValue[1].GetDouble(),originValue[2].GetDouble());
            if(!origin.IsValid)throw new InvalidDataException("游戏银河坐标原点无效。");
        }
        return new(rows,mass,processed,total,Flag("truncated")||capReached,
            Flag("complete")&&error.Length==0,Math.Max(error.Length>0?1:0,failed)){Error=error,Scope=scope,RequestedCells=requested,Origin=origin,RowLimit=rowLimit};
    }
    private async Task RetireCancelledQueryAsync(long generation,CancellationToken deadline)
    {
        // Older plugins reject galaxy.cancel. Drain that one accepted job rather
        // than poisoning the next request with 'query already pending'. The new
        // protocol releases it on the owning game thread at the next sample.
        try{if(!protocolInspected||SupportsCancellation)await pipe.CallAsync(new{op="galaxy.cancel",generation},deadline);}catch(InvalidOperationException){}
        catch(OperationCanceledException){return;}catch(IOException){return;}
        try
        {
            while(!deadline.IsCancellationRequested)
            {
                var result=await pipe.CallAsync(new{op="galaxy.result",generation,limit=0},deadline);
                if(result.GetProperty("generation").GetInt64()!=generation||result.GetProperty("done").GetBoolean())return;
                await Task.Delay(30,deadline);
            }
        }
        catch(OperationCanceledException){}catch(InvalidOperationException){}catch(IOException){}
    }
    private static IReadOnlyList<GalacticSystem> ReadRows(System.Text.Json.JsonElement result)
    {
        var rows=new List<GalacticSystem>();
        foreach(var item in result.GetProperty("items").EnumerateArray())
        {
            if(!long.TryParse(item.GetProperty("systemAddress").GetString(),out var address)||address<=0||address>=1L<<55)throw new InvalidDataException("游戏星系地址无效。");
            var p=item.GetProperty("position");var point=new Coordinates(p[0].GetDouble(),p[1].GetDouble(),p[2].GetDouble());
            var name=item.GetProperty("name").GetString()??"";
            if(!point.IsValid||name.Length is <1 or >159)throw new InvalidDataException("游戏星系名称或坐标无效。");
            GalaxyOriginalStellarAppearance? appearance=null;
            if(item.TryGetProperty("originalAppearance",out var a)&&a.ValueKind!=JsonValueKind.Null)
            {
                if(a.ValueKind!=JsonValueKind.Object)throw new InvalidDataException("原恒星外观字段无效。");
                var type=a.GetProperty("typeRgba").GetUInt32();uint? realistic=a.GetProperty("realisticRgba").ValueKind==JsonValueKind.Null?null:a.GetProperty("realisticRgba").GetUInt32();
                float? blend=a.GetProperty("colourBlend").ValueKind==JsonValueKind.Null?null:a.GetProperty("colourBlend").GetSingle();
                var raw=a.GetProperty("rawScale").GetUInt32();var classification=a.GetProperty("classification").GetUInt16();
                var size=a.GetProperty("size").GetSingle();var small=a.GetProperty("smallSize").GetSingle();var setting=a.GetProperty("smallSizeActive");
                bool? active=setting.ValueKind==JsonValueKind.Null?null:setting.ValueKind is JsonValueKind.True or JsonValueKind.False?setting.GetBoolean():throw new InvalidDataException("原恒星尺寸设置无效。");
                if(!float.IsFinite(size)||!float.IsFinite(small)||size is <0 or >4||small is <0 or >4||blend is {} value&&(!float.IsFinite(value)||value is <0 or >1))throw new InvalidDataException("原恒星外观数值无效。");
                bool? membership=null;int? membershipMode=null;int? routeView=null;
                if(a.TryGetProperty("navRouteMembership",out var member)&&member.ValueKind!=JsonValueKind.Null)
                {if(member.ValueKind is not(JsonValueKind.True or JsonValueKind.False))throw new InvalidDataException("原导航成员标记无效。");membership=member.GetBoolean();}
                if(a.TryGetProperty("navRouteMembershipMode",out var rm)&&rm.ValueKind!=JsonValueKind.Null)
                {if(!rm.TryGetInt32(out var routeMode)||routeMode is <0 or >1)throw new InvalidDataException("原导航成员模式无效。");membershipMode=routeMode;}
                if(a.TryGetProperty("navRouteViewMode",out var rv)&&rv.ValueKind!=JsonValueKind.Null)
                {if(!rv.TryGetInt32(out var routeViewValue))throw new InvalidDataException("原导航视图模式无效。");routeView=routeViewValue;}
                if(membership is not null&&(membershipMode is null||routeView is null)||routeView is{} originalRouteView&&membershipMode is{} originalMemberMode&&(originalRouteView==1?1:0)!=originalMemberMode)
                    throw new InvalidDataException("原导航成员上下文不匹配。");
                appearance=new(type,realistic,blend,raw,classification,size,small,active)
                    {NavRouteMembership=membership,NavRouteMembershipMode=membershipMode,NavRouteViewMode=routeView};
            }
            rows.Add(new(name,point,address,item.GetProperty("starClass").GetString()??"","游戏银河目录"){OriginalAppearance=appearance});
        }
        return rows;
    }
    private async Task<IReadOnlyList<GalacticSystem>> QueryAsync(string operation,string query,CancellationToken token)
    {
        query=query.Trim();if(query.Length is <1 or >128)throw new ArgumentException("星系名称长度无效。");
        await queries.WaitAsync(token);
        long generation=0;
        using var deadline=new CancellationTokenSource(TimeSpan.FromSeconds(12));
        try
        {
            token.ThrowIfCancellationRequested();
            // Keep acceptance independent so a changed search term can retire
            // its own generation instead of leaving an anonymous native job.
            var result=operation=="galaxy.resolve"
                ?await pipe.CallAsync(new{op=operation,systemAddress=query},deadline.Token)
                :await pipe.CallAsync(new{op=operation,query},deadline.Token);
            generation=result.GetProperty("generation").GetInt64();
            while(!result.GetProperty("done").GetBoolean())
            {
                token.ThrowIfCancellationRequested();
                using var pollStop=CancellationTokenSource.CreateLinkedTokenSource(token,deadline.Token);
                await Task.Delay(30,pollStop.Token);
                result=await pipe.CallAsync(new{op="galaxy.result",generation},pollStop.Token);
                if(result.GetProperty("generation").GetInt64()!=generation)throw new InvalidDataException("游戏星系查询回执已变化。");
            }
            token.ThrowIfCancellationRequested();
            if(result.GetProperty("error").GetString() is {Length:>0} error)throw new IOException(error);
            return ReadRows(result).Take(20).DistinctBy(s=>s.SystemAddress).ToArray();
        }
        catch(OperationCanceledException)
        {
            using var cleanup=new CancellationTokenSource(TimeSpan.FromSeconds(protocolInspected&&!SupportsCancellation?12:2));
            if(generation!=0)await RetireCancelledQueryAsync(generation,cleanup.Token);
            if(token.IsCancellationRequested)throw;
            throw new TimeoutException("游戏星系查询超时,请重新搜索。");
        }
        finally{queries.Release();}
    }
}