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

XFEEDToolBox

【WinUI】XFE精英危险工具箱

公开
关注 0 Fork 0 Star 0
UTF-8
using System.Collections.Concurrent;
using System.Threading.Channels;
using XFEEDToolBox.Core.Data;
using XFEEDToolBox.Core.Execution;
using XFEEDToolBox.Core.Models;

namespace XFEEDToolBox.Core.Services;

[Flags]
public enum RuntimeChange { None=0, Player=1, Markets=2, Activity=4, Connection=8, GameEvents=16, All=31 }

public sealed class ToolboxRuntime : IAsyncDisposable
{
    private readonly Channel<Func<Task>> writes = Channel.CreateUnbounded<Func<Task>>(new() { SingleReader = true });
    private readonly CancellationTokenSource stop = new();
    private readonly SemaphoreSlim startupGate=new(1,1);
    private readonly SemaphoreSlim marketWritesGate=new(1,1);
    private int disposing,marketInitializationCompleted;
    private bool explicitMarketValidation;
    private readonly ActivityLog logs;
    private readonly HttpClient http = new() { Timeout = TimeSpan.FromSeconds(25) };
    private Task? writer;
    public string DataDirectory { get; }
    public string SettingsPath => Path.Combine(DataDirectory, "settings.json");
    public ToolboxSettings Settings { get; private set; }
    public MarketRepository Markets { get; }
    public Func<string?>? ClearMarketCacheBlockReason {get;set;}
    public GameEventRepository GameEvents{get;}
    public JournalMonitor Journal { get; private set; }
    public EddnSubscriber? Eddn { get; private set; }
    public SpanshImporter Spansh { get; }
    public TradeRunner Runner { get; }
    public XFEEDToolBox.Core.Navigation.FlightAutomationCoordinator FlightAutomation { get; }
    public XFEEDToolBox.Core.Navigation.NavigationRunner Navigator=>FlightAutomation.Interstellar;
    // Bound by a build-specific native adapter after its calibration and capability checks succeed.
    public XFEEDToolBox.Core.Navigation.IFlightControlDriver? FlightDriver { get; internal set; }
    public async Task BindFlightDriverAsync(XFEEDToolBox.Core.Navigation.IFlightControlDriver driver,CancellationToken token=default)
    {
        if(FlightAutomation.IsActive)throw new InvalidOperationException("自动驾驶运行期间不能更换原生驱动。");
        var capabilities=await driver.PreflightAsync(token);
        if(!capabilities.Ready)throw new InvalidOperationException("等待游戏进入可读取姿态与热量的飞行状态。");
        FlightDriver=driver;
    }
    public void UnbindFlightDriver(){FlightAutomation.Stop();Navigator.Stop();FlightDriver=null;}
    private NativeWalletObservation? nativeWallet;
    public PlayerState Player => Volatile.Read(ref nativeWallet)?.Project(Journal.State)??Journal.State;
    public void ObserveNativeWallet(NativeWalletObservation observation)
    {
        if(ReferenceEquals(observation.Project(Journal.State),Journal.State))return;
        var previous=Volatile.Read(ref nativeWallet);if(previous?.ObservedAt>observation.ObservedAt)return;
        Volatile.Write(ref nativeWallet,observation);Notify(RuntimeChange.Player);
    }
    public IReadOnlyList<ActivityEntry> Activity => logs.Snapshot();
    public event Action? Changed;
    public event Action<RuntimeChange>? DataChanged;
    private void Notify(RuntimeChange change) { Changed?.Invoke(); DataChanged?.Invoke(change); }
    public event Action<RunProgress>? RunChanged;
    public event Action<ObservedJournalEvent,PlayerState>? LiveJournalEvent;

