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
10 changes: 7 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<TOperation>` (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<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.

To add debouncing to an API:
1. Define a public `enum DebouncedOperation` with your debounced operations
2. Inherit from `DebouncedAPI<YourAPI.DebouncedOperation>`
2. Inherit from `DebouncedAPI<YourAPI.DebouncedOperation, TReturnData, TUpdateResult>`
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<PlayersAPI.DebouncedOperation>`. 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 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<PlayerUpdateResult>`, `Talo.Saves.UpdateCurrentSave()` returns `Task<SaveUpdateResult>`. 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

Expand Down
121 changes: 104 additions & 17 deletions Assets/Talo Game Services/Talo/Runtime/APIs/DebouncedAPI.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,34 @@

namespace TaloGameServices
{
public abstract class DebouncedAPI<TOperation> : 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<TOperation, TReturnData, TUpdateResult> : DebouncedAPIBase where TOperation : Enum
{
private class DebouncedOperation
{
public float windowEndTime;
public bool windowOpen;
public bool hasTrailingCallQueued;
public bool isExecuting;
public Task<TReturnData> currentTask;
public List<TaskCompletionSource<TUpdateResult>> pendingTasks = new();
}

private readonly Dictionary<TOperation, DebouncedOperation> operations = new();

protected event Action<bool, TReturnData> OnOperationSettled;

protected DebouncedAPI(string service) : base(service) { }

private void OpenWindow(DebouncedOperation op)
Expand All @@ -25,7 +41,7 @@ private void OpenWindow(DebouncedOperation op)
op.windowEndTime = Time.realtimeSinceStartup + Talo.Settings.debounceTimerSeconds;
}

protected void Debounce(TOperation operation)
protected Task<TUpdateResult> Debounce(TOperation operation)
{
if (!operations.ContainsKey(operation))
{
Expand All @@ -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<TaskCompletionSource<TUpdateResult>>(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<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;
}

private async Task<(bool success, TUpdateResult result)> RunAndSettle(TOperation operation, DebouncedOperation op, List<TaskCompletionSource<TUpdateResult>> 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()
Expand Down Expand Up @@ -93,18 +144,54 @@ public async Task ProcessPendingUpdates()
var op = operations[key];
op.hasTrailingCallQueued = false;
op.isExecuting = true;
try

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

await RunAndSettle(key, op, pending);
}
}

public async Task<FlushResult> FlushUpdates()
{
var result = FlushResult.NothingPending;

var keys = new List<TOperation>(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<TaskCompletionSource<TUpdateResult>>(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<TReturnData> ExecuteDebouncedOperation(TOperation operation);
protected abstract TUpdateResult BuildResult(bool success, TReturnData returnData);
}
}
46 changes: 37 additions & 9 deletions Assets/Talo Game Services/Talo/Runtime/APIs/PlayersAPI.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ public class MergeOptions
public string postMergeIdentityService = "";
}

public class PlayersAPI : DebouncedAPI<PlayersAPI.DebouncedOperation>
public class PlayersAPI : DebouncedAPI<PlayersAPI.DebouncedOperation, RejectedProp[], PlayersAPI.PlayerUpdateResult>
{
public enum DebouncedOperation
{
Expand All @@ -21,10 +21,12 @@ public enum DebouncedOperation
public event Action<IdentifyException> OnIdentificationFailed;
public event Action OnIdentityCleared;
public event Action<RejectedProp[]> OnPropsRejected;
public event Action<bool> OnPlayerUpdated;

public PlayersAPI() : base("v1/players")
{
Talo.OnConnectionRestored += OnConnectionRestored;
OnOperationSettled += (success, _) => OnPlayerUpdated?.Invoke(success);
}

private async void OnConnectionRestored()
Expand Down Expand Up @@ -132,22 +134,36 @@ string playerId
return await Identify("game_center", identifier);
}

protected override async Task ExecuteDebouncedOperation(DebouncedOperation operation)
protected override async Task<RejectedProp[]> 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<PlayerUpdateResult> DebounceUpdate()
{
Debounce(DebouncedOperation.Update);
return Debounce(DebouncedOperation.Update);
}

public async Task<Player> Update()
{
await RunUpdate();
return Talo.CurrentPlayer;
}

private async Task<RejectedProp[]> RunUpdate()
{
Talo.IdentityCheck();

Expand All @@ -164,7 +180,7 @@ public async Task<Player> Update()
OnPropsRejected?.Invoke(res.rejectedProps);
}

return Talo.CurrentPlayer;
return res.rejectedProps ?? Array.Empty<RejectedProp>();
}

public async Task<Player> Merge(string playerId1, string playerId2, MergeOptions options = null)
Expand Down Expand Up @@ -258,5 +274,17 @@ public async Task<string> 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<RejectedProp>();
}
}
}
}
Loading
Loading