diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 643767a..6319c88 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -21,8 +21,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - pull-requests: read - issues: read + pull-requests: write id-token: write steps: @@ -37,22 +36,67 @@ jobs: with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} prompt: | - Please review this pull request and provide feedback on: - - Code quality and best practices - - Potential bugs or issues - - Performance considerations - - Security concerns - - Backwards compatibility - - For each of the points above, do not point out what works well, only what could be improved (if anything). - Be constructive and helpful in your feedback but do not repeat yourself - only summarise potential issues. - Test coverage is currently not a priority. - Prefix section headers with emojis and use dividers for better readability. - - Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback. - Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR. - - # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md - # or https://docs.anthropic.com/en/docs/claude-code/sdk#command-line for available options - claude_args: '--model sonnet --allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"' - use_sticky_comment: true + You are reviewing PR #${{ github.event.pull_request.number }} in the repository ${{ github.repository }}. + + Review this pull request and provide feedback focused only on improvements needed (not what works well): + + **Categories to check:** + 1. Code quality and best practices + 2. Potential bugs or issues + 3. Performance considerations + 4. Security concerns + 5. Backwards compatibility + + **Process:** + - The PR number is: ${{ github.event.pull_request.number }} + - View the PR using: `gh pr view ${{ github.event.pull_request.number }} --repo ${{ github.repository }}` + - Read CLAUDE.md to understand best practices + - View the PR diff using: `gh pr diff ${{ github.event.pull_request.number }} --repo ${{ github.repository }}` + - If an issue spans multiple categories, list it only once in the most relevant section + - Prioritize by severity: 🔴 Critical → 🟡 Major → 🔵 Minor + - Focus only on changes introduced in this PR, not pre-existing code issues + - Test coverage is currently not a priority + + **Review Workflow (Follow these steps):** + 1. **Analysis Phase**: Review the PR diff and identify potential issues + 2. **Validation Phase**: For each issue you find, verify it by: + - Re-reading the relevant code carefully + - Checking if your suggested fix is actually different from the current code + - Confirming the issue violates documented standards (check CLAUDE.md) + - Ensuring your criticism is actionable and specific + 3. **Draft Phase**: Write your review only after validating all issues + 4. **Quality Check**: Before posting, remove any issues where: + - Your "before" and "after" code snippets are identical + - You're uncertain or use phrases like "appears", "might", "should verify" + - The issue is theoretical without clear impact + 5. **Post Phase**: Only post the review if you have concrete, validated feedback + + **Edge Case Policy:** + Only flag edge cases that meet ALL of these criteria: + 1. Realistic: Could happen in normal usage or common error scenarios + 2. Impactful: Would cause bugs, security issues, or data problems (not just "it's not perfect") + 3. Actionable: Can be fixed with reasonable effort in this PR's scope + + Ignore theoretical issues that require multiple unlikely conditions or malicious input patterns. + Use the "would this bother a pragmatic senior developer?" test. + + Maximum chain of assumptions: 2 levels deep. Skip exotic input combinations that violate documented assumptions. + + **Feedback style:** + - Provide specific code examples or line references showing the issue + - Suggest concrete fixes with code snippets where helpful + - Keep total feedback under 500 words + - Use section headers with emojis and horizontal dividers (---) + - If no improvements needed in a category, simply state "No issues found" + - Use neutral language; focus on the code, not the author + - If the PR looks good overall, say so clearly rather than forcing criticism + + **Comment Management (IMPORTANT):** + Post your review using this command, which will edit your last comment if one exists, or create a new one: + ```bash + gh pr comment ${{ github.event.pull_request.number }} --repo ${{ github.repository }} --edit-last --create-if-none --body "" + ``` + + Ensure proper escaping of quotes and special characters in the comment body. Use single quotes around the body and escape any single quotes inside with '\'' + claude_args: | + --allowedTools "Read,Bash(gh pr:*),Grep,Glob" diff --git a/.vscode/settings.json b/.vscode/settings.json index 071ec02..0bd55fe 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,7 +3,7 @@ "files.exclude": { "**/*.meta": true }, - "dotnet.defaultSolution": "unity.sln", + "dotnet.defaultSolution": "unity.slnx", "editor.tabSize": 4, "editor.indentSize": "tabSize" } \ No newline at end of file diff --git a/Assets/Talo Game Services/Talo/Runtime/APIs/BaseAPI.cs b/Assets/Talo Game Services/Talo/Runtime/APIs/BaseAPI.cs index 77076c2..927b737 100644 --- a/Assets/Talo Game Services/Talo/Runtime/APIs/BaseAPI.cs +++ b/Assets/Talo Game Services/Talo/Runtime/APIs/BaseAPI.cs @@ -9,7 +9,7 @@ namespace TaloGameServices public class BaseAPI { // automatically updated with a pre-commit hook - private const string ClientVersion = "0.49.0"; + private const string ClientVersion = "0.50.0"; protected string baseUrl; diff --git a/Assets/Talo Game Services/Talo/Runtime/APIs/DebouncedAPI.cs b/Assets/Talo Game Services/Talo/Runtime/APIs/DebouncedAPI.cs new file mode 100644 index 0000000..40f960a --- /dev/null +++ b/Assets/Talo Game Services/Talo/Runtime/APIs/DebouncedAPI.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using UnityEngine; + +namespace TaloGameServices +{ + public abstract class DebouncedAPI : BaseAPI where TOperation : Enum + { + private class DebouncedOperation + { + public float nextUpdateTime; + public bool hasPending; + } + + private readonly Dictionary operations = new(); + + protected DebouncedAPI(string service) : base(service) { } + + protected void Debounce(TOperation operation) + { + if (!operations.ContainsKey(operation)) + { + operations[operation] = new DebouncedOperation(); + } + + operations[operation].nextUpdateTime = Time.realtimeSinceStartup + Talo.Settings.debounceTimerSeconds; + operations[operation].hasPending = true; + } + + public async Task ProcessPendingUpdates() + { + var keysToProcess = new List(); + + foreach (var kvp in operations) + { + if (kvp.Value.hasPending && Time.realtimeSinceStartup >= kvp.Value.nextUpdateTime) + { + keysToProcess.Add(kvp.Key); + } + } + + foreach (var key in keysToProcess) + { + operations[key].hasPending = false; + await ExecuteDebouncedOperation(key); + } + } + + protected abstract Task ExecuteDebouncedOperation(TOperation operation); + } +} diff --git a/Assets/Talo Game Services/Talo/Runtime/APIs/DebouncedAPI.cs.meta b/Assets/Talo Game Services/Talo/Runtime/APIs/DebouncedAPI.cs.meta new file mode 100644 index 0000000..0bdbf4d --- /dev/null +++ b/Assets/Talo Game Services/Talo/Runtime/APIs/DebouncedAPI.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7c9d1e2f3a4b5c6d7e8f9a0b1c2d3e4f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Talo Game Services/Talo/Runtime/APIs/EventsAPI.cs b/Assets/Talo Game Services/Talo/Runtime/APIs/EventsAPI.cs index ca05eac..4af82fa 100644 --- a/Assets/Talo Game Services/Talo/Runtime/APIs/EventsAPI.cs +++ b/Assets/Talo Game Services/Talo/Runtime/APIs/EventsAPI.cs @@ -80,15 +80,15 @@ public async Task Flush() { Talo.IdentityCheck(); - var eventsToSend = queue.ToArray(); - if (eventsToSend.Length == 0) + if (lockFlushes) { + flushAttemptedDuringLock = true; return; } - if (lockFlushes) + var eventsToSend = queue.ToArray(); + if (eventsToSend.Length == 0) { - flushAttemptedDuringLock = true; return; } @@ -103,14 +103,16 @@ public async Task Flush() await Call(uri, "POST", content); OnFlushed.Invoke(); - - eventsToFlush.Clear(); - lockFlushes = false; } catch (Exception ex) { Debug.LogError(ex.Message); } + finally + { + eventsToFlush.Clear(); + lockFlushes = false; + } if (flushAttemptedDuringLock) { diff --git a/Assets/Talo Game Services/Talo/Runtime/APIs/HealthCheckAPI.cs b/Assets/Talo Game Services/Talo/Runtime/APIs/HealthCheckAPI.cs index 246a685..c438d7d 100644 --- a/Assets/Talo Game Services/Talo/Runtime/APIs/HealthCheckAPI.cs +++ b/Assets/Talo Game Services/Talo/Runtime/APIs/HealthCheckAPI.cs @@ -14,6 +14,7 @@ public enum HealthCheckStatus public class HealthCheckAPI : BaseAPI { private HealthCheckStatus lastHealthCheckStatus = HealthCheckStatus.UNKNOWN; + private float nextPingTime; public HealthCheckAPI() : base("v1/health-check") { } @@ -24,6 +25,12 @@ public HealthCheckStatus GetLastStatus() public async Task Ping() { + var bustCache = lastHealthCheckStatus == HealthCheckStatus.UNKNOWN || Time.realtimeSinceStartup >= nextPingTime; + if (!bustCache) + { + return lastHealthCheckStatus == HealthCheckStatus.OK; + } + var uri = new Uri(baseUrl); bool success; @@ -57,6 +64,8 @@ public async Task Ping() } } + nextPingTime = Time.realtimeSinceStartup + Talo.Settings.debounceTimerSeconds; + return success; } } diff --git a/Assets/Talo Game Services/Talo/Runtime/APIs/LeaderboardsAPI.cs b/Assets/Talo Game Services/Talo/Runtime/APIs/LeaderboardsAPI.cs index 0dfbca1..bafd90d 100644 --- a/Assets/Talo Game Services/Talo/Runtime/APIs/LeaderboardsAPI.cs +++ b/Assets/Talo Game Services/Talo/Runtime/APIs/LeaderboardsAPI.cs @@ -110,7 +110,7 @@ public async Task GetEntriesForCurrentPlayer(string var json = await Call(uri, "POST", Prop.SanitiseJson(content)); var res = JsonUtility.FromJson(json); - _entriesManager.UpsertEntry(internalName, res.entry); + _entriesManager.UpsertEntry(internalName, res.entry, true); return (res.entry, res.updated); } diff --git a/Assets/Talo Game Services/Talo/Runtime/APIs/PlayersAPI.cs b/Assets/Talo Game Services/Talo/Runtime/APIs/PlayersAPI.cs index fbb0000..6b3d100 100644 --- a/Assets/Talo Game Services/Talo/Runtime/APIs/PlayersAPI.cs +++ b/Assets/Talo Game Services/Talo/Runtime/APIs/PlayersAPI.cs @@ -10,8 +10,13 @@ public class MergeOptions public string postMergeIdentityService = ""; } - public class PlayersAPI : BaseAPI + public class PlayersAPI : DebouncedAPI { + public enum DebouncedOperation + { + Update + } + public event Action OnIdentified; public event Action OnIdentificationStarted; public event Action OnIdentificationFailed; @@ -103,6 +108,21 @@ public async Task IdentifySteam(string ticket, string identity = "") return Talo.CurrentPlayer; } + protected override async Task ExecuteDebouncedOperation(DebouncedOperation operation) + { + switch (operation) + { + case DebouncedOperation.Update: + await Update(); + break; + } + } + + public void DebounceUpdate() + { + Debounce(DebouncedOperation.Update); + } + public async Task Update() { Talo.IdentityCheck(); diff --git a/Assets/Talo Game Services/Talo/Runtime/APIs/SavesAPI.cs b/Assets/Talo Game Services/Talo/Runtime/APIs/SavesAPI.cs index 81a9032..203c210 100644 --- a/Assets/Talo Game Services/Talo/Runtime/APIs/SavesAPI.cs +++ b/Assets/Talo Game Services/Talo/Runtime/APIs/SavesAPI.cs @@ -6,8 +6,13 @@ namespace TaloGameServices { - public class SavesAPI : BaseAPI + public class SavesAPI : DebouncedAPI { + public enum DebouncedOperation + { + Update + } + internal SavesManager savesManager; internal SavesContentManager contentManager; @@ -186,9 +191,43 @@ public async Task CreateSave(string saveName, SaveContent content = nu return savesManager.CreateSave(save); } + protected override async Task ExecuteDebouncedOperation(DebouncedOperation operation) + { + switch (operation) + { + case DebouncedOperation.Update: + var currentSave = savesManager.CurrentSave; + if (currentSave != null) + { + await UpdateSave(currentSave.id); + } + break; + } + } + + public void DebounceUpdate() + { + Debounce(DebouncedOperation.Update); + } + public async Task UpdateCurrentSave(string newName = "") { - return await UpdateSave(savesManager.CurrentSave.id, newName); + var currentSave = savesManager.CurrentSave; + if (currentSave == null) + { + throw new Exception("No save is currently loaded"); + } + + // if the save is being renamed, sync it immediately + if (!string.IsNullOrEmpty(newName)) + { + return await UpdateSave(currentSave.id, newName); + } + + // else, update the save locally and queue it for syncing + currentSave.content = contentManager.Content; + DebounceUpdate(); + return currentSave; } public async Task UpdateSave(int saveId, string newName = "") diff --git a/Assets/Talo Game Services/Talo/Runtime/Entities/Player.cs b/Assets/Talo Game Services/Talo/Runtime/Entities/Player.cs index 694dddc..30d8464 100644 --- a/Assets/Talo Game Services/Talo/Runtime/Entities/Player.cs +++ b/Assets/Talo Game Services/Talo/Runtime/Entities/Player.cs @@ -1,7 +1,6 @@ using UnityEngine; using System.Linq; using System; -using System.Threading.Tasks; namespace TaloGameServices { @@ -17,23 +16,23 @@ public override string ToString() return JsonUtility.ToJson(this); } - public async Task SetProp(string key, string value, bool update = true) + public void SetProp(string key, string value, bool update = true) { base.SetProp(key, value); if (update) { - await Talo.Players.Update(); + Talo.Players.DebounceUpdate(); } } - public async Task DeleteProp(string key, bool update = true) + public void DeleteProp(string key, bool update = true) { base.DeleteProp(key); if (update) { - await Talo.Players.Update(); + Talo.Players.DebounceUpdate(); } } diff --git a/Assets/Talo Game Services/Talo/Runtime/TaloManager.cs b/Assets/Talo Game Services/Talo/Runtime/TaloManager.cs index d8056d1..b84a689 100644 --- a/Assets/Talo Game Services/Talo/Runtime/TaloManager.cs +++ b/Assets/Talo Game Services/Talo/Runtime/TaloManager.cs @@ -1,3 +1,4 @@ +using System; using UnityEngine; namespace TaloGameServices @@ -42,13 +43,20 @@ private void OnApplicationPause(bool isPaused) private async void DoFlush() { - if (Talo.HasIdentity()) + try { - await Talo.Events.Flush(); + if (Talo.HasIdentity()) + { + await Talo.Events.Flush(); + } + } + catch (Exception ex) + { + Debug.LogError($"Failed to flush events: {ex}"); } } - private async void Update() + private void Update() { if (Application.platform == RuntimePlatform.WebGLPlayer) { @@ -65,10 +73,44 @@ private async void Update() tmrContinuity += Time.deltaTime; if (tmrContinuity >= 10f) { - await Talo.Continuity.ProcessRequests(); + ProcessContinuityRequests(); tmrContinuity = 0; } } + + ProcessDebouncedUpdates(); + } + + private async void ProcessContinuityRequests() + { + try + { + await Talo.Continuity.ProcessRequests(); + } + catch (Exception ex) + { + Debug.LogError($"Failed to process continuity requests: {ex}"); + } + } + + private async void ProcessDebouncedUpdates() + { + try + { + if (Talo.HasIdentity()) + { + await Talo.Players.ProcessPendingUpdates(); + } + + if (Talo.Saves.Current != null) + { + await Talo.Saves.ProcessPendingUpdates(); + } + } + catch (Exception ex) + { + Debug.LogError($"Failed to process debounced updates: {ex}"); + } } public void ResetFlushTimer() diff --git a/Assets/Talo Game Services/Talo/Runtime/TaloSettings.cs b/Assets/Talo Game Services/Talo/Runtime/TaloSettings.cs index 5a933cf..f81dbc5 100644 --- a/Assets/Talo Game Services/Talo/Runtime/TaloSettings.cs +++ b/Assets/Talo Game Services/Talo/Runtime/TaloSettings.cs @@ -34,5 +34,8 @@ public class TaloSettings : ScriptableObject [Tooltip("If enabled, Talo will automatically cache the player after a successful online identification for use in later offline sessions")] public bool cachePlayerOnIdentify = true; + + [Tooltip("Number of seconds to wait before sending debounced requests (e.g. player updates, save updates and health checks)")] + public float debounceTimerSeconds = 1f; } } diff --git a/Assets/Talo Game Services/Talo/Runtime/Utils/LeaderboardEntriesManager.cs b/Assets/Talo Game Services/Talo/Runtime/Utils/LeaderboardEntriesManager.cs index 572c68e..a086cfa 100644 --- a/Assets/Talo Game Services/Talo/Runtime/Utils/LeaderboardEntriesManager.cs +++ b/Assets/Talo Game Services/Talo/Runtime/Utils/LeaderboardEntriesManager.cs @@ -16,7 +16,7 @@ public List GetEntries(string internalName) return _currentEntries[internalName]; } - public void UpsertEntry(string internalName, LeaderboardEntry entry) + public void UpsertEntry(string internalName, LeaderboardEntry entry, bool bumpPositions = false) { if (!_currentEntries.ContainsKey(internalName)) { @@ -31,9 +31,15 @@ public void UpsertEntry(string internalName, LeaderboardEntry entry) int insertPosition = FindInsertPosition(entries, entry); entries.Insert(insertPosition, entry); - for (int idx = 0; idx < entries.Count; idx++) + if (bumpPositions) { - entries[idx].position = idx; + foreach (var e in entries) + { + if (e.id != entry.id && e.position >= entry.position) + { + e.position += 1; + } + } } } diff --git a/Assets/Talo Game Services/Talo/Samples/Playground/Scripts/Players/DeleteProp.cs b/Assets/Talo Game Services/Talo/Samples/Playground/Scripts/Players/DeleteProp.cs index 527219a..57a6e4f 100644 --- a/Assets/Talo Game Services/Talo/Samples/Playground/Scripts/Players/DeleteProp.cs +++ b/Assets/Talo Game Services/Talo/Samples/Playground/Scripts/Players/DeleteProp.cs @@ -8,12 +8,12 @@ public class DeleteHealthProp : MonoBehaviour { public string key; - public async void OnButtonClick() + public void OnButtonClick() { - await DeleteProp(); + DeleteProp(); } - private async Task DeleteProp() + private void DeleteProp() { if (string.IsNullOrEmpty(key)) { @@ -23,7 +23,7 @@ private async Task DeleteProp() try { - await Talo.CurrentPlayer.DeleteProp(key); + Talo.CurrentPlayer.DeleteProp(key); ResponseMessage.SetText($"{key} deleted"); } catch (Exception ex) 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 4a785ad..ea9445a 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,12 +7,12 @@ public class SetProp : MonoBehaviour { public string key, value; - public async void OnButtonClick() + public void OnButtonClick() { - await UpdateProp(); + UpdateProp(); } - private async Task UpdateProp() + private void UpdateProp() { if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(value)) { @@ -22,7 +22,7 @@ private async Task UpdateProp() try { - await Talo.CurrentPlayer.SetProp(key, value); + Talo.CurrentPlayer.SetProp(key, value); ResponseMessage.SetText($"{key} set to {value}"); } catch (System.Exception ex) diff --git a/Assets/Talo Game Services/Talo/Tests/LeaderboardsAPI/LeaderboardEntriesManagerTests.cs b/Assets/Talo Game Services/Talo/Tests/LeaderboardsAPI/LeaderboardEntriesManagerTests.cs index 67e7e6c..4fff1e0 100644 --- a/Assets/Talo Game Services/Talo/Tests/LeaderboardsAPI/LeaderboardEntriesManagerTests.cs +++ b/Assets/Talo Game Services/Talo/Tests/LeaderboardsAPI/LeaderboardEntriesManagerTests.cs @@ -15,21 +15,21 @@ public void SetUp() [Test] public void UpsertEntry_DescendingSort_InsertsInCorrectPosition() { - var entry1 = new LeaderboardEntry { id = 1, score = 100f, leaderboardSortMode = "desc" }; - var entry2 = new LeaderboardEntry { id = 2, score = 80f, leaderboardSortMode = "desc" }; - var entry3 = new LeaderboardEntry { id = 3, score = 90f, leaderboardSortMode = "desc" }; + var entry1 = new LeaderboardEntry { id = 1, score = 100f, position = 0, leaderboardSortMode = "desc" }; + var entry2 = new LeaderboardEntry { id = 2, score = 80f, position = 2, leaderboardSortMode = "desc" }; + var entry3 = new LeaderboardEntry { id = 3, score = 90f, position = 1, leaderboardSortMode = "desc" }; manager.UpsertEntry("test", entry1); manager.UpsertEntry("test", entry2); manager.UpsertEntry("test", entry3); var entries = manager.GetEntries("test"); - + Assert.AreEqual(3, entries.Count); Assert.AreEqual(100f, entries[0].score); Assert.AreEqual(90f, entries[1].score); Assert.AreEqual(80f, entries[2].score); - + Assert.AreEqual(0, entries[0].position); Assert.AreEqual(1, entries[1].position); Assert.AreEqual(2, entries[2].position); @@ -38,21 +38,21 @@ public void UpsertEntry_DescendingSort_InsertsInCorrectPosition() [Test] public void UpsertEntry_AscendingSort_InsertsInCorrectPosition() { - var entry1 = new LeaderboardEntry { id = 1, score = 100f, leaderboardSortMode = "asc" }; - var entry2 = new LeaderboardEntry { id = 2, score = 80f, leaderboardSortMode = "asc" }; - var entry3 = new LeaderboardEntry { id = 3, score = 90f, leaderboardSortMode = "asc" }; + var entry1 = new LeaderboardEntry { id = 1, score = 100f, position = 2, leaderboardSortMode = "asc" }; + var entry2 = new LeaderboardEntry { id = 2, score = 80f, position = 0, leaderboardSortMode = "asc" }; + var entry3 = new LeaderboardEntry { id = 3, score = 90f, position = 1, leaderboardSortMode = "asc" }; manager.UpsertEntry("test", entry1); manager.UpsertEntry("test", entry2); manager.UpsertEntry("test", entry3); var entries = manager.GetEntries("test"); - + Assert.AreEqual(3, entries.Count); Assert.AreEqual(80f, entries[0].score); Assert.AreEqual(90f, entries[1].score); Assert.AreEqual(100f, entries[2].score); - + Assert.AreEqual(0, entries[0].position); Assert.AreEqual(1, entries[1].position); Assert.AreEqual(2, entries[2].position); @@ -62,39 +62,40 @@ public void UpsertEntry_AscendingSort_InsertsInCorrectPosition() public void UpsertEntry_UpdateExistingEntry_MaintainsCorrectOrder() { // highest score - var entry1 = new LeaderboardEntry { id = 1, score = 100f, leaderboardSortMode = "desc" }; + var entry1 = new LeaderboardEntry { id = 1, score = 100f, position = 0, leaderboardSortMode = "desc" }; manager.UpsertEntry("test", entry1); - + // should go after entry1 - var entry2 = new LeaderboardEntry { id = 2, score = 80f, leaderboardSortMode = "desc" }; + var entry2 = new LeaderboardEntry { id = 2, score = 80f, position = 1, leaderboardSortMode = "desc" }; manager.UpsertEntry("test", entry2); - + // update entry1 to have the lowest score - should move to end - var updatedEntry1 = new LeaderboardEntry { id = 1, score = 70f, leaderboardSortMode = "desc" }; + var updatedEntry1 = new LeaderboardEntry { id = 1, score = 70f, position = 1, leaderboardSortMode = "desc" }; manager.UpsertEntry("test", updatedEntry1); - var entries = manager.GetEntries("test"); + var entries = manager.GetEntries("test"); Assert.AreEqual(2, entries.Count); Assert.AreEqual(2, entries[0].id); // entry2 should be first Assert.AreEqual(80f, entries[0].score); - Assert.AreEqual(1, entries[1].id); // updated entry1 should be second + Assert.AreEqual(1, entries[1].id); // updated entry1 should be second Assert.AreEqual(70f, entries[1].score); - - Assert.AreEqual(0, entries[0].position); + + // Positions preserved as-is when not bumping (intentional) + Assert.AreEqual(1, entries[0].position); Assert.AreEqual(1, entries[1].position); } [Test] public void UpsertEntry_EmptyList_InsertsFirstEntry() { - var entry = new LeaderboardEntry { id = 1, score = 100f, leaderboardSortMode = "desc" }; + var entry = new LeaderboardEntry { id = 1, score = 100f, position = 0, leaderboardSortMode = "desc" }; manager.UpsertEntry("test", entry); var entries = manager.GetEntries("test"); - + Assert.AreEqual(1, entries.Count); Assert.AreEqual(100f, entries[0].score); Assert.AreEqual(0, entries[0].position); @@ -103,17 +104,19 @@ public void UpsertEntry_EmptyList_InsertsFirstEntry() [Test] public void UpsertEntry_EqualScores_OrdersByCreatedAt() { - var earlierEntry = new LeaderboardEntry - { - id = 1, - score = 100f, + var earlierEntry = new LeaderboardEntry + { + id = 1, + score = 100f, + position = 0, leaderboardSortMode = "desc", createdAt = "2025-09-13T10:00:00Z" }; - var laterEntry = new LeaderboardEntry - { - id = 2, - score = 100f, + var laterEntry = new LeaderboardEntry + { + id = 2, + score = 100f, + position = 1, leaderboardSortMode = "desc", createdAt = "2025-09-13T11:00:00Z" }; @@ -122,12 +125,96 @@ public void UpsertEntry_EqualScores_OrdersByCreatedAt() manager.UpsertEntry("test", earlierEntry); var entries = manager.GetEntries("test"); - + Assert.AreEqual(2, entries.Count); Assert.AreEqual(1, entries[0].id); // earlier entry should be first Assert.AreEqual(2, entries[1].id); // later entry should be second Assert.AreEqual(0, entries[0].position); Assert.AreEqual(1, entries[1].position); } + + [Test] + public void UpsertEntry_BumpPositions_BumpsExistingEntries() + { + var entry1 = new LeaderboardEntry { id = 1, score = 100f, position = 0, leaderboardSortMode = "desc" }; + var entry2 = new LeaderboardEntry { id = 2, score = 80f, position = 1, leaderboardSortMode = "desc" }; + + manager.UpsertEntry("test", entry1); + manager.UpsertEntry("test", entry2); + + // Insert a new entry with position 0 - should bump the others + var entry3 = new LeaderboardEntry { id = 3, score = 110f, position = 0, leaderboardSortMode = "desc" }; + manager.UpsertEntry("test", entry3, bumpPositions: true); + + var entries = manager.GetEntries("test"); + + Assert.AreEqual(3, entries.Count); + Assert.AreEqual(3, entries[0].id); + Assert.AreEqual(0, entries[0].position); // new entry at position 0 + + Assert.AreEqual(1, entries[1].id); + Assert.AreEqual(1, entries[1].position); // bumped from 0 to 1 + + Assert.AreEqual(2, entries[2].id); + Assert.AreEqual(2, entries[2].position); // bumped from 1 to 2 + } + + [Test] + public void UpsertEntry_BumpPositions_OnlyBumpsAffectedEntries() + { + var entry1 = new LeaderboardEntry { id = 1, score = 100f, position = 0, leaderboardSortMode = "desc" }; + var entry2 = new LeaderboardEntry { id = 2, score = 80f, position = 1, leaderboardSortMode = "desc" }; + var entry3 = new LeaderboardEntry { id = 3, score = 60f, position = 2, leaderboardSortMode = "desc" }; + + manager.UpsertEntry("test", entry1); + manager.UpsertEntry("test", entry2); + manager.UpsertEntry("test", entry3); + + // Insert a new entry at position 1 - should only bump entries at position >= 1 + var entry4 = new LeaderboardEntry { id = 4, score = 90f, position = 1, leaderboardSortMode = "desc" }; + manager.UpsertEntry("test", entry4, bumpPositions: true); + + var entries = manager.GetEntries("test"); + + Assert.AreEqual(4, entries.Count); + + Assert.AreEqual(1, entries[0].id); + Assert.AreEqual(0, entries[0].position); // unchanged + + Assert.AreEqual(4, entries[1].id); + Assert.AreEqual(1, entries[1].position); // new entry at position 1 + + Assert.AreEqual(2, entries[2].id); + Assert.AreEqual(2, entries[2].position); // bumped from 1 to 2 + + Assert.AreEqual(3, entries[3].id); + Assert.AreEqual(3, entries[3].position); // bumped from 2 to 3 + } + + [Test] + public void UpsertEntry_NoBumpPositions_PreservesExistingPositions() + { + var entry1 = new LeaderboardEntry { id = 1, score = 100f, position = 0, leaderboardSortMode = "desc" }; + var entry2 = new LeaderboardEntry { id = 2, score = 80f, position = 1, leaderboardSortMode = "desc" }; + + manager.UpsertEntry("test", entry1); + manager.UpsertEntry("test", entry2); + + // Insert a new entry with position 0 without bumping + var entry3 = new LeaderboardEntry { id = 3, score = 110f, position = 0, leaderboardSortMode = "desc" }; + manager.UpsertEntry("test", entry3, bumpPositions: false); + + var entries = manager.GetEntries("test"); + + Assert.AreEqual(3, entries.Count); + Assert.AreEqual(3, entries[0].id); + Assert.AreEqual(0, entries[0].position); // new entry at position 0 + + Assert.AreEqual(1, entries[1].id); + Assert.AreEqual(0, entries[1].position); // unchanged - still position 0 + + Assert.AreEqual(2, entries[2].id); + Assert.AreEqual(1, entries[2].position); // unchanged - still position 1 + } } } \ No newline at end of file diff --git a/Assets/Talo Game Services/Talo/Tests/PlayersAPI/ClearIdentityTest.cs b/Assets/Talo Game Services/Talo/Tests/PlayersAPI/ClearIdentityTest.cs index 4618cc5..acc0355 100644 --- a/Assets/Talo Game Services/Talo/Tests/PlayersAPI/ClearIdentityTest.cs +++ b/Assets/Talo Game Services/Talo/Tests/PlayersAPI/ClearIdentityTest.cs @@ -2,7 +2,6 @@ using NUnit.Framework; using UnityEngine.TestTools; using UnityEngine; -using System.Threading.Tasks; namespace TaloGameServices.Test { @@ -41,7 +40,7 @@ public IEnumerator ClearIdentity_ShouldClearAliasData() yield return Talo.Events.Track("test-event"); Assert.IsNotEmpty(Talo.Events.queue); - Talo.Players.ClearIdentity(); + _ = Talo.Players.ClearIdentity(); Assert.IsNull(Talo.CurrentAlias); Assert.IsTrue(eventMock.identityCleared); Assert.IsEmpty(Talo.Events.queue); diff --git a/Assets/Talo Game Services/Talo/VERSION b/Assets/Talo Game Services/Talo/VERSION index 5c4503b..564edf8 100644 --- a/Assets/Talo Game Services/Talo/VERSION +++ b/Assets/Talo Game Services/Talo/VERSION @@ -1 +1 @@ -0.49.0 +0.50.0 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..2e506f7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,118 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is the **Talo Unity Package**, a self-hostable game development SDK providing leaderboards, player authentication, event tracking, game saves, stats, live config, channels, and more. The package is distributed via Unity Asset Store, GitHub releases, and itch.io. + +The codebase uses git submodules for dependency management (run `git submodule update --init` after cloning). + +## Project Structure + +All Talo code is located in `Assets/Talo Game Services/Talo/`: +- `Runtime/` - Core SDK implementation + - `APIs/` - API client classes for each service (Events, Players, Leaderboards, Saves, Stats, etc.) + - `Entities/` - Data models (Player, GameSave, LeaderboardEntry, etc.) + - `Requests/` - Request payload classes + - `Responses/` - Response payload classes + - `SocketRequests/` & `SocketResponses/` - WebSocket message types + - `Utils/` - Internal utilities (ContinuityManager, CryptoManager, SavesManager, etc.) + - `Vendor/` - Third-party dependencies (WebSocket client) + - `Talo.cs` - Main static entry point and API access + - `TaloManager.cs` - MonoBehaviour managing lifecycle and timers + - `TaloSocket.cs` - WebSocket connection handler + - `TaloSettings.cs` - ScriptableObject configuration +- `Tests/` - NUnit test suite +- `Samples/` - Demo scenes (Leaderboards, Saves, Authentication, Chat, etc.) + +## Architecture Patterns + +### Singleton Pattern +`Talo.cs` is the main static singleton providing access to all services (e.g., `Talo.Players`, `Talo.Events`, `Talo.Saves`). It initializes a `TaloManager` MonoBehaviour on first access, which persists across scenes. + +### API Architecture +All API classes inherit from `BaseAPI.cs`, which handles HTTP requests via Unity's `UnityWebRequest`. Each API class (e.g., `PlayersAPI`, `SavesAPI`) exposes public async methods returning typed responses. + +### Continuity System +The `ContinuityManager` queues failed network requests (POST/PUT/PATCH/DELETE) to disk and replays them when connectivity is restored. It excludes certain endpoints like `/health-check` and `/players/auth`. + +### Offline Mode +The SDK supports offline operation via `TaloSettings.offlineMode`. When offline, requests are queued for continuity replay. The `HealthCheckAPI` monitors connectivity and triggers connection lost/restored events. + +### WebSocket Integration +`TaloSocket.cs` manages WebSocket connections for real-time features (channels, presence). It uses the `com.mikeschweitzer.websocket` package (vendored in `Runtime/Vendor/`). + +### Event Flushing +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. + +To add debouncing to an API: +1. Define a public `enum DebouncedOperation` with your debounced operations +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)`, which queues the update to be executed after `debounceTimerSeconds` (default: 1s). Multiple property changes within the debounce window result in a single API call. + +## Common Development Commands + +### Running Tests +Tests use Unity Test Framework with NUnit. Run via Unity Editor: +- **Test Runner Window**: Window > General > Test Runner +- **Run All Tests**: Select "PlayMode" or "EditMode" tab and click "Run All" + +CI runs tests via GitHub Actions (`.github/workflows/ci.yml`) using Unity 6000.0.59f2. + +### Building the Package +The package is built via GitHub Actions (`.github/workflows/create-release.yml`) on tagged releases: +- Creates `talo.unitypackage` from `Assets/Talo Game Services/**/*` +- Uploads to itch.io and GitHub releases + +### Version Management +Version is stored in `Assets/Talo Game Services/Talo/VERSION`. A pre-commit hook (`.git/hooks/pre-commit`) automatically updates `ClientVersion` in `BaseAPI.cs` to match the VERSION file. + +## Key Configuration + +### TaloSettings +Settings are managed via a ScriptableObject asset (`Resources/Talo Settings`): +- `accessKey` - API authentication key +- `apiUrl` - Backend URL (default: `https://api.trytalo.com`) +- `socketUrl` - WebSocket URL (default: `wss://api.trytalo.com`) +- `autoStartSession` - Auto-authenticate with saved session token +- `autoConnectSocket` - Auto-connect WebSocket on startup +- `continuityEnabled` - Enable failed request replay +- `offlineMode` - Simulate offline for testing +- `debounceTimerSeconds` - Debounce interval for player updates, saves, and health checks (default: 1s) + +### Assembly Definitions +- `Talo.Runtime` (TaloRuntime.asmdef) - Main runtime assembly +- `Talo.Tests` (TaloTests.asmdef) - Test assembly with NUnit references + +## Important Implementation Details + +### Identity & Sessions +Players must be identified via `Talo.Players.Identify()` before using most APIs. The SDK stores the current alias in `Talo.CurrentAlias` and player in `Talo.CurrentPlayer`. Sessions are managed by `SessionManager` with tokens stored in PlayerPrefs. + +### Test Mode Detection +The SDK detects when running under NUnit (checks for `nunit.framework` assembly) and enables test mode, which uses `RequestMock` instead of real HTTP calls. + +### Error Handling +- `RequestException` - HTTP errors +- `PlayerAuthException` - Authentication-specific errors with error codes +- `SocketException` - WebSocket errors +- `ContinuityReplayException` - Continuity replay failures + +### Saves System +Game saves support local caching and offline operation via `SavesManager`. The manager handles loading, creating, updating, and deleting saves with automatic continuity support. + +## Git Workflow + +- Main branch: `develop` +- Submodules: Required for WebSocket dependency +- Pre-commit hook: Auto-updates version in BaseAPI.cs +- CI: Runs tests on every push +- Releases: Manual workflow dispatch creates Unity package and GitHub release diff --git a/Packages/manifest.json b/Packages/manifest.json index db06293..f3ae7af 100644 --- a/Packages/manifest.json +++ b/Packages/manifest.json @@ -1,6 +1,6 @@ { "dependencies": { - "com.unity.ide.visualstudio": "2.0.23", + "com.unity.ide.visualstudio": "2.0.25", "com.unity.test-framework": "1.6.0", "com.unity.toolchain.macos-arm64-linux-x86_64": "2.0.5", "com.unity.ugui": "2.0.0", diff --git a/Packages/packages-lock.json b/Packages/packages-lock.json index 662316e..0a6b00a 100644 --- a/Packages/packages-lock.json +++ b/Packages/packages-lock.json @@ -15,11 +15,11 @@ "dependencies": {} }, "com.unity.ide.visualstudio": { - "version": "2.0.23", + "version": "2.0.25", "depth": 0, "source": "registry", "dependencies": { - "com.unity.test-framework": "1.1.9" + "com.unity.test-framework": "1.1.31" }, "url": "https://packages.unity.com" }, diff --git a/ship-it.sh b/ship-it.sh new file mode 100755 index 0000000..f5f964b --- /dev/null +++ b/ship-it.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +VERSION=$(cat "Assets/Talo Game Services/Talo/VERSION" | tr -d '\n') +REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner) + +gh pr create \ + --repo "$REPO" \ + --base main \ + --head develop \ + --title "Release $VERSION" \ + --label "release" \ + --body "" diff --git a/unity.slnx b/unity.slnx new file mode 100644 index 0000000..033166a --- /dev/null +++ b/unity.slnx @@ -0,0 +1,7 @@ + + + + + + +