using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.IO; using System.Linq; namespace game.PlayerDataStorage { /// /// Synchronous, fixed-identity cache state. No HTTP, gameplay initialization or timers. /// All transitions and disk writes share a gate. The phase-3B owner must drain its /// admitted network operations before disposing this state and its filesystem lease. /// public sealed class PlayerKVSyncState : IDisposable { readonly object gate = new object(); readonly PlayerKVCacheStore store; KVSnapshot snapshot; PlayerKVSyncStatus status; public PlayerKVSyncStatus Status { get { lock (gate) return status; } } public string ApiBaseUri { get; } public string PlayerId { get; } PlayerKVSyncState(KVIdentity identity, PlayerKVCacheStore store, KVSnapshot snapshot) { ApiBaseUri = identity.Api; PlayerId = identity.PlayerId; this.store = store; this.snapshot = snapshot; // Authentication belongs to the old owner. A new owner must obtain a fresh // successful Get before Ready; this never clears a persisted unknown batch. snapshot.AuthenticationInvalid = false; // A recovered active batch might have reached the server. Never acknowledge it by guessing. if (snapshot.Active != null) { snapshot.Unknown = snapshot.Active; snapshot.Active = null; } status = snapshot.Unknown != null ? PlayerKVSyncStatus.UnknownOutcome : PlayerKVSyncStatus.NotLoaded; } public static PlayerKVOpenResult Open(string root, Uri api, string playerId) => Open(root, api, playerId, null); internal static PlayerKVOpenResult Open(string root, Uri api, string playerId, Action fault) { KVIdentity identity; try { identity = new KVIdentity(root, api, playerId); } catch (Exception ex) when (ex is ArgumentException || ex is NotSupportedException || ex is PathTooLongException) { return new PlayerKVOpenResult(PlayerKVOpenError.Validation); } var error = PlayerKVCacheStore.Open(identity, fault, out var store, out var snapshot); return error == PlayerKVOpenError.None ? new PlayerKVOpenResult(error, new PlayerKVSyncState(identity, store, snapshot)) : new PlayerKVOpenResult(error); } public PlayerKVDiagnosticSnapshot Inspect() { lock (gate) return new PlayerKVDiagnosticSnapshot(snapshot); } public IReadOnlyDictionary ReadLocal() { lock (gate) { if (status != PlayerKVSyncStatus.Ready) throw new InvalidOperationException("KV data is not ready."); var result = new Dictionary(snapshot.Baseline, StringComparer.Ordinal); foreach (var item in snapshot.Pending.Values) Apply(result, item.Key, item.Target); foreach (var pair in snapshot.LocalOnly) result[pair.Key] = pair.Value; return new ReadOnlyDictionary(result); } } // The caller must supply a successful complete Get. Failed reads must use RecordReadFailure. public PlayerKVStateResult Reconcile(IReadOnlyDictionary remote) { lock (gate) { if (Terminal || snapshot.Active != null || snapshot.AuthenticationInvalid) return Blocked(); if (remote == null || remote.Any(p => !KVSnapshot.ValidKey(p.Key) || p.Value == null)) return Invalid(); var copy = snapshot.Clone(); copy.Conflicts.Clear(); if (copy.Unknown != null) { foreach (var item in copy.Pending.Values) copy.Conflicts[item.Key] = PlayerKVValue.At(remote, item.Key); return Commit(copy, PlayerKVSyncStatus.UnknownOutcome); } foreach (var item in copy.Pending.Values.ToArray()) { var observed = PlayerKVValue.At(remote, item.Key); if (observed.Equals(item.Target)) copy.Pending.Remove(item.Key); else if (!observed.Equals(item.BaseValue)) copy.Conflicts[item.Key] = observed; } copy.Baseline = remote.ToDictionary(p => p.Key, p => p.Value, StringComparer.Ordinal); copy.HasBaseline = true; return Commit(copy, copy.Conflicts.Count == 0 ? PlayerKVSyncStatus.Ready : PlayerKVSyncStatus.Conflict); } } public PlayerKVStateResult RecordReadFailure(bool unauthorized = false) { lock (gate) { if (Terminal || snapshot.Active != null) return Blocked(); if (unauthorized) { var copy = snapshot.Clone(); copy.AuthenticationInvalid = true; return Commit(copy, copy.Unknown == null ? PlayerKVSyncStatus.Unauthorized : PlayerKVSyncStatus.UnknownOutcome); } status = snapshot.Unknown != null ? PlayerKVSyncStatus.UnknownOutcome : PlayerKVSyncStatus.ReadFailed; return new PlayerKVStateResult(PlayerKVChangeError.None, revision: snapshot.Revision); } } public PlayerKVStateResult Update(string key, string value, bool remoteStore = true) => Edit(key, value == null ? null : PlayerKVValue.Present(value), remoteStore); public PlayerKVStateResult Delete(string key) => Edit(key, PlayerKVValue.Missing, true); PlayerKVStateResult Edit(string key, PlayerKVValue target, bool remote) { lock (gate) { if (status != PlayerKVSyncStatus.Ready || snapshot.AuthenticationInvalid) return Blocked(); if (!KVSnapshot.ValidKey(key) || target == null || snapshot.NextSequence == long.MaxValue) return Invalid(); var copy = snapshot.Clone(); var sequence = copy.NextSequence++; if (remote) { var baseline = copy.Pending.TryGetValue(key, out var previous) ? previous.BaseValue : PlayerKVValue.At(copy.Baseline, key); copy.Pending[key] = new PlayerKVChange(key, sequence, baseline, target); copy.LocalOnly.Remove(key); } else copy.LocalOnly[key] = target.Value; return Commit(copy, PlayerKVSyncStatus.Ready, sequence); } } public PlayerKVStateResult BeginBatch(long throughSequence, out PlayerKVBatch batch) { lock (gate) { batch = null; if (status != PlayerKVSyncStatus.Ready || snapshot.Active != null || snapshot.Unknown != null || snapshot.AuthenticationInvalid) return Blocked(); if (throughSequence < 0) return Invalid(); var ordered = snapshot.Pending.Values.Where(p => p.Sequence <= throughSequence).OrderBy(p => p.Sequence).ToArray(); if (ordered.Length == 0) return new PlayerKVStateResult(PlayerKVChangeError.None, revision: snapshot.Revision); var copy = snapshot.Clone(); copy.Active = new PlayerKVBatch(Guid.NewGuid().ToString("N"), ordered.TakeWhile(p => p.Target.Exists == ordered[0].Target.Exists).Take(10)); var result = Commit(copy, PlayerKVSyncStatus.Ready); if (result.Success) batch = snapshot.Active; // Never expose a batch until its write-ahead record is durable. return result; } } public PlayerKVStateResult ConfirmBatch(string batchId) { lock (gate) { if (status != PlayerKVSyncStatus.Ready || snapshot.Active == null || snapshot.Active.Id != batchId) return Blocked(); var copy = snapshot.Clone(); foreach (var item in copy.Active.Items) { Apply(copy.Baseline, item.Key, item.Target); var current = copy.Pending[item.Key]; if (current.Sequence == item.Sequence) copy.Pending.Remove(item.Key); else copy.Pending[item.Key] = current.WithBase(item.Target); } copy.Active = null; return Commit(copy, PlayerKVSyncStatus.Ready); } } // mayHaveCommitted=false is only valid with positive evidence of no dispatch. public PlayerKVStateResult FailBatch(string batchId, bool mayHaveCommitted, bool unauthorized = false) { lock (gate) { if (Terminal || snapshot.Active == null || snapshot.Active.Id != batchId) return Blocked(); var copy = snapshot.Clone(); copy.AuthenticationInvalid |= unauthorized; if (mayHaveCommitted) copy.Unknown = copy.Active; copy.Active = null; return Commit(copy, mayHaveCommitted ? PlayerKVSyncStatus.UnknownOutcome : copy.AuthenticationInvalid ? PlayerKVSyncStatus.Unauthorized : PlayerKVSyncStatus.ReadFailed); } } bool Terminal => status == PlayerKVSyncStatus.Closed || status == PlayerKVSyncStatus.StorageError; PlayerKVStateResult Commit(KVSnapshot copy, PlayerKVSyncStatus nextStatus, long sequence = 0) { if (snapshot.Revision == long.MaxValue) { status = PlayerKVSyncStatus.StorageError; return new PlayerKVStateResult(PlayerKVChangeError.StorageError, sequence, snapshot.Revision); } copy.Revision = snapshot.Revision + 1; if (!store.Save(copy, out var uncertain)) { status = PlayerKVSyncStatus.StorageError; return new PlayerKVStateResult(PlayerKVChangeError.StorageError, sequence, copy.Revision, uncertain); } snapshot = copy; status = nextStatus; return new PlayerKVStateResult(PlayerKVChangeError.None, sequence, copy.Revision); } static void Apply(Dictionary data, string key, PlayerKVValue value) { if (value.Exists) data[key] = value.Value; else data.Remove(key); } PlayerKVStateResult Blocked() => new PlayerKVStateResult(PlayerKVChangeError.Blocked, revision: snapshot.Revision); PlayerKVStateResult Invalid() => new PlayerKVStateResult(PlayerKVChangeError.Validation, revision: snapshot.Revision); public void Dispose() { lock (gate) { if (status == PlayerKVSyncStatus.Closed) return; status = PlayerKVSyncStatus.Closed; store.Dispose(); } } } }