Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<TOperation, TReturnData, TUpdateResult>` 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
Expand All @@ -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<PlayersAPI.DebouncedOperation, RejectedProp[], PlayersAPI.PlayerUpdateResult>`. 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<PlayersAPI.DebouncedOperation, RejectedProp[], PlayersAPI.PlayerUpdateResult>`. 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

Expand Down
48 changes: 11 additions & 37 deletions Assets/Talo Game Services/Talo/Runtime/APIs/DebouncedAPI.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,31 +50,11 @@ protected Task<TUpdateResult> Debounce(TOperation operation)

var op = operations[operation];

if (!op.windowOpen && !op.isExecuting)
{
op.hasTrailingCallQueued = false;
op.isExecuting = true;
OpenWindow(op);

var pending = new List<TaskCompletionSource<TUpdateResult>>(op.pendingTasks);
op.pendingTasks.Clear();

return SettleLeading(operation, op, pending);
}
else
{
var tcs = new TaskCompletionSource<TUpdateResult>();
op.pendingTasks.Add(tcs);
op.hasTrailingCallQueued = true;
OpenWindow(op);
return tcs.Task;
}
}

private async Task<TUpdateResult> SettleLeading(TOperation operation, DebouncedOperation op, List<TaskCompletionSource<TUpdateResult>> pending)
{
(_, var result) = await RunAndSettle(operation, op, pending);
return result;
var tcs = new TaskCompletionSource<TUpdateResult>();
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<TaskCompletionSource<TUpdateResult>> pending)
Expand Down Expand Up @@ -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;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public void SetUp()
var tm = new GameObject().AddComponent<TaloManager>();
tm.settings = ScriptableObject.CreateInstance<TaloSettings>();
tm.settings.autoConnectSocket = false;
tm.settings.debounceTimerSeconds = 0.1f;
tm.settings.debounceTimerSeconds = 0f;

Talo.CurrentAlias = new PlayerAlias() {
player = new Player() {
Expand Down Expand Up @@ -57,7 +57,7 @@ public void TearDown()
}

[UnityTest]
public IEnumerator LeadingCall_FiresOnPlayerUpdated()
public IEnumerator TrailingCall_FiresOnPlayerUpdated()
{
Talo.Players.OnPlayerUpdated += _mock.OnUpdated;

Expand All @@ -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;

Expand All @@ -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]
Expand All @@ -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;
}
Expand All @@ -179,32 +172,33 @@ 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
{
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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public void SetUp()
var tm = new GameObject().AddComponent<TaloManager>();
tm.settings = ScriptableObject.CreateInstance<TaloSettings>();
tm.settings.autoConnectSocket = false;
tm.settings.debounceTimerSeconds = 0.1f;
tm.settings.debounceTimerSeconds = 0f;

Talo.CurrentAlias = new PlayerAlias() {
player = new Player() {
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
}
Expand Down
Loading