    public ToolboxRuntime(string dataDirectory)
    {
        DataDirectory = dataDirectory;
        Directory.CreateDirectory(dataDirectory);
        logs=new(persist:PersistActivity);
        try { Settings = ToolboxSettings.Load(SettingsPath); }
        catch (Exception e) when (e is System.Text.Json.JsonException or IOException)
        { Settings = new(); Log("设置", "设置文件读取失败,使用默认设置。"); }
        Markets = new(Path.Combine(dataDirectory, "markets.db"));
        Markets.PublicDataFilter=ShouldCachePublicStation;
        GameEvents=new(Path.Combine(dataDirectory,"game-events.db"));
        Journal = new(Settings.JournalDirectory);
        Spansh = new(Markets, http);
        Runner = new(() => Player, (market, key) => Markets.Search(key, market, 0, 1000).FirstOrDefault(x => x.Quote.Commodity == key)?.Quote, Path.Combine(dataDirectory,"trade-ledgers"));
        FlightAutomation=new(()=>Player);
        FlightAutomation.StopRecorded+=record=>
        {
            // The in-memory cause exists before cancellation. Persist on the
            // writer so disk I/O cannot delay an emergency input release.
            writes.Writer.TryWrite(()=>
            {
                ReportDiagnostic("flight.stop:"+record.RunId,"驾驶停止",
                    record.ManualVeto?"已按停止请求释放自动驾驶。":record.Cause is {} cause?"驾驶暂停:"+cause.Message:"自动驾驶已停止。",
                    System.Text.Json.JsonSerializer.Serialize(new
                    {
                        stop=record with{Cause=null},causeType=record.Cause?.GetType().FullName,causeMessage=record.Cause?.Message
                    },ToolboxSettings.JsonOptions),record.ManualVeto?ActivitySeverity.Status:
                        record.Cause is {} failure&&XFEEDToolBox.Core.Navigation.NavigationRecovery.HasInvalidDataCause(failure)?ActivitySeverity.Error:
                        record.Cause is IOException?ActivitySeverity.Status:ActivitySeverity.Warning);
                return Task.CompletedTask;
            });
        };
        Runner.Progress += p => { Log("执行", p.Message); RunChanged?.Invoke(p); };
    }

    public async Task InitializeAsync(CancellationToken token = default,bool offline=false,bool validateMarketCache=false)
    {
        if(Volatile.Read(ref disposing)!=0)return;
        // Startup only opens the existing schema. Full integrity/hash/backup
        // checks remain explicit maintenance operations, never a UI prerequisite.
        explicitMarketValidation=validateMarketCache;
        try { await Task.Run(validateMarketCache?Markets.Initialize:Markets.InitializeCheap, token).ConfigureAwait(false);Volatile.Write(ref marketInitializationCompleted,1); }
        catch (Exception error) when (error is Microsoft.Data.Sqlite.SqliteException or InvalidDataException)
        {if(Volatile.Read(ref disposing)!=0)return;MarkMarketsUnavailable(error);}
        if(Volatile.Read(ref disposing)!=0)return;
        await startupGate.WaitAsync(token).ConfigureAwait(false);
        try
        {
            if(Volatile.Read(ref disposing)!=0)return;
            await Task.Run(GameEvents.Initialize,token).ConfigureAwait(false);
            if(Volatile.Read(ref disposing)!=0)return;
            if(GameEvents.LastRecovery is {} eventRecovery)Log("事件索引恢复",System.Text.Json.JsonSerializer.Serialize(eventRecovery));
            writer = Task.Run(async () =>
            {
                try
                {
                    await foreach (var write in writes.Reader.ReadAllAsync(stop.Token))
                        try { await write(); }
                        catch (Exception e) when (e is not OperationCanceledException) { ReportDiagnostic("runtime.data-write","数据错误",e,ActivitySeverity.Error); }
                }
                catch (OperationCanceledException) when (stop.IsCancellationRequested) { }
            }, CancellationToken.None);
            if(!offline)
            {
                await StartJournalAsync(token).ConfigureAwait(false);
                if(Volatile.Read(ref disposing)!=0)return;
                if (Settings.ConnectEddnOnStart) StartEddn();
                Log("系统", "本地 Journal 监听已启动;未修改游戏或系统网络设置。");
            }
        }
        finally{startupGate.Release();}
    }

