using System; using System.IO; using System.Security; using System.Text; using Newtonsoft.Json; namespace game.PlayerDataStorage { // Internal seam for deterministic disk-failure tests; no production callback is supplied. internal enum KVCacheWriteStage { BeforeWrite, AfterWrite, AfterFlush, BeforeReplace, AfterReplace } internal sealed class PlayerKVCacheStore : IDisposable { const long MaxBytes = 64L * 1024 * 1024; readonly KVIdentity identity; readonly FileStream lease; readonly Action fault; readonly string main, temporary, backup; bool disposed; PlayerKVCacheStore(KVIdentity identity, FileStream lease, Action fault) { this.identity = identity; this.lease = lease; this.fault = fault; main = Path.Combine(identity.DirectoryPath, "state.json"); temporary = main + ".tmp"; backup = main + ".bak"; } internal static PlayerKVOpenError Open(KVIdentity identity, Action fault, out PlayerKVCacheStore store, out KVSnapshot snapshot) { store = null; snapshot = null; FileStream lease = null; try { Directory.CreateDirectory(identity.DirectoryPath); try { lease = new FileStream(Path.Combine(identity.DirectoryPath, "state.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); } catch (IOException ex) when ((ex.HResult & 0xffff) == 32 || (ex.HResult & 0xffff) == 33) { return PlayerKVOpenError.Busy; } store = new PlayerKVCacheStore(identity, lease, fault); if (!File.Exists(store.main)) { if (File.Exists(store.backup) || File.Exists(store.temporary)) throw KVSnapshot.Invalid(); snapshot = new KVSnapshot(); } else { if (new FileInfo(store.main).Length > MaxBytes) throw KVSnapshot.Invalid(); snapshot = KVSnapshot.Decode(File.ReadAllText(store.main, new UTF8Encoding(false, true)), identity); } return PlayerKVOpenError.None; } catch (Exception ex) when (ex is JsonException || ex is OverflowException || ex is FormatException || ex is DecoderFallbackException) { store?.Dispose(); lease?.Dispose(); store = null; return PlayerKVOpenError.RecoveryRequired; } catch (Exception ex) when (DiskError(ex)) { store?.Dispose(); lease?.Dispose(); store = null; return PlayerKVOpenError.StorageError; } } internal bool Save(KVSnapshot snapshot, out bool mayHaveCommitted) { mayHaveCommitted = false; try { if (disposed) throw new ObjectDisposedException(nameof(PlayerKVCacheStore)); snapshot.Validate(); var bytes = new UTF8Encoding(false, true).GetBytes(snapshot.Encode(identity)); if (bytes.LongLength > MaxBytes) throw new IOException("KV cache capacity exceeded."); fault?.Invoke(KVCacheWriteStage.BeforeWrite); using (var stream = new FileStream(temporary, FileMode.Create, FileAccess.Write, FileShare.None)) { stream.Write(bytes, 0, bytes.Length); fault?.Invoke(KVCacheWriteStage.AfterWrite); stream.Flush(true); fault?.Invoke(KVCacheWriteStage.AfterFlush); } fault?.Invoke(KVCacheWriteStage.BeforeReplace); mayHaveCommitted = true; // The temp file is on the same filesystem. Never delete the main file first. if (File.Exists(main)) File.Replace(temporary, main, backup); else File.Move(temporary, main); fault?.Invoke(KVCacheWriteStage.AfterReplace); mayHaveCommitted = false; return true; } catch (Exception ex) when (DiskError(ex) || ex is JsonException || ex is EncoderFallbackException) { return false; } } static bool DiskError(Exception ex) => ex is IOException || ex is UnauthorizedAccessException || ex is SecurityException || ex is NotSupportedException || ex is ObjectDisposedException; public void Dispose() { if (disposed) return; disposed = true; lease.Dispose(); // Keep files, including failed temporary writes, for explicit recovery diagnosis. } } }