using System; using System.Linq; using System.Security.Cryptography; using System.Text; using System.Threading; using System.Threading.Tasks; using ApeMotion.Backend.Api; using ApeMotion.Backend.Auth; using ApeMotion.Backend.Security; using Newtonsoft.Json; using Newtonsoft.Json.Linq; namespace ApeMotion.Backend.Samples { // Instantiate from the game's composition root with real platform adapters. // Call these methods serially on Unity's main thread. No async void handlers. public sealed class FirstGameExample { private readonly ApiClient api; private readonly ISecureStorage storage; private readonly IDeviceProofProvider device; private readonly IPlatformIdentityAdapter identity; private readonly string gameSlug, gameId, gameAppId, installationId, appIdentifier, expectedEnvironment; private readonly Func supportsMinimumVersion; private bool bootstrapped; public SessionState Session => api.Session; public FirstGameExample(string apiBaseUrl, string gameSlug, string gameId, string gameAppId, string appIdentifier, string installationId, ISecureStorage secureStorage, IDeviceProofProvider device, IPlatformIdentityAdapter identity, Func supportsMinimumVersion, string expectedEnvironment = "development") { var origin = new Uri(apiBaseUrl); if (origin.Scheme != "https" || origin.AbsolutePath != "/" || !string.IsNullOrEmpty(origin.Query) || !string.IsNullOrEmpty(origin.Fragment) || !string.IsNullOrEmpty(origin.UserInfo)) throw new ArgumentException("apiBaseUrl must be an HTTPS origin without /v1 or credentials."); Guid.Parse(gameId); Guid.Parse(gameAppId); Guid.Parse(installationId); this.gameSlug = gameSlug; this.gameId = gameId; this.gameAppId = gameAppId; this.appIdentifier = appIdentifier; this.installationId = installationId; this.expectedEnvironment = expectedEnvironment; this.device = device; this.identity = identity; this.supportsMinimumVersion = supportsMinimumVersion ?? throw new ArgumentNullException(nameof(supportsMinimumVersion)); if ((device.Platform == "ios" && identity.Provider != "game_center") || (device.Platform == "android" && identity.Provider != "play_games") || (device.Platform != "ios" && device.Platform != "android")) throw new ArgumentException("Platform and identity provider must match."); // Isolate tokens and pending operations across environment, App and installation. storage = new ScopedStorage(secureStorage, origin.AbsoluteUri + gameAppId + "/" + installationId + "/"); api = new ApiClient(apiBaseUrl, installationId, storage, device); } public async Task InitializeAsync(CancellationToken ct = default) { var response = await api.SendAsync("GET", "v1/public/games/" + Uri.EscapeDataString(gameSlug) + "/bootstrap", cancellationToken: ct); var bootstrap = JObject.Parse(response.Body); if ((string)bootstrap["gameId"] != gameId) throw new InvalidOperationException("Bootstrap gameId mismatch."); if ((string)bootstrap["environment"] != expectedEnvironment) throw new InvalidOperationException("Bootstrap environment mismatch."); if ((bool)bootstrap["maintenance"]["enabled"]) throw new InvalidOperationException("Game is under maintenance."); if (!supportsMinimumVersion((string)bootstrap["minimumClientVersion"])) throw new InvalidOperationException("Client upgrade required."); await api.SendAsync("GET", "v1/public/games/" + Uri.EscapeDataString(gameSlug) + "/config/" + Uri.EscapeDataString((string)bootstrap["configVersion"]), cancellationToken: ct); bootstrapped = true; await api.RestoreSessionAsync(ct); if (api.Session == null) return false; if (api.Session.GameId != gameId) { await api.ClearSessionAsync(ct); throw new InvalidOperationException("Stored session game mismatch."); } try { await api.SendAsync("GET", "v1/me", cancellationToken: ct); return true; } catch (ApiException error) when (error.Problem.Status == 401) { await api.ClearSessionAsync(ct); return false; } // Offline, 403 and 5xx are not treated as a new/empty account. } public async Task LoginAsync(CancellationToken ct = default) { if (!bootstrapped) throw new InvalidOperationException("Initialize first."); if (api.Session != null) throw new InvalidOperationException("Logout before switching accounts."); const string pendingKey = "first-game.login"; var pending = await storage.GetAsync(pendingKey, ct); JObject attempt; if (string.IsNullOrWhiteSpace(pending)) { var authRequestId = Guid.NewGuid().ToString("D"); var platform = JObject.Parse(await identity.CreateLoginPayloadAsync(gameSlug, installationId, authRequestId, ct)); var semantic = new JObject { ["gameSlug"] = gameSlug, ["installationId"] = installationId, ["authRequestId"] = authRequestId, ["devicePublicJwk"] = PublicJwk() }; var fields = device.Platform == "ios" ? new[] { "publicKeyUrl", "signature", "salt", "timestamp", "teamPlayerId", "gamePlayerId" } : new[] { "serverAuthCode", "recallSessionId" }; foreach (var field in fields) if (platform[field] != null) semantic[field] = platform[field].DeepClone(); semantic[device.Platform == "ios" ? "bundleId" : "packageName"] = appIdentifier; attempt = new JObject { ["key"] = Guid.NewGuid().ToString("D"), ["body"] = semantic }; await storage.SetAsync(pendingKey, attempt.ToString(Formatting.None), ct); } else attempt = JObject.Parse(pending); var body = (JObject)attempt["body"].DeepClone(); var challenge = await api.SendAsync("POST", "v1/auth/device-challenges", new { operation = "game_login", installationId, platform = device.Platform, clientAppId = gameAppId, devicePublicJwk = body["devicePublicJwk"] }, cancellationToken: ct); var issued = JObject.Parse(challenge.Body); var message = string.Join("\n", "ape-device-proof-v1", "game_login", (string)issued["challengeId"], (string)issued["nonce"], Hash(Canonical(body).ToString(Formatting.None))); body["deviceChallengeId"] = issued["challengeId"]; body["deviceChallengeNonce"] = issued["nonce"]; body["deviceProof"] = await device.SignAsync(message, ct); try { var response = await api.SendAsync("POST", device.Platform == "ios" ? "v1/auth/game-center" : "v1/auth/play-games", body, mutation: true, idempotencyKey: (string)attempt["key"], cancellationToken: ct); var session = response.ReadJson(); if (session.GameId != gameId) throw new InvalidOperationException("Login gameId mismatch."); await api.SetSessionAsync(session, ct); await storage.DeleteAsync(pendingKey, ct); } catch (ApiException error) when (error.Problem.Code == "EXTERNAL_IDENTITY_PROOF_REISSUE_REQUIRED") { await storage.DeleteAsync(pendingKey, ct); throw; // UI asks for fresh platform authentication; no silent retry loop. } // Unknown outcome keeps the exact platform proof/authRequestId/key. } public async Task ReadSaveAsync(CancellationToken ct = default) { RequireSession(); try { return JObject.Parse((await api.SendAsync("GET", SavePath, cancellationToken: ct)).Body); } catch (ApiException error) when (error.Problem.Status == 404 && error.Problem.Code == "NOT_FOUND") { return null; } } public static JObject DecodeDocument(JObject save) => JObject.Parse(Encoding.UTF8.GetString( Convert.FromBase64String((string)save["inline_document"]["payload"]))); public async Task SaveAsync(long baseRevision, JObject gameState, CancellationToken ct = default) { RequireSession(); var key = PendingSaveKey; if (!string.IsNullOrWhiteSpace(await storage.GetAsync(key, ct))) throw new InvalidOperationException("A pending save exists. Retry it or explicitly resolve the conflict first."); // ASCII base64 payload avoids .NET/JS floating point and escaping drift. var document = new JObject { ["payload"] = Convert.ToBase64String(Encoding.UTF8.GetBytes(gameState.ToString(Formatting.None))) }; var attempt = new JObject { ["key"] = Guid.NewGuid().ToString("D"), ["origin"] = api.Session.OriginAccountLifecycleId, ["body"] = new JObject { ["baseRevision"] = baseRevision, ["document"] = document, ["sha256"] = Hash(document.ToString(Formatting.None)) } }; await storage.SetAsync(key, attempt.ToString(Formatting.None), ct); return await RetryPendingSaveAsync(ct); } public async Task RetryPendingSaveAsync(CancellationToken ct = default) { RequireSession(); var key = PendingSaveKey; // freeze ownership across asynchronous work var pending = await storage.GetAsync(key, ct); if (string.IsNullOrWhiteSpace(pending)) throw new InvalidOperationException("No pending save."); var attempt = JObject.Parse(pending); var response = await api.SendAsync("PUT", SavePath, attempt["body"], mutation: true, idempotencyKey: (string)attempt["key"], originAccountLifecycleId: (string)attempt["origin"], cancellationToken: ct); var revision = (long)JObject.Parse(response.Body)["revision"]; await storage.DeleteAsync(key, ct); return revision; // On REVISION_CONFLICT leave the pending document intact for the UI. } public Task ReadPendingSaveAsync(CancellationToken ct = default) { RequireSession(); return storage.GetAsync(PendingSaveKey, ct); } // Call ONLY after reading latest save and user-approved merge/selection. public Task DiscardPendingSaveAfterResolutionAsync(CancellationToken ct = default) { RequireSession(); return storage.DeleteAsync(PendingSaveKey, ct); } public async Task LogoutAsync(CancellationToken ct = default) { RequireSession(); try { await api.SendAsync("POST", "v1/auth/logout", mutation: true, cancellationToken: ct); } catch (ApiException error) when (error.Problem.Status == 401) { /* Already unusable locally. */ } await api.ClearSessionAsync(ct); // Transport failure is propagated; do not claim remote revocation. } private void RequireSession() { if (api.Session == null) throw new InvalidOperationException("Login required."); } private string SavePath => "v1/games/" + gameId + "/saves/main"; private string PendingSaveKey => "first-game.save." + api.Session.GameAccountId; private JObject PublicJwk() { var value = JObject.Parse(device.PublicJwkJson); return new JObject { ["kty"] = value["kty"], ["crv"] = value["crv"], ["x"] = value["x"], ["y"] = value["y"], ["alg"] = "ES256", ["ext"] = true }; } private static string Hash(string value) { using var sha = SHA256.Create(); return BitConverter.ToString(sha.ComputeHash(Encoding.UTF8.GetBytes(value))).Replace("-", "").ToLowerInvariant(); } private static JToken Canonical(JToken value) { if (value is JObject obj) return new JObject(obj.Properties().OrderBy(p => p.Name, StringComparer.Ordinal).Select(p => new JProperty(p.Name, Canonical(p.Value)))); if (value is JArray array) return new JArray(array.Select(Canonical)); return value.DeepClone(); } private sealed class ScopedStorage : ISecureStorage { private readonly ISecureStorage inner; private readonly string prefix; public ScopedStorage(ISecureStorage inner, string prefix) { this.inner = inner; this.prefix = "ape." + Hash(prefix) + "."; } public Task SetAsync(string key, string value, CancellationToken ct = default) => inner.SetAsync(prefix + key, value, ct); public Task GetAsync(string key, CancellationToken ct = default) => inner.GetAsync(prefix + key, ct); public Task DeleteAsync(string key, CancellationToken ct = default) => inner.DeleteAsync(prefix + key, ct); } } }