using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; namespace game.PlayerDataStorage { // Collection is limited to a synchronous gameplay fragment. It never flows across await. public sealed class PlayerSaveAction { [ThreadStatic] static PlayerSaveAction current; readonly KVPlayerSaveStore store; readonly Dictionary receipts = new Dictionary(StringComparer.Ordinal); readonly List presentations = new List(); string failure; PlayerSaveAction(KVPlayerSaveStore store) { this.store = store; } internal static bool Accepts(KVPlayerSaveStore store) { if (current == null || ReferenceEquals(current.store, store)) return true; current.failure = "ForeignStoreInReward"; return false; } internal static void Record(KVPlayerSaveStore store, string key, PlayerSaveWriteResult result) { if (current == null || !ReferenceEquals(current.store, store)) return; if (!result.Accepted) current.failure = result.Error; else current.receipts[key] = result.Receipt; } public static void Present(Action presentation) { if (current == null) presentation(); else current.presentations.Add(presentation); } public static async Task RunAsync(IPlayerSaveStore store, string actionKey, Action apply, Action savePlayer, CancellationToken token = default) { if (!store.IsCurrent || token.IsCancellationRequested) throw new OperationCanceledException(); if (!(store is KVPlayerSaveStore kv)) { apply(); return; } // A live login may still have an UnknownOutcome/Guarded owner. Check readiness before inventory mutation. kv.Read("NewPlayerDataDic").ValueOrThrow(); if (current != null) throw new InvalidOperationException("Reward action must be awaited by its caller."); if (!kv.BeginAction(actionKey)) throw new InvalidOperationException("Reward is pending or requires recovery."); var action = new PlayerSaveAction(kv); try { current = action; apply(); savePlayer(); } finally { current = null; } // Keep the action reserved after any failure: replaying rewards is not recovery. if (action.failure != null) throw new InvalidOperationException("Reward write rejected: " + action.failure); if (action.receipts.Count == 0) throw new InvalidOperationException("Reward produced no save receipts."); foreach (var receipt in action.receipts.Values) { var result = await store.ConfirmAsync(receipt, token); if (!result.Confirmed) throw new InvalidOperationException("Reward save not confirmed: " + result.Status + "/" + result.Error); } if (!store.IsCurrent || token.IsCancellationRequested) throw new OperationCanceledException(); // Presentation failure must not cause an already committed reward to replay. foreach (var presentation in action.presentations) { if (!store.IsCurrent || token.IsCancellationRequested) throw new OperationCanceledException(); presentation(); } kv.CompleteAction(actionKey); } } public sealed class KVPlayerSaveStore : IPlayerSaveStore, IDisposable { PlayerKVGameSessionHandle handle; CancellationToken lifetime; bool bound, disposed; readonly object receiptsGate = new object(); readonly Dictionary pendingReceipts = new Dictionary(StringComparer.Ordinal); readonly HashSet actions = new HashSet(StringComparer.Ordinal); internal bool BeginAction(string key) { lock (receiptsGate) return IsCurrent && actions.Add(key); } internal void CompleteAction(string key) { lock (receiptsGate) actions.Remove(key); } public bool IsCurrent => bound && !disposed && !lifetime.IsCancellationRequested && handle.Status != PlayerKVSyncStatus.Closed; internal void Bind(PlayerKVGameSessionHandle original, CancellationToken loginLifetime) { if (bound || disposed || original == null || loginLifetime.IsCancellationRequested) throw new InvalidOperationException("Player save store cannot be rebound."); // This also checks the load guard before permitting business use. if (!KVMgr.ReadKV(original, "LastLoginTimeUtc").Success) throw new InvalidOperationException("Player save handle is not ready."); handle = original; lifetime = loginLifetime; bound = true; } public PlayerSaveReadResult Read(string key) { if (!IsCurrent) return new PlayerSaveReadResult("StaleOrNotReady"); var result = KVMgr.ReadKV(handle, key); return result.Success ? new PlayerSaveReadResult(result.Found.Value, result.Value) : new PlayerSaveReadResult(result.Error.ToString()); } public PlayerSaveWriteResult Write(string key, string value, bool remoteStore = true) { lock (receiptsGate) { if (!PlayerSaveAction.Accepts(this)) return new PlayerSaveWriteResult("ForeignStoreInReward"); if (!IsCurrent) { var rejected = new PlayerSaveWriteResult("StaleOrNotReady"); PlayerSaveAction.Record(this, key, rejected); return rejected; } var result = KVMgr.WriteKV(handle, key, value, remoteStore); if (result.Accepted && result.Ticket != null) pendingReceipts[key] = result.Ticket; var write = result.Accepted ? new PlayerSaveWriteResult(new PlayerSaveReceipt(this, result.Ticket)) : new PlayerSaveWriteResult(result.StateResult.Error.ToString()); PlayerSaveAction.Record(this, key, write); return write; } } public async Task WriteAndConfirmAsync(string key, string value, CancellationToken token = default) { if (token.IsCancellationRequested) return Cancelled(); var write = Write(key, value); if (!write.Accepted) return new PlayerSaveConfirmation(PlayerSaveStatus.Rejected, write.Error); return await ConfirmAsync(write.Receipt, token); } public async Task ConfirmAsync(PlayerSaveReceipt receipt, CancellationToken token = default) { if (!IsCurrent || token.IsCancellationRequested) return Cancelled(); if (receipt == null || !ReferenceEquals(receipt.Store, this)) return new PlayerSaveConfirmation(PlayerSaveStatus.Rejected, "ForeignReceipt"); if (receipt.Ticket == null) return new PlayerSaveConfirmation(PlayerSaveStatus.LocalOnly); using (var wait = CancellationTokenSource.CreateLinkedTokenSource(lifetime, token)) { try { await KVMgr.FlushKVAsync(handle, wait.Token); var ticket = await KVMgr.WaitForKVTicketAsync(handle, (PlayerKVSyncTicket)receipt.Ticket, wait.Token); if (!IsCurrent || wait.IsCancellationRequested) return Cancelled(); if (ticket.Status == PlayerKVTicketStatus.Confirmed) { ForgetConfirmed((PlayerKVSyncTicket)receipt.Ticket); return new PlayerSaveConfirmation(PlayerSaveStatus.Confirmed); } return new PlayerSaveConfirmation(ticket.Status == PlayerKVTicketStatus.Unknown ? PlayerSaveStatus.Unknown : ticket.Status == PlayerKVTicketStatus.Superseded ? PlayerSaveStatus.Superseded : ticket.Status == PlayerKVTicketStatus.Cancelled ? PlayerSaveStatus.Cancelled : PlayerSaveStatus.Rejected, ticket.TransportError.ToString()); } catch (OperationCanceledException) { return Cancelled(); } } } public async Task FlushAsync(CancellationToken token = default) { if (!IsCurrent || token.IsCancellationRequested) return Cancelled(); PlayerKVSyncTicket[] captured; lock (receiptsGate) captured = pendingReceipts.Values.ToArray(); using (var wait = CancellationTokenSource.CreateLinkedTokenSource(lifetime, token)) { try { var result = await KVMgr.FlushKVAsync(handle, wait.Token); if (!IsCurrent || wait.IsCancellationRequested) return Cancelled(); var status = result.Unknown > 0 || result.CacheStatus == PlayerKVSyncStatus.UnknownOutcome ? PlayerSaveStatus.Unknown : result.Blocked > 0 || result.TransportError != PlayerKVError.None || result.CacheStatus != PlayerKVSyncStatus.Ready ? PlayerSaveStatus.Rejected : PlayerSaveStatus.Confirmed; if (status != PlayerSaveStatus.Confirmed) return new PlayerSaveConfirmation(status, result.TransportError.ToString()); // Multiple writes before this flush are covered by the captured final receipt. // A replacement during the await belongs to a later flush and cannot confirm it. foreach (var receipt in captured) { var ticket = await KVMgr.WaitForKVTicketAsync(handle, receipt, wait.Token); if (!IsCurrent || wait.IsCancellationRequested) return Cancelled(); if (ticket.Status != PlayerKVTicketStatus.Confirmed) return new PlayerSaveConfirmation(ticket.Status == PlayerKVTicketStatus.Superseded ? PlayerSaveStatus.Superseded : ticket.Status == PlayerKVTicketStatus.Unknown ? PlayerSaveStatus.Unknown : ticket.Status == PlayerKVTicketStatus.Cancelled ? PlayerSaveStatus.Cancelled : PlayerSaveStatus.Rejected, ticket.TransportError.ToString()); ForgetConfirmed(receipt); } return new PlayerSaveConfirmation(PlayerSaveStatus.Confirmed); } catch (OperationCanceledException) { return Cancelled(); } } } static PlayerSaveConfirmation Cancelled() => new PlayerSaveConfirmation(PlayerSaveStatus.Cancelled); void ForgetConfirmed(PlayerKVSyncTicket receipt) { lock (receiptsGate) if (pendingReceipts.TryGetValue(receipt.Key, out var latest) && ReferenceEquals(latest, receipt)) pendingReceipts.Remove(receipt.Key); } public void Dispose() { disposed = true; lock (receiptsGate) { pendingReceipts.Clear(); actions.Clear(); } } } public sealed class PlayFabPlayerSaveStore : IPlayerSaveStore { readonly PlayFabMgr manager; readonly CancellationToken lifetime; public PlayFabPlayerSaveStore(IUserService user) : this(PlayFabMgr.Instance, user.LoginLifetime) { } internal PlayFabPlayerSaveStore(PlayFabMgr manager, CancellationToken lifetime) { this.manager = manager; this.lifetime = lifetime; } public bool IsCurrent => manager != null && !lifetime.IsCancellationRequested; public PlayerSaveReadResult Read(string key) { if (!IsCurrent) return new PlayerSaveReadResult("StaleOrNotReady"); try { var value = manager.GetLocalData(key); return new PlayerSaveReadResult(value != null, value); } catch (Exception ex) { return new PlayerSaveReadResult(ex.GetType().Name); } } public PlayerSaveWriteResult Write(string key, string value, bool remoteStore = true) { if (!IsCurrent) return new PlayerSaveWriteResult("StaleOrNotReady"); try { manager.UpdateUserDataValue(key, value, remoteStore); return new PlayerSaveWriteResult(new PlayerSaveReceipt(this, remoteStore ? (object)true : null)); } catch (Exception ex) { return new PlayerSaveWriteResult(ex.GetType().Name); } } public async Task WriteAndConfirmAsync(string key, string value, CancellationToken token = default) { if (!IsCurrent || token.IsCancellationRequested) return new PlayerSaveConfirmation(PlayerSaveStatus.Cancelled); using (var wait = CancellationTokenSource.CreateLinkedTokenSource(lifetime, token)) { var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); using (wait.Token.Register(() => cancelled.TrySetResult(false))) { try { var save = manager.SyncDataImmidiate(key, value); var finished = await Task.WhenAny(save, cancelled.Task); if (!IsCurrent || wait.IsCancellationRequested) return new PlayerSaveConfirmation(PlayerSaveStatus.Cancelled); return new PlayerSaveConfirmation(await finished ? PlayerSaveStatus.Confirmed : PlayerSaveStatus.Rejected); } catch (Exception ex) { return new PlayerSaveConfirmation(PlayerSaveStatus.Rejected, ex.GetType().Name); } } } } public Task ConfirmAsync(PlayerSaveReceipt receipt, CancellationToken token = default) => Task.FromResult(new PlayerSaveConfirmation(!IsCurrent || token.IsCancellationRequested ? PlayerSaveStatus.Cancelled : receipt == null || !ReferenceEquals(receipt.Store, this) ? PlayerSaveStatus.Rejected : receipt.Ticket == null ? PlayerSaveStatus.LocalOnly : PlayerSaveStatus.Buffered)); public Task FlushAsync(CancellationToken token = default) { if (!IsCurrent || token.IsCancellationRequested) return Task.FromResult(new PlayerSaveConfirmation(PlayerSaveStatus.Cancelled)); // The original manager owns its timer and callback-based buffered dispatch. manager.FlushBufferedData(); return Task.FromResult(new PlayerSaveConfirmation(PlayerSaveStatus.Buffered)); } } }