using System; using System.Collections; using System.Globalization; using System.IO; using System.Text; using System.Text.RegularExpressions; #if UNITY_EDITOR using UnityEditor; #endif using TMPro; using UnityEngine; using UnityEngine.Networking; using UnityEngine.SceneManagement; using UnityEngine.UI; public class LoginManager : MonoBehaviour { public const string AuthBaseUrl = "https://kkapi.playpoolstudios.com"; const string PlayerPrefsAuthKey = "WalkInvest.AuthToken"; const string PlayerPrefsDeviceIdKey = "WalkInvest.DeviceId"; const string AlreadyLoggedInCode = "already_logged_in"; const int MIN_USERNAME_LENGTH = 2; const int MIN_REGISTER_PASSWORD_LENGTH = 6; const int ShortAuthMessageMaxLength = 36; const string PlayerConnectionError = "Please check your internet connection and try again."; const string PlayerGenericError = "Something went wrong. Please try again."; [Header("Login")] [SerializeField] private TMP_InputField usernameInputLogin; [SerializeField] private TMP_InputField passwordInputLogin; [SerializeField] private Button btnLogin; [Header("Register")] [SerializeField] private TMP_InputField usernameInputRegister; [SerializeField] private TMP_InputField emailInputRegister; [SerializeField] private TMP_InputField passwordInputRegister; [SerializeField] private Button btnRegister; [Header("Server messages (GET /table_settings, no auth)")] [Header("Update")] [SerializeField] private GameObject serverUpdatePanel; [SerializeField] private Button btnServerUpdate; [Tooltip("Opened when the user taps Update on the forced-update panel (e.g. Telegram channel).")] public string telegramUpdateChannelUrl = ""; [Header("Error")] [SerializeField] private GameObject errorPanel; [SerializeField] private TMP_Text serverErrorMessageTxt; [SerializeField] private Button btnErrorClose; [Header("Warning")] [SerializeField] private GameObject warningPanel; [SerializeField] private TMP_Text serverWarningMessageTxt; [SerializeField] private Button btnWarningClose; const float KeepaliveIntervalSeconds = 10f; bool _authInProgress; Coroutine _keepaliveRoutine; bool _remoteLogoutInFlight; bool _retireAfterRemoteLogout; bool _appPaused; bool _appFocused = true; bool _handlingKeepaliveUnauthorized; string _pendingAuthMessage; public static string AuthToken { get; private set; } /// Assigned side from the matchmaker your_team field. null until a room is found or after clear. public static Team? MatchYourTeam { get; private set; } /// Parses matchmaker strings such as "red" / "blue" (case-insensitive). public static void SetMatchYourTeam(string matchmakerYourTeam) { MatchYourTeam = ParseMatchmakerTeam(matchmakerYourTeam); } static Team? ParseMatchmakerTeam(string s) { if (string.IsNullOrWhiteSpace(s)) return null; switch (s.Trim().ToLowerInvariant()) { case "red": return Team.Red; case "blue": return Team.Blue; default: return null; } } public static void ClearMatchYourTeam() { MatchYourTeam = null; } /// Latest user payload from the server. Reading this schedules a refresh (one in flight at a time). public static UserData CurrentUser { get { if (instance != null) instance.ScheduleUserDataRefreshOnRead(); return _currentUser; } } public static Action OnUserDataUpdated; static UserData _currentUser; bool _userDataReadRefreshPending; public static LoginManager instance; void Awake() { HideServerMessagePanels(); if (instance != null && instance != this) { instance.StopKeepalive(); SceneManager.sceneLoaded -= instance.OnSceneLoaded; if (instance._remoteLogoutInFlight) instance._retireAfterRemoteLogout = true; else Destroy(instance.gameObject); } instance = this; DontDestroyOnLoad(gameObject); SceneManager.sceneLoaded += OnSceneLoaded; AuthToken = PlayerPrefs.GetString(PlayerPrefsAuthKey, ""); if (string.IsNullOrEmpty(AuthToken)) ClearLocalUserProfile(); #if !UNITY_EDITOR else StartKeepalive(); #endif } void Start() { btnLogin.onClick.AddListener(OnLogin); btnRegister.onClick.AddListener(OnRegister); if (usernameInputLogin != null) usernameInputLogin.characterLimit = 0; if (usernameInputRegister != null) usernameInputRegister.characterLimit = 0; if (btnServerUpdate != null) btnServerUpdate.onClick.AddListener(OnServerUpdateClick); if (btnErrorClose != null) btnErrorClose.onClick.AddListener(OnServerErrorCloseClick); SetBusy(true); StartCoroutine(StartupSequenceCoroutine()); } IEnumerator StartupSequenceCoroutine() { bool mayContinue = false; yield return StartCoroutine(CoApplyTableSettingsGate(r => mayContinue = r)); if (!mayContinue) { // Blocked by update or error panel — keep login UI disabled, but hold the session. if (!string.IsNullOrEmpty(AuthToken)) StartKeepalive(); SetBusy(false); yield break; } #if UNITY_EDITOR // Dev: log in with clone-aware credentials instead of resuming a saved session. StopKeepalive(); AuthToken = ""; PlayerPrefs.DeleteKey(PlayerPrefsAuthKey); PlayerPrefs.Save(); bool cloneWorkspace = EditorWorkspaceLooksLikeClone(); string devUser = cloneWorkspace ? "warlock2" : "warlock"; const string devPass = "12345678"; if (usernameInputLogin != null) usernameInputLogin.text = devUser; if (passwordInputLogin != null) passwordInputLogin.text = devPass; string loginJson = BuildLoginBody(devUser, devPass); // StartCoroutine(AuthPostCoroutine(AuthBaseUrl + "/auth/login", loginJson, "Signed in.")); SetBusy(false); #else if (!string.IsNullOrEmpty(AuthToken)) { StartKeepalive(); yield return StartCoroutine(ResumeSessionCoroutine()); } else SetBusy(false); #endif } #if UNITY_EDITOR public static bool EditorWorkspaceLooksLikeClone() { // Project folder is parent of Assets (same as editor project path). string projectRoot = Path.GetDirectoryName(Application.dataPath); if (!string.IsNullOrEmpty(projectRoot) && projectRoot.IndexOf("clone", StringComparison.OrdinalIgnoreCase) >= 0) return true; string product = PlayerSettings.productName; if (!string.IsNullOrEmpty(product) && product.IndexOf("clone", StringComparison.OrdinalIgnoreCase) >= 0) return true; return false; } #endif IEnumerator ResumeSessionCoroutine() { ClearLocalUserProfile(); bool ok = false; string errMsg = null; yield return StartCoroutine(FetchUserDataCoroutine((success, err, _) => { ok = success; errMsg = err; }, true)); if (ok) ProceedToMainMenu(); else { InvalidateStoredSession(); ShowAuthMessage(string.IsNullOrEmpty(errMsg) ? "Session expired. Please sign in again." : errMsg); } SetBusy(false); } /// Clears cached profile only (token unchanged). Call when leaving a broken state without wiping prefs. static void ClearLocalUserProfile() { _currentUser = null; } /// Removes auth token from memory and disk and clears cached profile (login screen recovery). static void InvalidateStoredSession() { if (instance != null) instance.StopKeepalive(); AuthToken = ""; ClearLocalUserProfile(); ClearMatchYourTeam(); PlayerPrefs.DeleteKey(PlayerPrefsAuthKey); PlayerPrefs.Save(); } /// Clears the server session, then local session state and PlayerPrefs. public static void Logout() { if (MainMenuManager.instance != null) MainMenuManager.instance.ClearWithdrawalAddress(); string token = AuthToken; if (instance != null) { instance.StopKeepalive(); if (!string.IsNullOrEmpty(token)) { instance._remoteLogoutInFlight = true; instance.StartCoroutine(instance.RemoteLogoutCoroutine(token)); } } AuthToken = ""; ClearLocalUserProfile(); MainMenuManager.ClearCachedSettingsProfile(); ClearMatchYourTeam(); OnUserDataUpdated = null; Cupid.ClearSession(); CupidLobby.ClearUsername(); GameManager.ClearMatchSession(); if (LevelLoadManager.instance != null) LevelLoadManager.instance.ResetSession(); NetPlayer.RedPlayer = null; NetPlayer.BluePlayer = null; string deviceId = PlayerPrefs.GetString(PlayerPrefsDeviceIdKey, ""); PlayerPrefs.DeleteAll(); if (IsUsableDeviceId(deviceId)) PlayerPrefs.SetString(PlayerPrefsDeviceIdKey, deviceId); PlayerPrefs.Save(); } /// Full logout, then reload the login scene so a fresh can bind UI. public static void LogoutAndReturnToLogin() { Logout(); AddressableSceneLoader.Load(AddressableSceneLoader.IntroScene); } static void ProceedToMainMenu() { if (LevelLoadManager.instance != null) LevelLoadManager.LoadLevel("MainMenu"); else AddressableSceneLoader.Load(AddressableSceneLoader.MainMenuScene); } void SetBusy(bool busy) { _authInProgress = busy; SetButtonInteractable(btnLogin, !busy); SetButtonInteractable(btnRegister, !busy); } static void SetButtonInteractable(Button button, bool interactable) { if (button == null) return; if (button.interactable == interactable) return; button.interactable = interactable; if (!interactable) return; // Disabling mid-click swallows pointer-up, and the button then ignores later clicks. button.enabled = false; button.enabled = true; } void ShowAuthMessage(string message) { if (string.IsNullOrEmpty(message)) return; message = ToPlayerError(message, PlayerGenericError); if (MessageBoxDialog.instance == null) { _pendingAuthMessage = message; return; } _pendingAuthMessage = null; if (message.Length <= ShortAuthMessageMaxLength) MessageBoxDialog.instance.ShowMessageBox(message); else MessageBoxDialog.instance.ShowMessageBox("Error", message); } void HideAuthMessage() { if (MessageBoxDialog.instance != null) MessageBoxDialog.instance.HideMessageBox(); } void OnSceneLoaded(Scene scene, LoadSceneMode mode) { if (string.IsNullOrEmpty(_pendingAuthMessage) || MessageBoxDialog.instance == null) return; string message = _pendingAuthMessage; _pendingAuthMessage = null; ShowAuthMessage(message); } void OnLogin() { if (_authInProgress) return; // A failed attempt can leave a token and keepalive without leaving this screen. // Drop that session so this click performs a new connection check. if (_keepaliveRoutine != null && !string.IsNullOrEmpty(AuthToken)) InvalidateStoredSession(); string username = usernameInputLogin.text != null ? usernameInputLogin.text.Trim() : ""; string password = passwordInputLogin.text ?? ""; if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password)) { ShowAuthMessage("Please fill in all fields"); return; } if (username.Length < MIN_USERNAME_LENGTH) { ShowAuthMessage("Username must be at least " + MIN_USERNAME_LENGTH + " characters"); return; } StartCoroutine(AuthPostCoroutine(AuthBaseUrl + "/auth/login", BuildLoginBody(username, password), "Signed in.")); } void OnRegister() { if (_authInProgress) return; if (_keepaliveRoutine != null && !string.IsNullOrEmpty(AuthToken)) InvalidateStoredSession(); string username = usernameInputRegister.text != null ? usernameInputRegister.text.Trim() : ""; string email = emailInputRegister != null ? emailInputRegister.text : ""; string password = passwordInputRegister.text ?? ""; if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(email) || string.IsNullOrEmpty(password)) { ShowAuthMessage("Please fill in all fields"); return; } email = email.Trim().ToLowerInvariant(); if (!IsValidEmailFormat(email)) { ShowAuthMessage("Please enter a valid email address"); return; } if (username.Length < MIN_USERNAME_LENGTH) { ShowAuthMessage("Username must be at least " + MIN_USERNAME_LENGTH + " characters"); return; } if (password.Length < MIN_REGISTER_PASSWORD_LENGTH) { ShowAuthMessage("Password must be at least " + MIN_REGISTER_PASSWORD_LENGTH + " characters"); return; } StartCoroutine(AuthPostCoroutine(AuthBaseUrl + "/auth/register", BuildRegisterBody(username, email, password), "Account created.")); } static bool IsValidEmailFormat(string email) { if (string.IsNullOrEmpty(email)) return false; int at = email.IndexOf('@'); if (at <= 0) return false; if (email.IndexOf('@', at + 1) >= 0) return false; string domain = email.Substring(at + 1); int dot = domain.LastIndexOf('.'); if (dot <= 0 || dot >= domain.Length - 1) return false; for (int i = 0; i < email.Length; i++) { if (char.IsWhiteSpace(email[i])) return false; } return true; } static string EscapeJsonString(string s) { if (string.IsNullOrEmpty(s)) return s; return s.Replace("\\", "\\\\").Replace("\"", "\\\""); } static string BuildLoginBody(string username, string password) { return "{\"username\":\"" + EscapeJsonString(username) + "\",\"password\":\"" + EscapeJsonString(password) + "\",\"device_id\":\"" + EscapeJsonString(GetDeviceId()) + "\"}"; } static string BuildRegisterBody(string username, string email, string password) { return "{\"username\":\"" + EscapeJsonString(username) + "\",\"email\":\"" + EscapeJsonString(email) + "\",\"password\":\"" + EscapeJsonString(password) + "\",\"device_id\":\"" + EscapeJsonString(GetDeviceId()) + "\"}"; } /// /// Stable id sent on register and login. The server keeps it across logout, so a login /// from this device can still match while logout is in flight. /// static string GetDeviceId() { string id = SystemInfo.deviceUniqueIdentifier; if (IsUsableDeviceId(id)) return id; id = PlayerPrefs.GetString(PlayerPrefsDeviceIdKey, ""); if (IsUsableDeviceId(id)) return id; id = Guid.NewGuid().ToString("N"); PlayerPrefs.SetString(PlayerPrefsDeviceIdKey, id); PlayerPrefs.Save(); return id; } static bool IsUsableDeviceId(string id) { return !string.IsNullOrWhiteSpace(id) && id != SystemInfo.unsupportedIdentifier; } /// JsonUtility cannot read "id":"42" into . static string CoerceQuotedNumericIds(string json) { if (string.IsNullOrEmpty(json)) return json; return Regex.Replace(json, "\"id\"\\s*:\\s*\"(\\d+)\"", "\"id\":$1"); } static bool IsTransportFailure(UnityWebRequest request) { return request.result != UnityWebRequest.Result.Success && request.result != UnityWebRequest.Result.ProtocolError; } static string TransportError(UnityWebRequest request) { string technical = request == null || string.IsNullOrEmpty(request.error) ? "Network error" : request.error; string url = request != null ? request.url : ""; Logger.Log("Network error: " + technical + " url=" + url); return PlayerConnectionError; } static string PlayerFacingHttpError(string body, long responseCode) { if (responseCode == 401) return "Session expired. Please sign in again."; string apiError = TryReadApiErrorField(body); string candidate = !string.IsNullOrWhiteSpace(apiError) ? apiError : body; if (string.IsNullOrWhiteSpace(candidate)) { Logger.Log("HTTP " + responseCode + " with no error message"); return PlayerGenericError; } return ToPlayerError(candidate, PlayerGenericError); } static string TryReadApiErrorField(string body) { if (string.IsNullOrWhiteSpace(body)) return null; string trimmed = body.TrimStart(); if (trimmed.Length == 0 || trimmed[0] != '{') return null; try { AuthApiResponse resp = JsonUtility.FromJson(body); return resp != null ? resp.error : null; } catch (Exception) { return null; } } /// /// Rewrites engine and transport text ("Cannot resolve destination host", HTTP dumps, raw JSON) /// before it reaches the player. Readable server messages are left as-is. /// static string ToPlayerError(string message, string fallback) { if (IsConnectivityError(message)) { Logger.Log("Replaced technical error: " + message); return PlayerConnectionError; } if (!IsPlayerReadable(message)) { if (!string.IsNullOrWhiteSpace(message)) Logger.Log("Replaced technical error: " + message); return fallback; } return message.Trim(); } static bool IsConnectivityError(string message) { if (string.IsNullOrWhiteSpace(message)) return false; string m = message.ToLowerInvariant(); return m.Contains("resolve host") || m.Contains("resolve destination") || m.Contains("could not resolve") || m.Contains("couldn't resolve") || m.Contains("cannot resolve") || m.Contains("can't resolve") || m.Contains("unable to resolve") || m.Contains("unknown host") || m.Contains("name resolution") || m.Contains("name or service") || m.Contains("destination host") || m.Contains("cannot connect") || m.Contains("can't connect") || m.Contains("could not connect") || m.Contains("couldn't connect") || m.Contains("connection refused") || m.Contains("connection reset") || m.Contains("connection aborted") || m.Contains("connection timed") || m.Contains("network is unreachable") || m.Contains("network error") || m.Contains("no internet") || m.Contains("no route to host") || m.Contains("host unreachable") || m.Contains("failed to receive") || m.Contains("request timeout") || m.Contains("timed out") || m.Contains("timeout") || m.Contains("ssl") || m.Contains("certificate") || m.Contains("curl error") || m.Contains("unknown error") || m.Contains("insecure connection"); } static bool IsPlayerReadable(string message) { if (string.IsNullOrWhiteSpace(message) || IsConnectivityError(message)) return false; string trimmed = message.Trim(); if (trimmed.Length > 180) return false; char c = trimmed[0]; if (c == '{' || c == '<' || c == '[') return false; string m = trimmed.ToLowerInvariant(); return m.IndexOf("http/1") < 0 && m.IndexOf("request failed (") < 0 && m.IndexOf("empty response") < 0 && m.IndexOf("exception") < 0 && m.IndexOf("stack trace") < 0 && m.IndexOf("stacktrace") < 0 && m.IndexOf("nullreference") < 0 && m.IndexOf("data processing error") < 0 && m.IndexOf("malformed url") < 0 && m.IndexOf('\n') < 0 && m.IndexOf('\r') < 0; } IEnumerator AuthPostCoroutine(string url, string jsonBody, string successMessage) { SetBusy(true); HideAuthMessage(); ClearLocalUserProfile(); bool enteredMenu = false; try { using (var request = new UnityWebRequest(url, UnityWebRequest.kHttpVerbPOST)) { byte[] bodyRaw = Encoding.UTF8.GetBytes(jsonBody); request.uploadHandler = new UploadHandlerRaw(bodyRaw); request.downloadHandler = new DownloadHandlerBuffer(); request.SetRequestHeader("Content-Type", "application/json"); yield return request.SendWebRequest(); if (IsTransportFailure(request)) { ShowAuthMessage(TransportError(request)); yield break; } string text = CoerceQuotedNumericIds(ReadResponseBody(request)); if (string.IsNullOrEmpty(text)) { ShowAuthMessage(PlayerGenericError); yield break; } AuthApiResponse resp = null; try { resp = JsonUtility.FromJson(text); } catch (Exception e) { Logger.Log("Auth JSON error: " + e.Message); } if (resp != null && resp.ok && !string.IsNullOrEmpty(resp.token)) { AuthToken = resp.token; PlayerPrefs.SetString(PlayerPrefsAuthKey, resp.token); PlayerPrefs.Save(); bool profileOk = resp.user != null && !string.IsNullOrEmpty(resp.user.username); string profileErr = null; if (profileOk) ApplyUserData(resp.user); else { yield return StartCoroutine(FetchUserDataCoroutine((success, err, _) => { profileOk = success; profileErr = err; }, true)); } if (profileOk) { enteredMenu = true; StartKeepalive(); ProceedToMainMenu(); } else { InvalidateStoredSession(); ShowAuthMessage(string.IsNullOrEmpty(profileErr) ? PlayerConnectionError : profileErr); } } else { string serverError = resp != null ? resp.error : null; if (string.IsNullOrEmpty(serverError) && resp != null && resp.code == AlreadyLoggedInCode) serverError = "Already logged in on another device"; ShowAuthMessage(string.IsNullOrEmpty(serverError) ? "Login failed. Please try again." : serverError); } } } finally { if (!enteredMenu) SetBusy(false); } } static string ReadResponseBody(UnityWebRequest request) { if (request == null || request.downloadHandler == null) return ""; try { return request.downloadHandler.text ?? ""; } catch (Exception e) { Logger.Log("Auth response unreadable: " + e.Message); return ""; } } void HideServerMessagePanels() { if (serverUpdatePanel != null) serverUpdatePanel.SetActive(false); if (errorPanel != null) errorPanel.SetActive(false); if (warningPanel != null) warningPanel.SetActive(false); } void OnServerUpdateClick() { if (!string.IsNullOrWhiteSpace(telegramUpdateChannelUrl)) Application.OpenURL(telegramUpdateChannelUrl.Trim()); } void OnServerErrorCloseClick() { #if UNITY_EDITOR EditorApplication.ExitPlaymode(); #else Application.Quit(); #endif } /// /// GET /table_settings (public) and applies version / error / warning gates. /// Callback receives true when login may continue to the main menu. /// IEnumerator CoApplyTableSettingsGate(Action onComplete) { CupidTableSettings table = null; yield return StartCoroutine(FetchTableSettingsCoroutine(t => table = t)); if (table == null) { onComplete?.Invoke(true); yield break; } if (IsClientBelowRequiredVersion(table.version)) { if (serverUpdatePanel != null) serverUpdatePanel.SetActive(true); onComplete?.Invoke(false); yield break; } if (!string.IsNullOrWhiteSpace(table.error_msg)) { if (serverErrorMessageTxt != null) serverErrorMessageTxt.text = table.error_msg.Trim(); if (errorPanel != null) errorPanel.SetActive(true); onComplete?.Invoke(false); yield break; } if (!string.IsNullOrWhiteSpace(table.warning_msg)) yield return CoWaitForWarningDismiss(table.warning_msg.Trim()); onComplete?.Invoke(true); } IEnumerator FetchTableSettingsCoroutine(Action onResult) { string url = AuthBaseUrl + "/table_settings"; using (var request = UnityWebRequest.Get(url)) { request.downloadHandler = new DownloadHandlerBuffer(); yield return request.SendWebRequest(); string text = request.downloadHandler != null ? request.downloadHandler.text : ""; if (request.result != UnityWebRequest.Result.Success && request.result != UnityWebRequest.Result.ProtocolError) { onResult?.Invoke(null); yield break; } if (request.responseCode < 200 || request.responseCode >= 300 || string.IsNullOrWhiteSpace(text)) { onResult?.Invoke(null); yield break; } try { CupidTableSettings parsed = JsonUtility.FromJson(text); onResult?.Invoke(parsed); } catch (Exception e) { Logger.Log("table_settings JSON error: " + e.Message + " body=" + text); onResult?.Invoke(null); } } } static bool IsClientBelowRequiredVersion(string requiredVersion) { if (string.IsNullOrWhiteSpace(requiredVersion)) return false; if (!TryParseVersionFloat(requiredVersion, out float required)) return false; if (!TryParseVersionFloat(Application.version, out float client)) return false; return client < required; } static bool TryParseVersionFloat(string version, out float value) { value = 0f; if (string.IsNullOrWhiteSpace(version)) return false; return float.TryParse(version.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out value); } IEnumerator CoWaitForWarningDismiss(string message) { if (serverWarningMessageTxt != null) serverWarningMessageTxt.text = message; if (warningPanel != null) warningPanel.SetActive(true); bool dismissed = false; void OnDismiss() { dismissed = true; if (warningPanel != null) warningPanel.SetActive(false); } if (btnWarningClose != null) { btnWarningClose.onClick.RemoveListener(OnDismiss); btnWarningClose.onClick.AddListener(OnDismiss); } yield return new WaitUntil(() => dismissed); if (btnWarningClose != null) btnWarningClose.onClick.RemoveListener(OnDismiss); } static void ApplyUserData(UserData u) { _currentUser = u; OnUserDataUpdated?.Invoke(u); } void ScheduleUserDataRefreshOnRead() { if (string.IsNullOrEmpty(AuthToken)) return; if (_userDataReadRefreshPending) return; _userDataReadRefreshPending = true; StartCoroutine(UserDataReadRefreshCoroutine()); } IEnumerator UserDataReadRefreshCoroutine() { try { yield return StartCoroutine(FetchUserDataCoroutine(null, true)); } finally { _userDataReadRefreshPending = false; } } /// GET /auth/user; updates when successful. Callback (success, errorOrNull, userOrNull). public void GetUserData(Action onComplete) { StartCoroutine(FetchUserDataCoroutine(onComplete, true)); } /// GET /auth/rc-packs (no auth). Callback (success, errorOrNull, jsonOrNull). public void GetRcPacks(Action onComplete) { StartCoroutine(GetRcPacksCoroutine(onComplete)); } IEnumerator GetRcPacksCoroutine(Action onComplete) { string url = AuthBaseUrl + "/auth/rc-packs"; using (var request = UnityWebRequest.Get(url)) { request.downloadHandler = new DownloadHandlerBuffer(); yield return request.SendWebRequest(); string text = request.downloadHandler != null ? request.downloadHandler.text : ""; if (IsTransportFailure(request)) { onComplete?.Invoke(false, TransportError(request), null); yield break; } if (request.responseCode < 200 || request.responseCode >= 300) { onComplete?.Invoke(false, PlayerFacingHttpError(text, request.responseCode), null); yield break; } onComplete?.Invoke(true, null, text); } } /// POST /auth/purchase-rc with {"pack":"usd_5"|...}. Refreshes profile on success. Callback (success, errorOrNull). public void PurchaseRcPack(string packId, Action onComplete) { StartCoroutine(PurchaseRcCoroutine(packId, onComplete)); } /// POST /auth/ping-report with {"match_id":123,"ping":42}. Callback (success, errorOrNull, reportOrNull). public void ReportPing(int matchId, int ping, Action onComplete) { StartCoroutine(ReportPingCoroutine(matchId, ping, onComplete)); } IEnumerator PurchaseRcCoroutine(string packId, Action onComplete) { if (string.IsNullOrEmpty(AuthToken)) { onComplete?.Invoke(false, "Not signed in"); yield break; } if (!RcPack.IsValid(packId)) { onComplete?.Invoke(false, "Invalid pack (use " + RcPack.Usd5 + ", " + RcPack.Usd20 + ", or " + RcPack.Usd50 + ")"); yield break; } string json = "{\"pack\":\"" + EscapeJsonString(packId) + "\"}"; string url = AuthBaseUrl + "/auth/purchase-rc"; using (var request = new UnityWebRequest(url, UnityWebRequest.kHttpVerbPOST)) { byte[] bodyRaw = Encoding.UTF8.GetBytes(json); request.uploadHandler = new UploadHandlerRaw(bodyRaw); request.downloadHandler = new DownloadHandlerBuffer(); request.SetRequestHeader("Content-Type", "application/json"); request.SetRequestHeader("Authorization", "Bearer " + AuthToken); yield return request.SendWebRequest(); string text = request.downloadHandler != null ? request.downloadHandler.text : ""; if (IsTransportFailure(request)) { onComplete?.Invoke(false, TransportError(request)); yield break; } if (request.responseCode < 200 || request.responseCode >= 300) { onComplete?.Invoke(false, PlayerFacingHttpError(text, request.responseCode)); yield break; } if (string.IsNullOrEmpty(text)) { onComplete?.Invoke(false, PlayerGenericError); yield break; } PurchaseRcApiResponse resp = JsonUtility.FromJson(text); if (resp != null && resp.ok) { bool refreshOk = false; string refreshErr = null; yield return StartCoroutine(FetchUserDataCoroutine((ok, err, u) => { refreshOk = ok; refreshErr = err; }, true)); if (refreshOk) onComplete?.Invoke(true, null); else onComplete?.Invoke(false, string.IsNullOrEmpty(refreshErr) ? PlayerGenericError : ToPlayerError(refreshErr, PlayerGenericError)); } else { string err = resp != null && !string.IsNullOrEmpty(resp.error) ? resp.error : null; onComplete?.Invoke(false, ToPlayerError(err, "Purchase failed. Please try again.")); } } } IEnumerator ReportPingCoroutine(int matchId, int ping, Action onComplete) { if (string.IsNullOrEmpty(AuthToken)) { onComplete?.Invoke(false, "Not signed in", null); yield break; } string json = "{\"match_id\":" + matchId + ",\"ping\":" + ping + "}"; string url = AuthBaseUrl + "/auth/ping-report"; using (var request = new UnityWebRequest(url, UnityWebRequest.kHttpVerbPOST)) { byte[] bodyRaw = Encoding.UTF8.GetBytes(json); request.uploadHandler = new UploadHandlerRaw(bodyRaw); request.downloadHandler = new DownloadHandlerBuffer(); request.SetRequestHeader("Content-Type", "application/json"); request.SetRequestHeader("Authorization", "Bearer " + AuthToken); yield return request.SendWebRequest(); string text = request.downloadHandler != null ? request.downloadHandler.text : ""; if (IsTransportFailure(request)) { onComplete?.Invoke(false, TransportError(request), null); yield break; } if (request.responseCode < 200 || request.responseCode >= 300) { onComplete?.Invoke(false, PlayerFacingHttpError(text, request.responseCode), null); yield break; } if (string.IsNullOrEmpty(text)) { onComplete?.Invoke(false, PlayerGenericError, null); yield break; } PingReportApiResponse resp = JsonUtility.FromJson(text); if (resp != null && resp.ok && resp.report != null) onComplete?.Invoke(true, null, resp.report); else { string err = resp != null && !string.IsNullOrEmpty(resp.error) ? resp.error : null; onComplete?.Invoke(false, ToPlayerError(err, PlayerGenericError), null); } } } IEnumerator FetchUserDataCoroutine(Action onComplete, bool applyToStatic) { if (string.IsNullOrEmpty(AuthToken)) { onComplete?.Invoke(false, "Not signed in", null); yield break; } string url = AuthBaseUrl + "/auth/user"; using (var request = UnityWebRequest.Get(url)) { request.downloadHandler = new DownloadHandlerBuffer(); request.SetRequestHeader("Authorization", "Bearer " + AuthToken); yield return request.SendWebRequest(); string text = request.downloadHandler != null ? request.downloadHandler.text : ""; if (IsTransportFailure(request)) { onComplete?.Invoke(false, TransportError(request), null); yield break; } if (request.responseCode < 200 || request.responseCode >= 300) { onComplete?.Invoke(false, PlayerFacingHttpError(text, request.responseCode), null); yield break; } if (string.IsNullOrEmpty(text)) { onComplete?.Invoke(false, PlayerGenericError, null); yield break; } AuthUserApiResponse resp = JsonUtility.FromJson(CoerceQuotedNumericIds(text)); if (resp != null && resp.ok && resp.user != null) { if (applyToStatic) ApplyUserData(resp.user); onComplete?.Invoke(true, null, resp.user); } else { string err = resp != null && !string.IsNullOrEmpty(resp.error) ? resp.error : null; onComplete?.Invoke(false, ToPlayerError(err, PlayerGenericError), null); } } } bool IsAppInForeground => !_appPaused && _appFocused; void OnApplicationPause(bool paused) { _appPaused = paused; } void OnApplicationFocus(bool hasFocus) { _appFocused = hasFocus; } void OnDestroy() { SceneManager.sceneLoaded -= OnSceneLoaded; StopKeepalive(); if (instance == this) instance = null; } void StartKeepalive() { if (string.IsNullOrEmpty(AuthToken)) return; if (_keepaliveRoutine != null) return; _handlingKeepaliveUnauthorized = false; _keepaliveRoutine = StartCoroutine(KeepaliveLoopCoroutine()); } void StopKeepalive() { if (_keepaliveRoutine == null) return; StopCoroutine(_keepaliveRoutine); _keepaliveRoutine = null; } IEnumerator KeepaliveLoopCoroutine() { while (!string.IsNullOrEmpty(AuthToken)) { while (!IsAppInForeground && !string.IsNullOrEmpty(AuthToken)) yield return null; if (string.IsNullOrEmpty(AuthToken)) break; yield return KeepalivePingCoroutine(); if (string.IsNullOrEmpty(AuthToken)) break; float elapsed = 0f; while (elapsed < KeepaliveIntervalSeconds && !string.IsNullOrEmpty(AuthToken)) { if (!IsAppInForeground) { while (!IsAppInForeground && !string.IsNullOrEmpty(AuthToken)) yield return null; break; } elapsed += Time.unscaledDeltaTime; yield return null; } } _keepaliveRoutine = null; } IEnumerator RemoteLogoutCoroutine(string token) { try { string url = AuthBaseUrl + "/auth/logout"; using (var request = new UnityWebRequest(url, UnityWebRequest.kHttpVerbPOST)) { request.uploadHandler = new UploadHandlerRaw(Array.Empty()); request.downloadHandler = new DownloadHandlerBuffer(); request.SetRequestHeader("Authorization", "Bearer " + token); yield return request.SendWebRequest(); if (IsTransportFailure(request)) Logger.Log("auth/logout network error: " + (string.IsNullOrEmpty(request.error) ? "Network error" : request.error)); else if (request.responseCode < 200 || request.responseCode >= 300) Logger.Log("auth/logout failed: HTTP " + request.responseCode); } } finally { _remoteLogoutInFlight = false; if (_retireAfterRemoteLogout) Destroy(gameObject); } } IEnumerator KeepalivePingCoroutine() { if (string.IsNullOrEmpty(AuthToken)) yield break; string url = AuthBaseUrl + "/auth/keepalive"; using (var request = new UnityWebRequest(url, UnityWebRequest.kHttpVerbPOST)) { request.uploadHandler = new UploadHandlerRaw(Array.Empty()); request.downloadHandler = new DownloadHandlerBuffer(); request.SetRequestHeader("Content-Type", "application/json"); request.SetRequestHeader("Authorization", "Bearer " + AuthToken); yield return request.SendWebRequest(); if (request.responseCode == 401) { string text = request.downloadHandler != null ? request.downloadHandler.text : ""; string err = "Session expired. Please sign in again."; if (!string.IsNullOrEmpty(text)) { AuthApiResponse resp = JsonUtility.FromJson(text); if (resp != null && !string.IsNullOrEmpty(resp.error)) err = ToPlayerError(resp.error, err); } HandleKeepaliveUnauthorized(err); yield break; } if (request.result != UnityWebRequest.Result.Success && request.result != UnityWebRequest.Result.ProtocolError) { Logger.Log("auth/keepalive network error: " + (string.IsNullOrEmpty(request.error) ? "Network error" : request.error)); yield break; } if (request.responseCode < 200 || request.responseCode >= 300) Logger.Log("auth/keepalive failed: HTTP " + request.responseCode); } } void HandleKeepaliveUnauthorized(string message) { if (_handlingKeepaliveUnauthorized) return; _handlingKeepaliveUnauthorized = true; SetBusy(false); ShowAuthMessage(string.IsNullOrEmpty(message) ? "Session expired. Please sign in again." : message); LogoutAndReturnToLogin(); } }