    private async Task StartJournalAsync(CancellationToken token)
    {
        Journal.PlayerChanged += p => { Runner.ObservePlayer(p); Notify(RuntimeChange.Player); };
        Journal.JournalEvent += Runner.Observe;
        Journal.JournalEvent += observed=>LiveJournalEvent?.Invoke(observed,Journal.State);
        Journal.EventBatch+=batch=>writes.Writer.TryWrite(()=>{if(GameEvents.Insert(batch)>0)Notify(RuntimeChange.GameEvents);return Task.CompletedTask;});
        Journal.MarketChanged += market => writes.Writer.TryWrite(()=>WriteLocalMarketAsync(async()=>{if(await Markets.SaveSnapshotAsync(market,stop.Token))Notify(RuntimeChange.Markets);}));
        Journal.StationObserved += station => writes.Writer.TryWrite(()=>WriteLocalMarketAsync(async()=>{await Markets.SaveStationAsync(station,stop.Token,localObservation:true);Notify(RuntimeChange.Markets);}));
        Journal.Diagnostic += m => Log("Journal", m);
        await Task.Run(() => Journal.StartAsync(token), token).ConfigureAwait(false);
        // Flush writes from the initial replay before the first page queries the cache.
        var ready = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        writes.Writer.TryWrite(() => { ready.TrySetResult(); return Task.CompletedTask; });
        await ready.Task.WaitAsync(token).ConfigureAwait(false);
    }

    public void StartEddn()
    {
        if(Volatile.Read(ref disposing)!=0)return;
        if(Volatile.Read(ref marketInitializationCompleted)==0)return;
        if (Eddn != null) return;
        Eddn = new(Markets);
        Eddn.StatusChanged += s => { Log("EDDN", s); Notify(RuntimeChange.Connection); };
        Eddn.MarketUpdated += () => Notify(RuntimeChange.Markets | RuntimeChange.Connection);
        Eddn.CacheUnavailable += MarkMarketsUnavailable;
        Eddn.Start(Settings.EddnEndpoint);
    }
    public async Task StopEddnAsync()
    {
        if (Eddn == null) return;
        await Eddn.DisposeAsync().ConfigureAwait(false); Eddn = null; Notify(RuntimeChange.Connection);
    }

    private bool ShouldCachePublicStation(Station station)
    {
        var player=Player;
        var settings=Settings;
        bool Accepts(Station? known=null)=>NearbyMarketCachePolicy.Accepts(player,station,known,settings.MarketCacheCenterPosition,settings.MarketCacheRadiusLy,settings.MarketCacheCenterName);
        if(Accepts())return true;
        if((settings.MarketCacheCenterPosition??player.Position) is not{IsValid:true}||station.MarketId<=0)return false;
        return Accepts(Markets.GetStation(station.MarketId));
    }

    private void MarkMarketsUnavailable(Exception error)
    {
        Volatile.Write(ref marketInitializationCompleted,0);
        ReportDiagnostic("market.initialize","商品缓存",error,ActivitySeverity.Warning,"行情缓存暂不可用,请在设置中清理行情缓存。");
    }

    private async Task WriteLocalMarketAsync(Func<Task> write)
    {
        await marketWritesGate.WaitAsync(stop.Token).ConfigureAwait(false);
        try
        {
            if(Volatile.Read(ref marketInitializationCompleted)==0)return;
            try{await write().ConfigureAwait(false);}
            catch(Exception error)when(error is InvalidDataException||error is Microsoft.Data.Sqlite.SqliteException sql&&MarketCacheMaintenance.IsCorruption(sql))
            {MarkMarketsUnavailable(error);}
        }
        finally{marketWritesGate.Release();}
    }

    public Task<MarketCacheUsage> GetMarketCacheUsageAsync(CancellationToken token=default)
        =>Task.Run(Markets.GetCacheUsage,token);

    private string? MarketCacheBlockReason()=>Runner.State is RunState.Running or RunState.Paused
        ||FlightAutomation.IsActive
        ?"请先停止自动跑商或导航,再清理行情缓存。":ClearMarketCacheBlockReason?.Invoke();

