diff --git a/AGENTS.md b/AGENTS.md index 385b7252..e083762e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,18 +45,22 @@ The SDK supports offline operation via `TaloSettings.offlineMode`. When offline, Events are batched and flushed on application quit/pause/focus loss. On WebGL, events flush every `webGLEventFlushRate` seconds (default 30s) due to platform limitations. ### Debouncing -Player updates and save updates are debounced to prevent excessive API calls during rapid property changes. APIs that need debouncing inherit from `DebouncedAPI` (a generic base class) and define a `DebouncedOperation` enum for type-safe operation keys. The base class uses a dictionary to track multiple debounced operations independently. +Player updates and save updates are debounced to prevent excessive API calls during rapid property changes. APIs that need debouncing inherit from `DebouncedAPI` and define a `DebouncedOperation` enum for type-safe operation keys. The base class uses a dictionary to track multiple debounced operations independently. The debounce is **leading and trailing**: the first call fires immediately (leading), and if further calls arrive during the debounce window they are coalesced into a single trailing call executed after the window closes. The window is defined by `debounceTimerSeconds` (default: 1s) and resets on each subsequent call. To add debouncing to an API: 1. Define a public `enum DebouncedOperation` with your debounced operations -2. Inherit from `DebouncedAPI` +2. Inherit from `DebouncedAPI` 3. Call `Debounce(DebouncedOperation.YourOperation)` to queue an operation 4. Implement `ExecuteDebouncedOperation(DebouncedOperation operation)` with a switch statement 5. The base class's `ProcessPendingUpdates()` is called by `TaloManager.Update()` every frame -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. +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 + +`Player.SetProp(...)` returns `Task`, `Talo.Saves.UpdateCurrentSave()` returns `Task`. All callers in the same debounce window share the same settle result. `FlushUpdates()` returns `FlushResult` (`NothingPending`, `Success`, `Failure`). `OnPlayerUpdated(bool)` / `OnSaveUpdated(bool, GameSave)` fire after each settle. `TaloManager.OnApplicationQuit()` flushes pending updates automatically. ## Key Configuration diff --git a/Assets/Talo Game Services/Talo/Runtime/APIs/DebouncedAPI.cs b/Assets/Talo Game Services/Talo/Runtime/APIs/DebouncedAPI.cs index 2bb5735d..c1607262 100644 --- a/Assets/Talo Game Services/Talo/Runtime/APIs/DebouncedAPI.cs +++ b/Assets/Talo Game Services/Talo/Runtime/APIs/DebouncedAPI.cs @@ -5,7 +5,19 @@ namespace TaloGameServices { - public abstract class DebouncedAPI : BaseAPI where TOperation : Enum + public abstract class DebouncedAPIBase : BaseAPI + { + public enum FlushResult + { + NothingPending, + Success, + Failure + } + + protected DebouncedAPIBase(string service) : base(service) { } + } + + public abstract class DebouncedAPI : DebouncedAPIBase where TOperation : Enum { private class DebouncedOperation { @@ -13,10 +25,14 @@ private class DebouncedOperation public bool windowOpen; public bool hasTrailingCallQueued; public bool isExecuting; + public Task currentTask; + public List> pendingTasks = new(); } private readonly Dictionary operations = new(); + protected event Action OnOperationSettled; + protected DebouncedAPI(string service) : base(service) { } private void OpenWindow(DebouncedOperation op) @@ -25,7 +41,7 @@ private void OpenWindow(DebouncedOperation op) op.windowEndTime = Time.realtimeSinceStartup + Talo.Settings.debounceTimerSeconds; } - protected void Debounce(TOperation operation) + protected Task Debounce(TOperation operation) { if (!operations.ContainsKey(operation)) { @@ -36,25 +52,60 @@ protected void Debounce(TOperation operation) if (!op.windowOpen && !op.isExecuting) { - // leading call: fire immediately and open the debounce window op.hasTrailingCallQueued = false; op.isExecuting = true; OpenWindow(op); - ExecuteDebouncedOperation(operation).ContinueWith((t) => { - op.isExecuting = false; - if (t.IsFaulted) - { - Debug.LogError(t.Exception); - } - }, TaskScheduler.FromCurrentSynchronizationContext()); + var pending = new List>(op.pendingTasks); + op.pendingTasks.Clear(); + + return SettleLeading(operation, op, pending); } else { - // window open or request in-flight: queue a trailing call and extend the window + var tcs = new TaskCompletionSource(); + op.pendingTasks.Add(tcs); op.hasTrailingCallQueued = true; OpenWindow(op); + return tcs.Task; + } + } + + private async Task SettleLeading(TOperation operation, DebouncedOperation op, List> pending) + { + (_, var result) = await RunAndSettle(operation, op, pending); + return result; + } + + private async Task<(bool success, TUpdateResult result)> RunAndSettle(TOperation operation, DebouncedOperation op, List> pending) + { + op.currentTask = ExecuteDebouncedOperation(operation); + + bool success; + TReturnData returnData; + try + { + returnData = await op.currentTask; + success = true; + } + catch (Exception) + { + returnData = default; + success = false; + } + finally + { + op.isExecuting = false; } + + OnOperationSettled?.Invoke(success, returnData); + + var result = BuildResult(success, returnData); + foreach (var tcs in pending) + { + tcs.SetResult(result); + } + return (success, result); } public async Task ProcessPendingUpdates() @@ -93,18 +144,54 @@ public async Task ProcessPendingUpdates() var op = operations[key]; op.hasTrailingCallQueued = false; op.isExecuting = true; - try + + var pending = new List>(op.pendingTasks); + op.pendingTasks.Clear(); + + await RunAndSettle(key, op, pending); + } + } + + public async Task FlushUpdates() + { + var result = FlushResult.NothingPending; + + var keys = new List(operations.Keys); + foreach (var key in keys) + { + var op = operations[key]; + + if (op.isExecuting) { - await ExecuteDebouncedOperation(key); + await op.currentTask; } - finally + + while (op.hasTrailingCallQueued) { - op.isExecuting = false; - op.windowOpen = false; + op.hasTrailingCallQueued = false; + op.isExecuting = true; + + var pending = new List>(op.pendingTasks); + op.pendingTasks.Clear(); + + var (settleSuccess, _) = await RunAndSettle(key, op, pending); + if (settleSuccess) + { + if (result == FlushResult.NothingPending) result = FlushResult.Success; + } + else + { + result = FlushResult.Failure; + } } + + op.windowOpen = false; } + + return result; } - protected abstract Task ExecuteDebouncedOperation(TOperation operation); + protected abstract Task ExecuteDebouncedOperation(TOperation operation); + protected abstract TUpdateResult BuildResult(bool success, TReturnData returnData); } } diff --git a/Assets/Talo Game Services/Talo/Runtime/APIs/PlayersAPI.cs b/Assets/Talo Game Services/Talo/Runtime/APIs/PlayersAPI.cs index 4e14c7c0..f2d739a7 100644 --- a/Assets/Talo Game Services/Talo/Runtime/APIs/PlayersAPI.cs +++ b/Assets/Talo Game Services/Talo/Runtime/APIs/PlayersAPI.cs @@ -9,7 +9,7 @@ public class MergeOptions public string postMergeIdentityService = ""; } - public class PlayersAPI : DebouncedAPI + public class PlayersAPI : DebouncedAPI { public enum DebouncedOperation { @@ -21,10 +21,12 @@ public enum DebouncedOperation public event Action OnIdentificationFailed; public event Action OnIdentityCleared; public event Action OnPropsRejected; + public event Action OnPlayerUpdated; public PlayersAPI() : base("v1/players") { Talo.OnConnectionRestored += OnConnectionRestored; + OnOperationSettled += (success, _) => OnPlayerUpdated?.Invoke(success); } private async void OnConnectionRestored() @@ -132,22 +134,36 @@ string playerId return await Identify("game_center", identifier); } - protected override async Task ExecuteDebouncedOperation(DebouncedOperation operation) + protected override async Task ExecuteDebouncedOperation(DebouncedOperation operation) { - switch (operation) + return operation switch { - case DebouncedOperation.Update: - await Update(); - break; + DebouncedOperation.Update => await RunUpdate(), + _ => null, + }; + } + + protected override PlayerUpdateResult BuildResult(bool success, RejectedProp[] updateData) + { + if (!success) + { + return new PlayerUpdateResult(false); } + return new PlayerUpdateResult(true, updateData); } - public void DebounceUpdate() + public Task DebounceUpdate() { - Debounce(DebouncedOperation.Update); + return Debounce(DebouncedOperation.Update); } public async Task Update() + { + await RunUpdate(); + return Talo.CurrentPlayer; + } + + private async Task RunUpdate() { Talo.IdentityCheck(); @@ -164,7 +180,7 @@ public async Task Update() OnPropsRejected?.Invoke(res.rejectedProps); } - return Talo.CurrentPlayer; + return res.rejectedProps ?? Array.Empty(); } public async Task Merge(string playerId1, string playerId2, MergeOptions options = null) @@ -258,5 +274,17 @@ public async Task CreateSocketToken() return ""; } } + + public class PlayerUpdateResult + { + public bool Success { get; } + public RejectedProp[] RejectedProps { get; } + + public PlayerUpdateResult(bool success, RejectedProp[] rejectedProps = null) + { + Success = success; + RejectedProps = rejectedProps ?? Array.Empty(); + } + } } } diff --git a/Assets/Talo Game Services/Talo/Runtime/APIs/SavesAPI.cs b/Assets/Talo Game Services/Talo/Runtime/APIs/SavesAPI.cs index 203c2101..06f5ef53 100644 --- a/Assets/Talo Game Services/Talo/Runtime/APIs/SavesAPI.cs +++ b/Assets/Talo Game Services/Talo/Runtime/APIs/SavesAPI.cs @@ -6,7 +6,7 @@ namespace TaloGameServices { - public class SavesAPI : DebouncedAPI + public class SavesAPI : DebouncedAPI { public enum DebouncedOperation { @@ -21,6 +21,7 @@ public enum DebouncedOperation public event Action OnSaveChosen; public event Action OnSaveUnloaded; + public event Action OnSaveUpdated; public GameSave[] All { @@ -38,7 +39,11 @@ public GameSave Current } public SavesAPI() : base("v1/game-saves") - { } + { + OnOperationSettled += (success, save) => { + OnSaveUpdated?.Invoke(success, success ? save : null); + }; + } internal void Setup() { @@ -191,7 +196,7 @@ public async Task CreateSave(string saveName, SaveContent content = nu return savesManager.CreateSave(save); } - protected override async Task ExecuteDebouncedOperation(DebouncedOperation operation) + protected override async Task ExecuteDebouncedOperation(DebouncedOperation operation) { switch (operation) { @@ -199,18 +204,24 @@ protected override async Task ExecuteDebouncedOperation(DebouncedOperation opera var currentSave = savesManager.CurrentSave; if (currentSave != null) { - await UpdateSave(currentSave.id); + return await UpdateSave(currentSave.id); } break; } + return null; + } + + protected override SaveUpdateResult BuildResult(bool success, GameSave updateData) + { + return new SaveUpdateResult(success, success ? updateData : null); } - public void DebounceUpdate() + public Task DebounceUpdate() { - Debounce(DebouncedOperation.Update); + return Debounce(DebouncedOperation.Update); } - public async Task UpdateCurrentSave(string newName = "") + public async Task UpdateCurrentSave(string newName = "") { var currentSave = savesManager.CurrentSave; if (currentSave == null) @@ -221,13 +232,16 @@ public async Task UpdateCurrentSave(string newName = "") // if the save is being renamed, sync it immediately if (!string.IsNullOrEmpty(newName)) { - return await UpdateSave(currentSave.id, newName); + var save = await UpdateSave(currentSave.id, newName); + var success = save != null; + var result = new SaveUpdateResult(success, save); + OnSaveUpdated?.Invoke(success, save); + return result; } // else, update the save locally and queue it for syncing currentSave.content = contentManager.Content; - DebounceUpdate(); - return currentSave; + return await DebounceUpdate(); } public async Task UpdateSave(int saveId, string newName = "") @@ -238,7 +252,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"); } @@ -254,7 +271,6 @@ public async Task UpdateSave(int saveId, string newName = "") }); var json = await Call(uri, "PATCH", content); - var res = JsonUtility.FromJson(json); save = res.save; } @@ -293,5 +309,17 @@ public async Task DeleteSave(int saveId, bool unloadIfCurrentSave = false) savesManager.DeleteSave(saveId, unloadIfCurrentSave); } + + public class SaveUpdateResult + { + public bool Success { get; } + public GameSave Save { get; } + + public SaveUpdateResult(bool success, GameSave save = null) + { + Success = success; + Save = save; + } + } } } diff --git a/Assets/Talo Game Services/Talo/Runtime/Entities/Player.cs b/Assets/Talo Game Services/Talo/Runtime/Entities/Player.cs index 73b4d2a8..df79fb15 100644 --- a/Assets/Talo Game Services/Talo/Runtime/Entities/Player.cs +++ b/Assets/Talo Game Services/Talo/Runtime/Entities/Player.cs @@ -1,101 +1,108 @@ -using UnityEngine; -using System.Linq; -using System; -using System.Collections.Generic; - -namespace TaloGameServices -{ - [Serializable] - public class Player : EntityWithProps - { - public string id; - public PlayerAlias[] aliases; - public GroupStub[] groups; - public PlayerPresence presence; - - public override string ToString() - { - return JsonUtility.ToJson(this); - } - - public void SetProp(string key, string value, bool update = true) - { - base.SetProp(key, value); - - if (update) - { - Talo.Players.DebounceUpdate(); - } - } - - public void DeleteProp(string key, bool update = true) - { - base.DeleteProp(key); - - if (update) - { - Talo.Players.DebounceUpdate(); - } - } - - public void SetPropArray(string key, IEnumerable values, bool update = true) - { - base.SetPropArray(key, values); - - if (update) - { - Talo.Players.DebounceUpdate(); - } - } - - public void DeletePropArray(string key, bool update = true) - { - base.DeletePropArray(key); - - if (update) - { - Talo.Players.DebounceUpdate(); - } - } - - public void InsertIntoPropArray(string key, string value, bool update = true) - { - base.InsertIntoPropArray(key, value); - - if (update) - { - Talo.Players.DebounceUpdate(); - } - } - - public void RemoveFromPropArray(string key, string value, bool update = true) - { - base.RemoveFromPropArray(key, value); - - if (update) - { - Talo.Players.DebounceUpdate(); - } - } - - public bool IsInGroupID(string groupId) - { - return groups.Any((group) => group.id == groupId); - } - - public bool IsInGroupName(string groupName) - { - return groups.Any((group) => group.name == groupName); - } - - public PlayerAlias GetAlias(string service = "") - { - if (string.IsNullOrEmpty(service)) - { - return aliases.Length > 0 ? aliases[0] : null; - } - - return aliases.FirstOrDefault((alias) => alias.service == service); - } - } -} +using UnityEngine; +using System.Linq; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace TaloGameServices +{ + [Serializable] + public class Player : EntityWithProps + { + public string id; + public PlayerAlias[] aliases; + public GroupStub[] groups; + public PlayerPresence presence; + + public override string ToString() + { + return JsonUtility.ToJson(this); + } + + public Task SetProp(string key, string value, bool update = true) + { + base.SetProp(key, value); + + if (update) + { + return Talo.Players.DebounceUpdate(); + } + return Task.FromResult(new PlayersAPI.PlayerUpdateResult(true)); + } + + public Task DeleteProp(string key, bool update = true) + { + base.DeleteProp(key); + + if (update) + { + return Talo.Players.DebounceUpdate(); + } + return Task.FromResult(new PlayersAPI.PlayerUpdateResult(true)); + } + + public Task SetPropArray(string key, IEnumerable values, bool update = true) + { + base.SetPropArray(key, values); + + if (update) + { + return Talo.Players.DebounceUpdate(); + } + return Task.FromResult(new PlayersAPI.PlayerUpdateResult(true)); + } + + public Task DeletePropArray(string key, bool update = true) + { + base.DeletePropArray(key); + + if (update) + { + return Talo.Players.DebounceUpdate(); + } + return Task.FromResult(new PlayersAPI.PlayerUpdateResult(true)); + } + + public Task InsertIntoPropArray(string key, string value, bool update = true) + { + base.InsertIntoPropArray(key, value); + + if (update) + { + return Talo.Players.DebounceUpdate(); + } + return Task.FromResult(new PlayersAPI.PlayerUpdateResult(true)); + } + + public Task RemoveFromPropArray(string key, string value, bool update = true) + { + base.RemoveFromPropArray(key, value); + + if (update) + { + return Talo.Players.DebounceUpdate(); + } + return Task.FromResult(new PlayersAPI.PlayerUpdateResult(true)); + } + + public bool IsInGroupID(string groupId) + { + return groups.Any((group) => group.id == groupId); + } + + public bool IsInGroupName(string groupName) + { + return groups.Any((group) => group.name == groupName); + } + + public PlayerAlias GetAlias(string service = "") + { + if (string.IsNullOrEmpty(service)) + { + return aliases.Length > 0 ? aliases[0] : null; + } + + return aliases.FirstOrDefault((alias) => alias.service == service); + } + } +} diff --git a/Assets/Talo Game Services/Talo/Runtime/TaloManager.cs b/Assets/Talo Game Services/Talo/Runtime/TaloManager.cs index 6f55788e..f36c0e92 100644 --- a/Assets/Talo Game Services/Talo/Runtime/TaloManager.cs +++ b/Assets/Talo Game Services/Talo/Runtime/TaloManager.cs @@ -37,9 +37,24 @@ private void OnDisable() Talo.Events.OnFlushed -= ResetFlushTimer; } - private void OnApplicationQuit() + private async void OnApplicationQuit() { - DoFlush(); + try + { + if (Talo.HasIdentity()) + { + await Talo.Events.Flush(); + await Talo.Players.FlushUpdates(); + if (Talo.Saves.Current != null) + { + await Talo.Saves.FlushUpdates(); + } + } + } + catch (Exception ex) + { + Debug.LogError($"Failed to flush on quit: {ex}"); + } } private void OnApplicationFocus(bool hasFocus) 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..3a567d18 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 @@ -7,22 +7,12 @@ public class SetProp : MonoBehaviour { public string key, value; - private void OnEnable() - { - Talo.Players.OnPropsRejected += OnPropsRejected; - } - - private void OnDisable() - { - Talo.Players.OnPropsRejected -= OnPropsRejected; - } - public void OnButtonClick() { UpdateProp(); } - private void UpdateProp() + private async void UpdateProp() { if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(value)) { @@ -32,20 +22,21 @@ private void UpdateProp() try { - Talo.CurrentPlayer.SetProp(key, value); - ResponseMessage.SetText($"{key} set to {value}"); + var result = await Talo.CurrentPlayer.SetProp(key, value); + + if (result.RejectedProps.Length > 0) + { + var reasons = string.Join(", ", Array.ConvertAll(result.RejectedProps, (rp) => $"[{rp.key}] {rp.message}")); + ResponseMessage.SetText($"Rejected props: {reasons}"); + return; + } + + ResponseMessage.SetText($"{key} saved successfully"); } - catch (System.Exception ex) + catch (Exception ex) { ResponseMessage.SetText(ex.Message); - throw; } } - - private void OnPropsRejected(RejectedProp[] rejectedProps) - { - var reasons = string.Join(", ", Array.ConvertAll(rejectedProps, (rp) => $"[{rp.key}] {rp.message}")); - ResponseMessage.SetText($"Rejected props: {reasons}"); - } } } 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..af699282 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