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();}
}
}
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();}
}
}