diff --git a/AGENTS.md b/AGENTS.md index e083762..b61e975 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,7 +47,7 @@ Events are batched and flushed on application quit/pause/focus loss. On WebGL, e ### Debouncing 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. +The debounce is **trailing**: calls are coalesced into a single API call executed after the debounce window closes. The window is defined by `debounceTimerSeconds` (default: 1s) and resets on each subsequent call. All callers in the same window share the same `Task` result. To add debouncing to an API: 1. Define a public `enum DebouncedOperation` with your debounced operations @@ -56,7 +56,7 @@ To add debouncing to an API: 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 opens a window; subsequent calls within the window extend it. A single trailing API call fires at the end of the window. #### Debounced update completion signals diff --git a/Assets/Talo Game Services/Talo/Runtime/APIs/DebouncedAPI.cs b/Assets/Talo Game Services/Talo/Runtime/APIs/DebouncedAPI.cs index c160726..c5f0d97 100644 --- a/Assets/Talo Game Services/Talo/Runtime/APIs/DebouncedAPI.cs +++ b/Assets/Talo Game Services/Talo/Runtime/APIs/DebouncedAPI.cs @@ -50,31 +50,11 @@ protected Task Debounce(TOperation operation) var op = operations[operation]; - if (!op.windowOpen && !op.isExecuting) - { - op.hasTrailingCallQueued = false; - op.isExecuting = true; - OpenWindow(op); - - var pending = new List>(op.pendingTasks); - op.pendingTasks.Clear(); - - return SettleLeading(operation, op, pending); - } - else - { - 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; + var tcs = new TaskCompletionSource(); + op.pendingTasks.Add(tcs); + op.hasTrailingCallQueued = true; + OpenWindow(op); + return tcs.Task; } private async Task<(bool success, TUpdateResult result)> RunAndSettle(TOperation operation, DebouncedOperation op, List> pending) @@ -118,22 +98,16 @@ public async Task ProcessPendingUpdates() var windowClosed = Time.realtimeSinceStartup >= op.windowEndTime; if (windowClosed) { - if (op.hasTrailingCallQueued) + if (op.hasTrailingCallQueued && !op.isExecuting) + { + keysToProcess.Add(kvp.Key); + } + else if (op.isExecuting) { - if (!op.isExecuting) - { - // window closed with a trailing call pending: execute it - keysToProcess.Add(kvp.Key); - } - else - { - // leading call still in-flight: delay trailing until it completes - OpenWindow(op); - } + OpenWindow(op); } else if (op.windowOpen) { - // window closed with no trailing call: reset for the next leading call op.windowOpen = false; } } diff --git a/Assets/Talo Game Services/Talo/Tests/PlayersAPI/PlayerUpdatedEventTest.cs b/Assets/Talo Game Services/Talo/Tests/PlayersAPI/PlayerUpdatedEventTest.cs index a03af11..81a8200 100644 --- a/Assets/Talo Game Services/Talo/Tests/PlayersAPI/PlayerUpdatedEventTest.cs +++ b/Assets/Talo Game Services/Talo/Tests/PlayersAPI/PlayerUpdatedEventTest.cs @@ -29,7 +29,7 @@ public void SetUp() var tm = new GameObject().AddComponent(); tm.settings = ScriptableObject.CreateInstance(); tm.settings.autoConnectSocket = false; - tm.settings.debounceTimerSeconds = 0.1f; + tm.settings.debounceTimerSeconds = 0f; Talo.CurrentAlias = new PlayerAlias() { player = new Player() { @@ -57,7 +57,7 @@ public void TearDown() } [UnityTest] - public IEnumerator LeadingCall_FiresOnPlayerUpdated() + public IEnumerator TrailingCall_FiresOnPlayerUpdated() { Talo.Players.OnPlayerUpdated += _mock.OnUpdated; @@ -69,14 +69,14 @@ public IEnumerator LeadingCall_FiresOnPlayerUpdated() Talo.CurrentPlayer.SetProp("k1", "v1-updated"); + yield return null; + Assert.AreEqual(1, _mock.updatedCount); Assert.IsTrue(_mock.lastSuccess); - - yield return null; } [UnityTest] - public IEnumerator LeadingAndTrailing_FireOnPlayerUpdatedTwice() + public IEnumerator TrailingCall_FiresOnceForMultipleCalls() { Talo.Players.OnPlayerUpdated += _mock.OnUpdated; @@ -89,13 +89,13 @@ public IEnumerator LeadingAndTrailing_FireOnPlayerUpdatedTwice() Talo.CurrentPlayer.SetProp("k1", "v1-updated"); Talo.CurrentPlayer.SetProp("k2", "v2"); + yield return null; + Assert.AreEqual(1, _mock.updatedCount); var result = Talo.Players.FlushUpdates().GetAwaiter().GetResult(); - Assert.AreEqual(DebouncedAPIBase.FlushResult.Success, result); - Assert.AreEqual(2, _mock.updatedCount); - - yield return null; + Assert.AreEqual(DebouncedAPIBase.FlushResult.NothingPending, result); + Assert.AreEqual(1, _mock.updatedCount); } [UnityTest] @@ -110,51 +110,44 @@ public IEnumerator PropRejection_OnPlayerUpdatedFiresWithRejectedProps() rejectedProps = new[] { new RejectedProp { key = "k1", error = "PROP_VALUE_TOO_LONG", message = "too long" } } })); - var result = Talo.CurrentPlayer.SetProp("k1", "v1-updated").GetAwaiter().GetResult(); + var task = Talo.CurrentPlayer.SetProp("k1", "v1-updated"); + + yield return null; + + var result = task.GetAwaiter().GetResult(); Assert.AreEqual(1, result.RejectedProps.Length); Assert.AreEqual("k1", result.RejectedProps[0].key); Assert.AreEqual(1, _mock.updatedCount); Assert.IsTrue(_mock.lastSuccess); - - yield return null; } [UnityTest] - public IEnumerator HttpError_FiresOnPlayerUpdatedWithFalse() + public IEnumerator TrailingCall_HttpError_FiresOnPlayerUpdatedWithFalse() { Talo.Players.OnPlayerUpdated += _mock.OnUpdated; - // no mock for update means the leading call's Debounce() fails - + // no mock registered — RequestMock.HandleCall throws, simulating an HTTP error Talo.CurrentPlayer.SetProp("k1", "v1-updated"); + yield return null; + Assert.AreEqual(1, _mock.updatedCount); Assert.IsFalse(_mock.lastSuccess); - - yield return null; } [UnityTest] - public IEnumerator TrailingCall_FailsOnHttpError_ReturnsFlushResultFailure() + public IEnumerator TrailingCall_HttpError_FlushReturnsFailure() { Talo.Players.OnPlayerUpdated += _mock.OnUpdated; - var uri = new Uri($"{Talo.Settings.apiUrl}/v1/players/uuid"); - RequestMock.ReplyOnce(uri, "PATCH", JsonUtility.ToJson(new PlayersUpdateResponse - { - player = new Player { id = "uuid" } - })); - + // no mock registered — RequestMock.HandleCall throws, simulating an HTTP error Talo.CurrentPlayer.SetProp("k1", "v1-updated"); - Talo.CurrentPlayer.SetProp("k2", "v2"); - - Assert.AreEqual(1, _mock.updatedCount); - Assert.IsTrue(_mock.lastSuccess); - // trailing call has no mock so flush returns Failure (not throw) var flushResult = Talo.Players.FlushUpdates().GetAwaiter().GetResult(); Assert.AreEqual(DebouncedAPIBase.FlushResult.Failure, flushResult); + Assert.AreEqual(1, _mock.updatedCount); + Assert.IsFalse(_mock.lastSuccess); yield return null; } @@ -179,19 +172,18 @@ public IEnumerator FlushUpdates_WithTrailingQueued_ReturnsSuccessAndFiresEvent() })); Talo.CurrentPlayer.SetProp("k1", "v1-updated"); - Talo.CurrentPlayer.SetProp("k2", "v2"); - Assert.AreEqual(1, _mock.updatedCount); + Assert.AreEqual(0, _mock.updatedCount); var result = Talo.Players.FlushUpdates().GetAwaiter().GetResult(); Assert.AreEqual(DebouncedAPIBase.FlushResult.Success, result); - Assert.AreEqual(2, _mock.updatedCount); + Assert.AreEqual(1, _mock.updatedCount); yield return null; } [UnityTest] - public IEnumerator SetProp_ReturnsResultInline() + public IEnumerator SetProp_ReturnsResult() { var uri = new Uri($"{Talo.Settings.apiUrl}/v1/players/uuid"); RequestMock.ReplyOnce(uri, "PATCH", JsonUtility.ToJson(new PlayersUpdateResponse @@ -199,12 +191,14 @@ public IEnumerator SetProp_ReturnsResultInline() player = new Player { id = "uuid" } })); - var result = Talo.CurrentPlayer.SetProp("k1", "v1-updated").GetAwaiter().GetResult(); + var task = Talo.CurrentPlayer.SetProp("k1", "v1-updated"); + + yield return null; + + var result = task.GetAwaiter().GetResult(); Assert.IsTrue(result.Success); Assert.AreEqual(0, result.RejectedProps.Length); - - yield return null; } [UnityTest] diff --git a/Assets/Talo Game Services/Talo/Tests/SavesAPI/SaveUpdatedEventTest.cs b/Assets/Talo Game Services/Talo/Tests/SavesAPI/SaveUpdatedEventTest.cs index efa6233..506e49a 100644 --- a/Assets/Talo Game Services/Talo/Tests/SavesAPI/SaveUpdatedEventTest.cs +++ b/Assets/Talo Game Services/Talo/Tests/SavesAPI/SaveUpdatedEventTest.cs @@ -32,7 +32,7 @@ public void SetUp() var tm = new GameObject().AddComponent(); tm.settings = ScriptableObject.CreateInstance(); tm.settings.autoConnectSocket = false; - tm.settings.debounceTimerSeconds = 0.1f; + tm.settings.debounceTimerSeconds = 0f; Talo.CurrentAlias = new PlayerAlias() { player = new Player() { @@ -85,7 +85,7 @@ private static string PatchedSaveJson() } [UnityTest] - public IEnumerator LeadingCall_FiresOnSaveUpdated() + public IEnumerator TrailingCall_FiresOnSaveUpdated() { var api = BuildApiWithChosenSave(1, "Online Save"); Talo.Saves.OnSaveUpdated += _mock.OnUpdated; @@ -95,16 +95,16 @@ public IEnumerator LeadingCall_FiresOnSaveUpdated() _ = Talo.Saves.DebounceUpdate(); + yield return null; + Assert.AreEqual(1, _mock.updatedCount); Assert.IsTrue(_mock.lastSuccess); Assert.IsNotNull(_mock.lastSave); Assert.AreEqual(1, _mock.lastSave.id); - - yield return null; } [UnityTest] - public IEnumerator LeadingAndTrailing_FireOnSaveUpdatedTwice() + public IEnumerator TrailingCall_FiresOnceForMultipleCalls() { var api = BuildApiWithChosenSave(1, "Online Save"); Talo.Saves.OnSaveUpdated += _mock.OnUpdated; @@ -115,49 +115,42 @@ public IEnumerator LeadingAndTrailing_FireOnSaveUpdatedTwice() _ = Talo.Saves.DebounceUpdate(); _ = Talo.Saves.DebounceUpdate(); + yield return null; + Assert.AreEqual(1, _mock.updatedCount); var result = Talo.Saves.FlushUpdates().GetAwaiter().GetResult(); - Assert.AreEqual(DebouncedAPIBase.FlushResult.Success, result); - Assert.AreEqual(2, _mock.updatedCount); - - yield return null; + Assert.AreEqual(DebouncedAPIBase.FlushResult.NothingPending, result); + Assert.AreEqual(1, _mock.updatedCount); } [UnityTest] - public IEnumerator HttpError_FiresOnSaveUpdatedWithFalse() + public IEnumerator TrailingCall_HttpError_FiresOnSaveUpdatedWithFalse() { BuildApiWithChosenSave(1, "Online Save"); Talo.Saves.OnSaveUpdated += _mock.OnUpdated; - // no mock for update means the leading call's Debounce() fails _ = Talo.Saves.DebounceUpdate(); + yield return null; + Assert.AreEqual(1, _mock.updatedCount); Assert.IsFalse(_mock.lastSuccess); Assert.IsNull(_mock.lastSave); - - yield return null; } [UnityTest] - public IEnumerator TrailingCall_FailsOnHttpError_ReturnsFlushResultFailure() + public IEnumerator TrailingCall_HttpError_FlushReturnsFailure() { - var api = BuildApiWithChosenSave(1, "Online Save"); + BuildApiWithChosenSave(1, "Online Save"); Talo.Saves.OnSaveUpdated += _mock.OnUpdated; - var uri = new Uri(api.GetUri() + "/1"); - RequestMock.ReplyOnce(uri, "PATCH", PatchedSaveJson()); - _ = Talo.Saves.DebounceUpdate(); - _ = Talo.Saves.DebounceUpdate(); - - Assert.AreEqual(1, _mock.updatedCount); - Assert.IsTrue(_mock.lastSuccess); - // trailing call has no mock so flush returns Failure (not throw) var flushResult = Talo.Saves.FlushUpdates().GetAwaiter().GetResult(); Assert.AreEqual(DebouncedAPIBase.FlushResult.Failure, flushResult); + Assert.AreEqual(1, _mock.updatedCount); + Assert.IsFalse(_mock.lastSuccess); yield return null; }