using System; using System.Collections; using System.Globalization; using System.IO; using System.Text; #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://kickkingsapi.playpoolstudios.com"; const string PlayerPrefsAuthKey = "WalkInvest.AuthToken"; const int MIN_USERNAME_LENGTH = 3; const int MAX_USERNAME_LENGTH = 16; const int MIN_PASSWORD_LENGTH = 8; const int MAX_PASSWORD_LENGTH = 20; [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("UI")] [SerializeField] private TMP_Text error_txt; [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; bool _authInProgress; 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() { error_txt.text = ""; HideServerMessagePanels(); AuthToken = PlayerPrefs.GetString(PlayerPrefsAuthKey, ""); if(instance !=null){ Destroy(gameObject); return; } instance = this; DontDestroyOnLoad(gameObject); if (string.IsNullOrEmpty(AuthToken)) ClearLocalUserProfile(); } void Start() { btnLogin.onClick.AddListener(OnLogin); btnRegister.onClick.AddListener(OnRegister); 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. SetBusy(false); yield break; } #if UNITY_EDITOR // Dev: log in with clone-aware credentials instead of resuming a saved session. 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 = "{\"username\":\"" + EscapeJsonString(devUser) + "\",\"password\":\"" + EscapeJsonString(devPass) + "\"}"; // StartCoroutine(AuthPostCoroutine(AuthBaseUrl + "/auth/login", loginJson, "Signed in.")); SetBusy(false); #else if (!string.IsNullOrEmpty(AuthToken)) 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(); if (error_txt != null) error_txt.text = 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() { AuthToken = ""; ClearLocalUserProfile(); ClearMatchYourTeam(); PlayerPrefs.DeleteKey(PlayerPrefsAuthKey); PlayerPrefs.Save(); } static void ProceedToMainMenu() { if (LevelLoadManager.instance != null) LevelLoadManager.LoadLevel("MainMenu"); else SceneManager.LoadScene("MainMenu"); } void SetBusy(bool busy) { _authInProgress = busy; if (btnLogin != null) btnLogin.interactable = !busy; if (btnRegister != null) btnRegister.interactable = !busy; } void OnLogin() { if (_authInProgress) return; string username = usernameInputLogin.text; string password = passwordInputLogin.text; if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password)) { error_txt.text = "Please fill in all fields"; return; } if (username.Length < MIN_USERNAME_LENGTH || username.Length > MAX_USERNAME_LENGTH) { error_txt.text = "Username must be between " + MIN_USERNAME_LENGTH + " and " + MAX_USERNAME_LENGTH + " characters"; return; } if (password.Length < MIN_PASSWORD_LENGTH || password.Length > MAX_PASSWORD_LENGTH) { error_txt.text = "Password must be between " + MIN_PASSWORD_LENGTH + " and " + MAX_PASSWORD_LENGTH + " characters"; return; } string json = "{\"username\":\"" + EscapeJsonString(username) + "\",\"password\":\"" + EscapeJsonString(password) + "\"}"; StartCoroutine(AuthPostCoroutine(AuthBaseUrl + "/auth/login", json, "Signed in.")); } void OnRegister() { if (_authInProgress) return; string username = usernameInputRegister.text; string email = emailInputRegister != null ? emailInputRegister.text : ""; string password = passwordInputRegister.text; if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(email) || string.IsNullOrEmpty(password)) { error_txt.text = "Please fill in all fields"; return; } email = email.Trim().ToLowerInvariant(); if (!IsValidEmailFormat(email)) { error_txt.text = "Please enter a valid email address"; return; } if (username.Length < MIN_USERNAME_LENGTH || username.Length > MAX_USERNAME_LENGTH) { error_txt.text = "Username must be between " + MIN_USERNAME_LENGTH + " and " + MAX_USERNAME_LENGTH + " characters"; return; } if (password.Length < MIN_PASSWORD_LENGTH || password.Length > MAX_PASSWORD_LENGTH) { error_txt.text = "Password must be between " + MIN_PASSWORD_LENGTH + " and " + MAX_PASSWORD_LENGTH + " characters"; return; } string json = "{\"username\":\"" + EscapeJsonString(username) + "\",\"email\":\"" + EscapeJsonString(email) + "\",\"password\":\"" + EscapeJsonString(password) + "\"}"; StartCoroutine(AuthPostCoroutine(AuthBaseUrl + "/auth/register", json, "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("\"", "\\\""); } IEnumerator AuthPostCoroutine(string url, string jsonBody, string successMessage) { SetBusy(true); error_txt.text = ""; ClearLocalUserProfile(); 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(); string text = request.downloadHandler != null ? request.downloadHandler.text : ""; if (request.result != UnityWebRequest.Result.Success && request.result != UnityWebRequest.Result.ProtocolError) { error_txt.text = string.IsNullOrEmpty(request.error) ? "Network error" : request.error; SetBusy(false); yield break; } if (string.IsNullOrEmpty(text)) { error_txt.text = "Empty response from server"; SetBusy(false); yield break; } AuthApiResponse resp = JsonUtility.FromJson(text); if (resp.ok && !string.IsNullOrEmpty(resp.token)) { AuthToken = resp.token; PlayerPrefs.SetString(PlayerPrefsAuthKey, resp.token); PlayerPrefs.Save(); bool profileOk = false; string profileErr = null; yield return StartCoroutine(FetchUserDataCoroutine((success, err, _) => { profileOk = success; profileErr = err; }, true)); if (profileOk) ProceedToMainMenu(); else if (error_txt != null) error_txt.text = string.IsNullOrEmpty(profileErr) ? "Signed in but could not load your profile. Check your connection and try again." : profileErr; } else { error_txt.text = string.IsNullOrEmpty(resp.error) ? "Request failed" : resp.error; } } SetBusy(false); } 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 (request.result != UnityWebRequest.Result.Success && request.result != UnityWebRequest.Result.ProtocolError) { onComplete?.Invoke(false, string.IsNullOrEmpty(request.error) ? "Network error" : request.error, null); yield break; } if (request.responseCode < 200 || request.responseCode >= 300) { onComplete?.Invoke(false, string.IsNullOrEmpty(text) ? "Request failed (" + request.responseCode + ")" : text, 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 (request.result != UnityWebRequest.Result.Success && request.result != UnityWebRequest.Result.ProtocolError) { onComplete?.Invoke(false, string.IsNullOrEmpty(request.error) ? "Network error" : request.error); yield break; } if (request.responseCode < 200 || request.responseCode >= 300) { onComplete?.Invoke(false, string.IsNullOrEmpty(text) ? "Request failed (" + request.responseCode + ")" : text); yield break; } if (string.IsNullOrEmpty(text)) { onComplete?.Invoke(false, "Empty response from server"); 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) ? "Purchase ok but profile refresh failed" : refreshErr); } else { string err = resp != null && !string.IsNullOrEmpty(resp.error) ? resp.error : "Purchase failed"; onComplete?.Invoke(false, err); } } } 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 (request.result != UnityWebRequest.Result.Success && request.result != UnityWebRequest.Result.ProtocolError) { onComplete?.Invoke(false, string.IsNullOrEmpty(request.error) ? "Network error" : request.error, null); yield break; } if (request.responseCode < 200 || request.responseCode >= 300) { onComplete?.Invoke(false, string.IsNullOrEmpty(text) ? "Request failed (" + request.responseCode + ")" : text, null); yield break; } if (string.IsNullOrEmpty(text)) { onComplete?.Invoke(false, "Empty response from server", 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 : "Ping report failed"; onComplete?.Invoke(false, err, 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 (request.result != UnityWebRequest.Result.Success && request.result != UnityWebRequest.Result.ProtocolError) { onComplete?.Invoke(false, string.IsNullOrEmpty(request.error) ? "Network error" : request.error, null); yield break; } if (request.responseCode < 200 || request.responseCode >= 300) { onComplete?.Invoke(false, string.IsNullOrEmpty(text) ? "Request failed (" + request.responseCode + ")" : text, null); yield break; } if (string.IsNullOrEmpty(text)) { onComplete?.Invoke(false, "Empty response from server", null); yield break; } AuthUserApiResponse resp = JsonUtility.FromJson(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 : "Request failed"; onComplete?.Invoke(false, err, null); } } } }