diff --git a/AGENTS.md b/AGENTS.md index 385b7252..651aa05f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,6 +58,24 @@ To add debouncing to an API: Example: `PlayersAPI` defines `enum DebouncedOperation { Update }` and inherits from `DebouncedAPI`. When `Player.SetProp()` is called, it calls `Debounce(DebouncedOperation.Update)`. The first call fires immediately; subsequent calls within the debounce window result in a single trailing API call at the end of the window. +#### Debounced update completion signals + +Because the trailing call is fired by `TaloManager.Update()` on a later frame, the caller's code (e.g. `Player.SetProp()`) returns long before the PATCH actually settles. Two complementary signals let the caller know when a batch has landed: + +- **Event** — `Talo.Players.OnPlayerUpdated` / `Talo.Players.OnPlayerUpdateFailed` (and the matching `Talo.Saves.OnSaveUpdated(GameSave)` / `Talo.Saves.OnSaveUpdateFailed`) fire after each settled batch. `OnPlayerUpdated` and `OnPlayerUpdateFailed` are mutually exclusive. `OnPropsRejected` still fires independently when the server rejects props. +- **`await FlushUpdates()`** — `Talo.Players.FlushUpdates()` (and `Talo.Saves.FlushUpdates()`) force any pending trailing call to fire immediately and `await` it, returning `true` if anything was flushed and `false` if nothing was pending. Throws on transport failure (matching the SDK's `Identify` pattern: the event fires AND the `Task` throws). Use this when you need determinism, e.g. before quitting: + +```csharp +private async void OnApplicationQuit() +{ + if (Talo.Saves.Current != null) + { + try { await Talo.Saves.FlushUpdates(); } + catch (Exception ex) { Debug.LogError($"Final save failed: {ex.Message}"); } + } +} +``` + ## Key Configuration ### TaloSettings diff --git a/Assets/Talo Game Services/Talo/Runtime/APIs/DebouncedAPI.cs b/Assets/Talo Game Services/Talo/Runtime/APIs/DebouncedAPI.cs index 2bb5735d..025070ba 100644 --- a/Assets/Talo Game Services/Talo/Runtime/APIs/DebouncedAPI.cs +++ b/Assets/Talo Game Services/Talo/Runtime/APIs/DebouncedAPI.cs @@ -13,10 +13,14 @@ private class DebouncedOperation public bool windowOpen; public bool hasTrailingCallQueued; public bool isExecuting; + public Task currentTask; } private readonly Dictionary operations = new(); + protected event Action OnOperationCompleted; + protected event Action OnOperationFailed; + protected DebouncedAPI(string service) : base(service) { } private void OpenWindow(DebouncedOperation op) @@ -25,7 +29,7 @@ private void OpenWindow(DebouncedOperation op) op.windowEndTime = Time.realtimeSinceStartup + Talo.Settings.debounceTimerSeconds; } - protected void Debounce(TOperation operation) + protected async void Debounce(TOperation operation) { if (!operations.ContainsKey(operation)) { @@ -41,13 +45,21 @@ protected void Debounce(TOperation operation) op.isExecuting = true; OpenWindow(op); - ExecuteDebouncedOperation(operation).ContinueWith((t) => { + op.currentTask = ExecuteDebouncedOperation(operation); + + try + { + await op.currentTask; + OnOperationCompleted?.Invoke(); + } + catch (Exception ex) + { + OnOperationFailed?.Invoke(ex); + } + finally + { op.isExecuting = false; - if (t.IsFaulted) - { - Debug.LogError(t.Exception); - } - }, TaskScheduler.FromCurrentSynchronizationContext()); + } } else { @@ -93,9 +105,16 @@ public async Task ProcessPendingUpdates() var op = operations[key]; op.hasTrailingCallQueued = false; op.isExecuting = true; + op.currentTask = ExecuteDebouncedOperation(key); + try { - await ExecuteDebouncedOperation(key); + await op.currentTask; + OnOperationCompleted?.Invoke(); + } + catch (Exception ex) + { + OnOperationFailed?.Invoke(ex); } finally { @@ -105,6 +124,53 @@ public async Task ProcessPendingUpdates() } } + public async Task FlushUpdates() + { + bool didWork = false; + + var keys = new List(operations.Keys); + foreach (var key in keys) + { + var op = operations[key]; + + if (op.isExecuting) + { + await op.currentTask; + didWork = true; + } + + if (op.hasTrailingCallQueued) + { + while (op.hasTrailingCallQueued) + { + op.hasTrailingCallQueued = false; + op.isExecuting = true; + op.currentTask = ExecuteDebouncedOperation(key); + + try + { + await op.currentTask; + OnOperationCompleted?.Invoke(); + } + catch (Exception ex) + { + OnOperationFailed?.Invoke(ex); + throw; + } + finally + { + op.isExecuting = false; + op.windowOpen = false; + } + + didWork = true; + } + } + } + + return didWork; + } + protected abstract Task ExecuteDebouncedOperation(TOperation operation); } } diff --git a/Assets/Talo Game Services/Talo/Runtime/APIs/PlayersAPI.cs b/Assets/Talo Game Services/Talo/Runtime/APIs/PlayersAPI.cs index 4e14c7c0..19fadf12 100644 --- a/Assets/Talo Game Services/Talo/Runtime/APIs/PlayersAPI.cs +++ b/Assets/Talo Game Services/Talo/Runtime/APIs/PlayersAPI.cs @@ -21,10 +21,14 @@ public enum DebouncedOperation public event Action OnIdentificationFailed; public event Action OnIdentityCleared; public event Action OnPropsRejected; + public event Action OnPlayerUpdated; + public event Action OnPlayerUpdateFailed; public PlayersAPI() : base("v1/players") { Talo.OnConnectionRestored += OnConnectionRestored; + OnOperationCompleted += () => OnPlayerUpdated?.Invoke(Talo.CurrentPlayer); + OnOperationFailed += (ex) => OnPlayerUpdateFailed?.Invoke(ex); } private async void OnConnectionRestored() diff --git a/Assets/Talo Game Services/Talo/Runtime/APIs/SavesAPI.cs b/Assets/Talo Game Services/Talo/Runtime/APIs/SavesAPI.cs index 203c2101..6136af30 100644 --- a/Assets/Talo Game Services/Talo/Runtime/APIs/SavesAPI.cs +++ b/Assets/Talo Game Services/Talo/Runtime/APIs/SavesAPI.cs @@ -21,6 +21,8 @@ public enum DebouncedOperation public event Action OnSaveChosen; public event Action OnSaveUnloaded; + public event Action OnSaveUpdated; + public event Action OnSaveUpdateFailed; public GameSave[] All { @@ -37,8 +39,13 @@ public GameSave Current get => savesManager.CurrentSave; } + private GameSave _lastUpdateResult; + public SavesAPI() : base("v1/game-saves") - { } + { + OnOperationCompleted += () => OnSaveUpdated?.Invoke(_lastUpdateResult); + OnOperationFailed += (ex) => OnSaveUpdateFailed?.Invoke(ex); + } internal void Setup() { @@ -196,11 +203,8 @@ protected override async Task ExecuteDebouncedOperation(DebouncedOperation opera switch (operation) { case DebouncedOperation.Update: - var currentSave = savesManager.CurrentSave; - if (currentSave != null) - { - await UpdateSave(currentSave.id); - } + var currentSave = savesManager.CurrentSave ?? throw new Exception("No save is currently loaded"); + _lastUpdateResult = await UpdateSave(currentSave.id); break; } } @@ -238,7 +242,10 @@ public async Task UpdateSave(int saveId, string newName = "") if (Talo.IsOffline()) { - if (!string.IsNullOrEmpty(newName)) save.name = newName; + if (!string.IsNullOrEmpty(newName)) + { + save.name = newName; + } save.content = saveContent; save.updatedAt = DateTime.UtcNow.ToString("O"); } diff --git a/Assets/Talo Game Services/Talo/Runtime/Utils/RequestMock.cs b/Assets/Talo Game Services/Talo/Runtime/Utils/RequestMock.cs index 7244f918..f68e4ee6 100644 --- a/Assets/Talo Game Services/Talo/Runtime/Utils/RequestMock.cs +++ b/Assets/Talo Game Services/Talo/Runtime/Utils/RequestMock.cs @@ -13,8 +13,8 @@ private struct RequestHandler public long status; } - private static List _permanentHandlers = new List(); - private static List _oneTimeHandlers = new List(); + private static readonly List _permanentHandlers = new(); + private static readonly List _oneTimeHandlers = new(); private static bool _offline; public static bool Offline diff --git a/Assets/Talo Game Services/Talo/Samples/Playground/Scripts/Players/SetProp.cs b/Assets/Talo Game Services/Talo/Samples/Playground/Scripts/Players/SetProp.cs index 032341bf..dd3c777b 100644 --- a/Assets/Talo Game Services/Talo/Samples/Playground/Scripts/Players/SetProp.cs +++ b/Assets/Talo Game Services/Talo/Samples/Playground/Scripts/Players/SetProp.cs @@ -9,11 +9,15 @@ public class SetProp : MonoBehaviour private void OnEnable() { + Talo.Players.OnPlayerUpdated += OnPlayerUpdated; + Talo.Players.OnPlayerUpdateFailed += OnPlayerUpdateFailed; Talo.Players.OnPropsRejected += OnPropsRejected; } private void OnDisable() { + Talo.Players.OnPlayerUpdated -= OnPlayerUpdated; + Talo.Players.OnPlayerUpdateFailed -= OnPlayerUpdateFailed; Talo.Players.OnPropsRejected -= OnPropsRejected; } @@ -42,6 +46,16 @@ private void UpdateProp() } } + private void OnPlayerUpdated(Player player) + { + ResponseMessage.SetText($"{key} saved successfully"); + } + + private void OnPlayerUpdateFailed(Exception ex) + { + ResponseMessage.SetText($"Save failed: {ex.Message}"); + } + private void OnPropsRejected(RejectedProp[] rejectedProps) { var reasons = string.Join(", ", Array.ConvertAll(rejectedProps, (rp) => $"[{rp.key}] {rp.message}")); diff --git a/Assets/Talo Game Services/Talo/Samples/SavesDemo/Scripts/GameUIController.cs b/Assets/Talo Game Services/Talo/Samples/SavesDemo/Scripts/GameUIController.cs index 84b21baa..12e313ef 100644 --- a/Assets/Talo Game Services/Talo/Samples/SavesDemo/Scripts/GameUIController.cs +++ b/Assets/Talo Game Services/Talo/Samples/SavesDemo/Scripts/GameUIController.cs @@ -29,8 +29,6 @@ private void Start() updateSaveButton.clicked += async () => { await Talo.Saves.UpdateCurrentSave(); - updateSaveButton.text = "Saved!"; - Invoke("ResetUpdateSaveButtonText", 1f); }; root.Q