    public async Task<MarketCacheClearResult> ClearMarketCacheAsync(CancellationToken token=default)
    {
        var reason=MarketCacheBlockReason();
        if(reason is not null)return new(false,reason,await GetMarketCacheUsageAsync(token).ConfigureAwait(false),0);
        await startupGate.WaitAsync(token).ConfigureAwait(false);
        try
        {
            reason=MarketCacheBlockReason();
            if(Volatile.Read(ref disposing)!=0||reason is not null)
                return new(false,reason??"工具箱正在关闭。",await GetMarketCacheUsageAsync(token).ConfigureAwait(false),0);
            await marketWritesGate.WaitAsync(token).ConfigureAwait(false);
            try
            {
                var resumeFeed=Eddn is not null;
                try
                {
                    await StopEddnAsync().ConfigureAwait(false);
                    reason=MarketCacheBlockReason();
                    if(reason is not null)return new(false,reason,await GetMarketCacheUsageAsync(token).ConfigureAwait(false),0);
                    var result=await Markets.ClearAsync(token).ConfigureAwait(false);
                    if(result.Succeeded)
                    {
                        Volatile.Write(ref marketInitializationCompleted,1);
                        ResolveDiagnostic("market.initialize","商品缓存",$"已清理行情缓存,将继续接收本地及所选范围 {Settings.MarketCacheRadiusLy:g} 光年内的行情。");
                        Notify(RuntimeChange.Markets);
                    }
                    return result;
                }
                finally{if(resumeFeed&&Volatile.Read(ref marketInitializationCompleted)!=0)StartEddn();}
            }
            finally{marketWritesGate.Release();}
        }
        finally{startupGate.Release();}
    }

    public async Task SaveSettingsAsync(ToolboxSettings updated)
    {
        if (Runner.State is RunState.Running or RunState.Paused) throw new InvalidOperationException("请先停止路线,再修改数据接入设置。");
        var journalChanged = updated.JournalDirectory != Settings.JournalDirectory;
        var endpointChanged = updated.EddnEndpoint != Settings.EddnEndpoint;
        updated.Save(SettingsPath);
        Settings = updated;
        if (journalChanged)
        {
            await Journal.DisposeAsync();
            Journal = new(updated.JournalDirectory);
            await StartJournalAsync(stop.Token);
        }
        if (endpointChanged && Eddn != null) { await StopEddnAsync(); StartEddn(); }
        Log("设置", "配置已保存。");
    }

    public void Log(string kind, string message)
    {
        // Preserve compatibility for old exception-string callers without
        // putting the same full stack on a new row every polling interval.
        if(message.Contains("\n   at ",StringComparison.Ordinal)||message.Contains("\n   在 ",StringComparison.Ordinal))
        {
            var summary=message.Split('\n',2)[0].TrimEnd('\r');
            logs.Report("exception:"+kind+":"+summary,kind,summary,ActivitySeverity.Error,message);
        }
        else logs.Append(kind,message);
        Notify(RuntimeChange.Activity);
    }

