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> writes = Channel.CreateUnbounded>(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? 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 Activity => logs.Snapshot(); public event Action? Changed; public event Action? DataChanged; private void Notify(RuntimeChange change) { Changed?.Invoke(); DataChanged?.Invoke(change); } public event Action? RunChanged; public event Action? 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 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 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 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();} } }