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

namespace XFEEliteDangerous.Core;

public sealed class StateStore : IAsyncDisposable
{
    private readonly SemaphoreSlim gate = new(1, 1);
    private readonly string path;
    private readonly string lockPath;
    private int disposed;
    public string DirectoryPath { get; }
    public StateStore(string directory)
    {
        DirectoryPath = XFEEliteDangerous.Client.LocalPaths.Directory(directory);
        Directory.CreateDirectory(DirectoryPath);
        path = Path.Combine(DirectoryPath, "state.json");
        lockPath = XFEEliteDangerous.Client.LocalPaths.FilePath(Path.Combine(DirectoryPath, "workspace.lock"));
        using var lease = AcquireFileLeaseAsync().GetAwaiter().GetResult();
        _ = Load();
    }
    private DatabaseState Load()
    {
            var state = File.Exists(path) ? JsonSerializer.Deserialize<DatabaseState>(File.ReadAllText(XFEEliteDangerous.Client.LocalPaths.FilePath(path)), Wire.Json) ?? throw new InvalidDataException("Empty saved state.") : new();
            if (state.Schema != 1) throw new InvalidDataException("Unsupported state schema.");
            // Missing role on older schema-1 records retains AccountState's player initializer.
            if (state.Accounts.Values.Any(account => !AccountRoles.IsValid(account.Role)))
                throw new InvalidDataException("Invalid saved account role.");
            if (state.Accounts.Values.Where(account => account.NativeUserId != 0)
                .GroupBy(account => account.NativeUserId).Any(group => group.Count() > 1))
                throw new InvalidDataException("Duplicate saved private native user identity.");
            if (state.Accounts.Values.Any(account => account.NativeTutorial is null
                || account.NativeTutorial.Flight is null || account.NativeTutorial.OnFoot is null))
                throw new InvalidDataException("Invalid saved native tutorial state.");
            if(state.Accounts.Values.Any(account=>account.NativeArx is null||account.NativeArx.TotalBalance<0))
                throw new InvalidDataException("Invalid saved private ARX ledger.");
            if(state.Accounts.Values.Any(account=>account.NativePreferences is null||account.NativePreferences.PrivacyMode is not("PRIVATE" or "SQUADRON")))
                throw new InvalidDataException("Invalid saved native commander preferences.");
            if(state.Accounts.Values.Any(account=>!NativeBookmarkData.Valid(account.NativeBookmarks)))
                throw new InvalidDataException("Invalid saved native bookmarks.");
            try
            {
                if(state.NativeResources is null||state.NativeResourceRevision<0)throw new ArgumentException("Invalid native resource state.");
                state.NativeResources.Validate();
            }
            catch(ArgumentException error){throw new InvalidDataException("Invalid saved native resource configuration.",error);}
            foreach (var account in state.Accounts.Values.Where(account => account.NativeSave is not null))
                try { account.NativeSave!.ValidateCurrent(account); }
                catch (Exception error) when (error is ArgumentException or InvalidOperationException or OverflowException)
                { throw new InvalidDataException("Invalid saved native commander state.", error); }
            return state;
    }
    public async Task<T> Read<T>(Func<DatabaseState, T> operation)
    {
        await gate.WaitAsync().ConfigureAwait(false);
        try
        {
            ObjectDisposedException.ThrowIf(Volatile.Read(ref disposed) != 0, this);
            using var lease = await AcquireFileLeaseAsync().ConfigureAwait(false);
            return operation(Load());
        }
        finally { gate.Release(); }
    }
    public async Task<T> Update<T>(Func<DatabaseState, T> operation)
    {
        await gate.WaitAsync().ConfigureAwait(false);
        try
        {
            ObjectDisposedException.ThrowIf(Volatile.Read(ref disposed) != 0, this);
            using var lease = await AcquireFileLeaseAsync().ConfigureAwait(false);
            var next = Load();
            var previous = JsonSerializer.Serialize(next, Wire.Json);
            var result = operation(next);
            var encoded = JsonSerializer.SerializeToUtf8Bytes(next, Wire.Json);
            if (System.Text.Encoding.UTF8.GetString(encoded) == previous) return result;
            var temporary = path + "." + Wire.Id() + ".tmp";
            try
            {
                using (var file = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None)) { file.Write(encoded); file.Flush(flushToDisk: true); }
                try
                {
                    if (File.Exists(path)) File.Replace(temporary, path, path + ".bak");
                    else File.Move(temporary, path);
                }
                catch(Exception error)when(error is IOException or UnauthorizedAccessException)
                {
                    XFEEliteDangerous.Client.OfflineDiagnostics.Current?.Write("error","save.store","save.atomic.failed",
                        "The flushed state candidate could not atomically replace the previous file.",new Dictionary<string,string?>
                        {["targetPath"]=path,["candidatePath"]=temporary,["backupPath"]=path+".bak",
                            ["targetExists"]=File.Exists(path).ToString()},error);
                    throw;
                }
            }
            finally { if (File.Exists(temporary)) File.Delete(temporary); }
            return result;
        }
        finally { gate.Release(); }
    }
    public string Backup(CommanderState commander)
    {
        var directory = Path.Combine(DirectoryPath, "inventory-backups");
        Directory.CreateDirectory(directory);
        var name = $"{commander.Id}-{DateTime.UtcNow:yyyyMMdd-HHmmss}-{Wire.Id()}.json";
        using var file = new FileStream(Path.Combine(directory, name), FileMode.CreateNew, FileAccess.Write, FileShare.None);
        JsonSerializer.Serialize(file, commander.Public(), Wire.Json); file.Flush(true);
        return name;
    }
    public string BackupNative(AccountState account)
    {
        var directory = Path.Combine(DirectoryPath, "inventory-backups");
        Directory.CreateDirectory(directory);
        var name = $"{account.Commander.Id}-native-{DateTime.UtcNow:yyyyMMdd-HHmmss}-{Wire.Id()}.json";
        using var file = new FileStream(Path.Combine(directory, name), FileMode.CreateNew, FileAccess.Write, FileShare.None);
        JsonSerializer.Serialize(file, new { commander = account.Commander.Public(), nativeSave = account.NativeSave?.ExportCurrent(account),
            nativeTutorial = NativeTutorialSelection.FromProfile(account.NativeTutorial) }, Wire.Json);
        file.Flush(true);
        return name;
    }
    private async Task<FileStream> AcquireFileLeaseAsync()
    {
        var started = System.Diagnostics.Stopwatch.GetTimestamp();
        while (true)
        {
            try { return new FileStream(lockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); }
            catch (IOException error) when ((error.HResult & 0xFFFF) is 32 or 33 && System.Diagnostics.Stopwatch.GetElapsedTime(started) < TimeSpan.FromSeconds(10))
            { await Task.Delay(15).ConfigureAwait(false); }
        }
    }
    public async ValueTask DisposeAsync()
    {
        Interlocked.Exchange(ref disposed, 1);
        await gate.WaitAsync().ConfigureAwait(false);
        // Queued calls acquire the gate and observe disposed; do not destroy it underneath them.
        gate.Release();
    }
}