    public void ReportDiagnostic(string code,string kind,Exception error,ActivitySeverity severity=ActivitySeverity.Warning,string? summary=null)
    {
        logs.Report(code,kind,summary??error.Message,severity,error.ToString(),error.GetType().FullName);
        Notify(RuntimeChange.Activity);
    }
    public void ReportDiagnostic(string code,string kind,string summary,string diagnostic,ActivitySeverity severity=ActivitySeverity.Warning)
    {
        logs.Report(code,kind,summary,severity,diagnostic);
        Notify(RuntimeChange.Activity);
    }
    public void ReportStatus(string code,string kind,string message)
    {logs.Report(code,kind,message,ActivitySeverity.Status);Notify(RuntimeChange.Activity);}
    public void ResolveDiagnostic(string code,string kind,string message)
    {if(logs.Resolve(code,kind,message))Notify(RuntimeChange.Activity);}
    private void PersistActivity(ActivityEntry entry)
    {
        var directory=Path.Combine(DataDirectory,"logs");Directory.CreateDirectory(directory);
        var path=Path.Combine(directory,"activity.jsonl");
        if(File.Exists(path)&&new FileInfo(path).Length>5*1024*1024)
        {
            var oldest=path+".3";if(File.Exists(oldest))File.Delete(oldest);
            for(var index=2;index>=1;index--)if(File.Exists(path+"."+index))File.Move(path+"."+index,path+"."+(index+1),true);
            File.Move(path,path+".1",true);
        }
        File.AppendAllText(path,System.Text.Json.JsonSerializer.Serialize(entry)+Environment.NewLine);
    }
    private readonly object scoDiagnosticGate=new();
    private string lastScoDiagnostic="";
    public void RecordScoDiagnostic(System.Text.Json.JsonElement status)
    {
        if(Volatile.Read(ref disposing)!=0)return;
        static string Field(System.Text.Json.JsonElement row,string name)=>row.ValueKind==System.Text.Json.JsonValueKind.Object&&row.TryGetProperty(name,out var value)?value.ToString():"";
        var state=status.TryGetProperty("sample",out var sample)?Field(sample,"state"):"";
        var stopped=status.TryGetProperty("lastOriginalStop",out var stop)?Field(stop,"observedAtUnixMilliseconds"):"";
        var application=status.TryGetProperty("applicationControl",out var control)?control:default;
        var diagnostic=status.TryGetProperty("diagnostic",out var details)?details:default;
        var key=string.Join(':',Field(status,"processId"),Field(status,"service"),Field(status,"watchEntityId"),Field(status,"originalCommandSerial"),stopped,Field(status,"issue"),state,
            Field(application,"detail"),Field(application,"policyReason"),Field(status,"targetSystemAddress"),Field(diagnostic,"generation"));
        lock(scoDiagnosticGate){if(key==lastScoDiagnostic)return;lastScoDiagnostic=key;}
        var line=System.Text.Json.JsonSerializer.Serialize(new{at=DateTimeOffset.UtcNow,status=status.Clone()})+Environment.NewLine;
        writes.Writer.TryWrite(()=>
        {
            try
            {
                var directory=Path.Combine(DataDirectory,"logs");Directory.CreateDirectory(directory);var path=Path.Combine(directory,"sco-diagnostics.jsonl");
                if(File.Exists(path)&&new FileInfo(path).Length>2*1024*1024)
                {
                    for(var index=2;index>=1;index--)if(File.Exists(path+"."+index))File.Move(path+"."+index,path+"."+(index+1),true);
                    File.Move(path,path+".1",true);
                }
                File.AppendAllText(path,line);
            }
            catch(Exception error)when(error is IOException or UnauthorizedAccessException){/* Optional evidence cannot interrupt flight. */}
            return Task.CompletedTask;
        });
    }

    public async ValueTask DisposeAsync()
    {
        if(Interlocked.Exchange(ref disposing,1)!=0)return;
        await startupGate.WaitAsync().ConfigureAwait(false);
        try
        {
        Runner.Stop();FlightAutomation.Stop();Navigator.Stop();
        await Journal.DisposeAsync().ConfigureAwait(false);
        await StopEddnAsync().ConfigureAwait(false);
        writes.Writer.TryComplete();
        if (writer != null) await writer.ConfigureAwait(false);
        // No Journal/EDDN producer or queued write remains. Retry a seal that
        // was deferred by another reader, without blessing any foreign change.
        var sealedGeneration=false;
        for(var attempt=0;explicitMarketValidation&&Volatile.Read(ref marketInitializationCompleted)!=0&&attempt<3&&!sealedGeneration;attempt++)
        {
            if(attempt>0)await Task.Delay(100).ConfigureAwait(false);
            try{sealedGeneration=await Markets.FlushVerifiedGenerationAsync(completeFullValidation:false).ConfigureAwait(false);}
            catch(Exception error)when(error is IOException or UnauthorizedAccessException or Microsoft.Data.Sqlite.SqliteException or InvalidDataException)
            {MarketCacheMaintenance.Log(Markets.DatabasePath,"verified-generation-close-flush-deferred",error.Message);}
        }
        if(explicitMarketValidation&&!sealedGeneration)MarketCacheMaintenance.Log(Markets.DatabasePath,"verified-generation-close-unsealed",Volatile.Read(ref marketInitializationCompleted)==0
            ?"Market initialization has not completed; closing skips its busy gate and starts no data sources. No generation receipt was changed."
            :"No unchanged validated generation was sealed; normal startup only opens the cache schema and does not run a full validation.");
        logs.Flush();
        await stop.CancelAsync().ConfigureAwait(false); stop.Dispose(); http.Dispose();
        }
        finally{startupGate.Release();}
    }
}