Commit ac3d4138 authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix: audio mute bypass, TF timer race condition, analytics resilience, and login/profile validation

parent 48c75590
using System;
using com.al_arcade.shared;
using Cysharp.Threading.Tasks;
using EasyTransition;
using Supabase.Gotrue;
......@@ -42,34 +43,26 @@ public class AppRouter : MonoBehaviour
private async UniTask Boot()
{
// 1. Try cache first — instant
// var cached = UserService.Instance.LoadFromCache();
var cached = UserService.Instance.LoadFromCache();
TransitionManager.Instance().onTransitionCutPointReached += HideSplash;
// 2. Init Supabase
bool ready = await SupabaseManager.Instance.Initialize();
if (!ready)
{
// Offline but have cache — go to home
// if (cached != null) { GoToHome(); return; }
if (cached != null) { GoToHome(); return; }
ShowError("Failed to connect");
return;
}
// 3. Ensure session
var authResult = await SupabaseAuthentication.Instance.EnsureSession();
if (authResult.IsT1)
{
// if (cached != null) { GoToHome(); return; }
if (cached != null) { GoToHome(); return; }
GoToLogin();
return;
}
// 4. Refresh from network
var profileResult = UserService.Instance.GetCurrentUser();
var prof = await profileResult;
var prof = await UserService.Instance.GetCurrentUser();
prof.Switch(
async success =>
......@@ -79,8 +72,16 @@ public class AppRouter : MonoBehaviour
},
error =>
{
Debug.LogError($"[Boot] Failed to load user profile: {error}");
GoToLogin();
if (cached != null)
{
Debug.LogWarning($"[Boot] Network profile fetch failed, using cache");
GoToHome();
}
else
{
Debug.LogError($"[Boot] Failed to load user profile: {error}");
GoToLogin();
}
}
);
}
......@@ -120,10 +121,16 @@ public class AppRouter : MonoBehaviour
public static async void Logout()
{
CleanupSingletons();
await SupabaseAuthentication.Instance.LogOut();
GoToLogin();
}
private static void CleanupSingletons()
{
try { SSAudioManager.Instance?.StopMusic(); } catch { }
}
// ─── Helpers ─────────────────────────────────────────────────────
private static void HideSplash()
{
......
......@@ -127,22 +127,31 @@ public class UserService
if (response == null)
return new ErrorResult("User profile not found");
var oldUser = CurrentUser;
CurrentUser = response;
IsFromCache = false;
// SaveToCache(CurrentUser);
SaveToCache(CurrentUser);
// if (oldUser != null && oldUser.Rank != CurrentUser.Rank)
// OnRankChanged?.Invoke(oldUser.Rank, CurrentUser.Rank);
if (oldUser != null && oldUser.Rank != CurrentUser.Rank)
OnRankChanged?.Invoke(oldUser.Rank, CurrentUser.Rank);
// if (oldUser != null && oldUser.Points != CurrentUser.Points)
// OnPointsChanged?.Invoke(oldUser.Points, CurrentUser.Points);
if (oldUser != null && oldUser.Points != CurrentUser.Points)
OnPointsChanged?.Invoke(oldUser.Points, CurrentUser.Points);
OnUserChanged?.Invoke(CurrentUser);
return new UserResult(CurrentUser);
}
catch (Exception ex)
{
Debug.LogError($"[UserService] GetCurrentUser failed: {ex.Message} + {ex.StackTrace}");
Debug.LogWarning($"[UserService] GetCurrentUser network failed: {ex.Message}");
var fallback = LoadFromCache();
if (fallback != null)
{
Debug.Log("[UserService] Using cached user profile");
return new UserResult(fallback);
}
return new ErrorResult(ex.Message);
}
}
......
......@@ -110,14 +110,26 @@ public static class EducationManager
// --- Generic ID Lookups ---
public static int GetGradeId(string name) =>
GradeMap.FirstOrDefault(x => x.Value == name.Trim()).Key;
public static int GetGradeId(string name)
{
if (string.IsNullOrWhiteSpace(name)) return -1;
var match = GradeMap.FirstOrDefault(x => x.Value == name.Trim());
return GradeMap.ContainsKey(match.Key) ? match.Key : -1;
}
public static int GetTermId(string name) =>
TermMap.FirstOrDefault(x => x.Value == name.Trim()).Key;
public static int GetTermId(string name)
{
if (string.IsNullOrWhiteSpace(name)) return -1;
var match = TermMap.FirstOrDefault(x => x.Value == name.Trim());
return TermMap.ContainsKey(match.Key) ? match.Key : -1;
}
public static int GetCurriculumId(string name) =>
CurriculumMap.FirstOrDefault(x => x.Value == name.Trim()).Key;
public static int GetCurriculumId(string name)
{
if (string.IsNullOrWhiteSpace(name)) return -1;
var match = CurriculumMap.FirstOrDefault(x => x.Value == name.Trim());
return CurriculumMap.ContainsKey(match.Key) ? match.Key : -1;
}
// --- Lists for UI Dropdowns ---
public static List<string> GradeList => GradeMap.Values.ToList();
......
......@@ -35,31 +35,42 @@ public class LoginController : MonoBehaviour
public async void RegisterAnon()
{
var trimmed = username.text?.Trim();
int gradeId = EducationManager.GetGradeId(grade.value);
int termId = EducationManager.GetTermId(term.value);
int currId = EducationManager.GetCurriculumId(curriculum.value);
if (string.IsNullOrEmpty(trimmed)
|| trimmed.Length < 2
|| trimmed.Length > 30
|| gradeId < 0
|| termId < 0
|| currId < 0
|| string.IsNullOrEmpty(sex.value))
{
Debug.LogWarning("[Login] Validation failed — fill all fields, username 2-30 chars");
return;
}
register.text = "جاري التسجيل...";
register.SetEnabled(false);
var auth = await SupabaseAuthentication.Instance.LoginAnon();
if (auth.IsT1)
{
Debug.LogError($"Authentication failed");
return;
}
// Check if all fields are filled
if (string.IsNullOrEmpty(username.text) || grade.value == null || sex.value == null || term.value == null || curriculum.value == null)
{
Debug.LogError("Please fill in all fields");
Debug.LogError("[Login] Authentication failed");
register.text = "تسجيل";
register.SetEnabled(true);
return;
}
var signUp = await UserService.Instance.CreateProfile(
username: username.text,
grade: EducationManager.GetGradeId(grade.value),
username: trimmed,
grade: gradeId,
sex: sex.value,
term: EducationManager.GetTermId(term.value),
curriculum: EducationManager.GetCurriculumId(curriculum.value)
term: termId,
curriculum: currId
);
signUp.Switch(user =>
......@@ -67,12 +78,10 @@ public class LoginController : MonoBehaviour
AppRouter.GoToHome();
}, error =>
{
Debug.LogError($"Failed to create user: {error.Message}");
Debug.LogError($"[Login] Failed to create user: {error.Message}");
register.text = "تسجيل";
register.SetEnabled(true);
});
}
......
......@@ -55,10 +55,19 @@ public class ProfileController : MonoBehaviour
private void UpdateProfile()
{
int gradeId = EducationManager.GetGradeId(Grade.value);
int termId = EducationManager.GetTermId(Term.value);
if (gradeId < 0 || termId < 0)
{
Debug.LogWarning("[Profile] Invalid grade or term selection");
return;
}
UserService.Instance.UpdateProfile(
username: UsernameLabel.value,
grade: EducationManager.GetGradeId(Grade.value),
term: EducationManager.GetTermId(Term.value)
grade: gradeId,
term: termId
);
}
......
......@@ -514,7 +514,7 @@ namespace com.al_arcade.cs
_scoreLbl.enabled = value;
if (value)
{
_scoreLbl.Text = "الوقت الموفر";
_scoreLbl.Text = "نقاط إضافية";
}
else
{
......
......@@ -87,7 +87,7 @@ public class ChallengeCanvas : MonoBehaviour
public void ShowChallengeResult(bool hasWon, int timeSaved, int pointsChange)
{
statusText.Text = hasWon ? "كسبت التحدي" : "خسرت التحدي";
timeSavedText.Text = hasWon ? $"الوقت الموفر: {timeSaved} ثانية" : "";
timeSavedText.Text = hasWon ? $"نقاط إضافية: {timeSaved}" : "";
pointsEarnedText.Text = hasWon ? $"النقاط المكتسبة: {pointsChange}" : $"خسرت {pointsChange} نقطة";
restartButton.gameObject.SetActive(true);
......
......@@ -377,7 +377,8 @@ namespace com.al_arcade.mcq
if (particles != null && player != null)
particles.PlayCorrectBurst(player.transform.position + Vector3.up * 2f);
_mainCamera.DOColor(SSColorPalette.CorrectWord, 1).SetEase(Ease.Flash, 2);
DOTween.Kill(_mainCamera, "camColor");
_mainCamera.DOColor(SSColorPalette.CorrectWord, 1).SetEase(Ease.Flash, 2).SetId("camColor");
}
else
{
......@@ -401,7 +402,8 @@ namespace com.al_arcade.mcq
if (particles != null && player != null)
particles.PlayWrongBurst(player.transform.position + Vector3.up * 2f);
_mainCamera.DOColor(SSColorPalette.WrongWord, 1).SetEase(Ease.Flash, 2);
DOTween.Kill(_mainCamera, "camColor");
_mainCamera.DOColor(SSColorPalette.WrongWord, 1).SetEase(Ease.Flash, 2).SetId("camColor");
}
if (uiManager != null)
......@@ -573,7 +575,8 @@ namespace com.al_arcade.mcq
private void CameraFeedback(bool correct)
{
_mainCamera.DOFieldOfView(correct ? 78f : 76f, 0.2f).SetEase(Ease.OutQuad);
DOTween.Kill(_mainCamera, "camFov");
_mainCamera.DOFieldOfView(correct ? 78f : 76f, 0.2f).SetEase(Ease.OutQuad).SetId("camFov");
}
// ─── End Sequences ───────────────────────────────────────────────────
......
......@@ -81,8 +81,8 @@ namespace com.al_arcade.mcq
runEffect.Play();
transform.DOBlendableMoveBy(Vector3.forward * 5, .2f);
audioSource.PlayOneShot(dashClip);
audioSource.PlayOneShot(correctAnswerClip);
SSAudioManager.Instance?.Play(dashClip);
SSAudioManager.Instance?.Play(correctAnswerClip);
// RunForward(maxForwardSpeed);
}
......
......@@ -121,14 +121,7 @@ namespace com.al_arcade.mcq
{
_scoreText.enabled = value;
_scoreLbl.enabled = value;
if (value)
{
_scoreLbl.Text = "الوقت الموفر";
}
else
{
_scoreLbl.Text = "النقاط";
}
_scoreLbl.Text = value ? "نقاط إضافية" : "النقاط";
}
public void SetStreak(int streak)
......
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Text;
using UnityEngine;
using UnityEngine.Networking;
......@@ -46,9 +47,110 @@ namespace com.al_arcade.shared
return Instance;
}
// ═══════════════════════════════════════════════════
// OFFLINE ANALYTICS QUEUE
// ═══════════════════════════════════════════════════
private List<AttemptData> _pendingAttempts = new();
private bool _flushing;
private string PendingPath => Path.Combine(Application.persistentDataPath, "pending_attempts.json");
private void Start()
{
LoadPendingFromDisk();
StartCoroutine(AutoFlushLoop());
}
private void LoadPendingFromDisk()
{
try
{
if (!File.Exists(PendingPath)) return;
var json = File.ReadAllText(PendingPath);
var loaded = JsonConvert.DeserializeObject<List<AttemptData>>(json);
if (loaded != null && loaded.Count > 0)
{
_pendingAttempts.AddRange(loaded);
Debug.Log($"[SSApi] Loaded {loaded.Count} pending attempts from disk");
}
}
catch (Exception e)
{
Debug.LogWarning($"[SSApi] Failed to load pending attempts: {e.Message}");
}
}
private void SavePendingToDisk()
{
try
{
if (_pendingAttempts.Count == 0)
{
if (File.Exists(PendingPath)) File.Delete(PendingPath);
return;
}
var json = JsonConvert.SerializeObject(_pendingAttempts);
File.WriteAllText(PendingPath, json);
}
catch (Exception e)
{
Debug.LogWarning($"[SSApi] Failed to save pending attempts: {e.Message}");
}
}
private void QueueAttempt(AttemptData attempt)
{
_pendingAttempts.Add(attempt);
SavePendingToDisk();
Debug.Log($"[SSApi] Queued attempt (total pending: {_pendingAttempts.Count})");
}
private IEnumerator AutoFlushLoop()
{
while (true)
{
yield return new WaitForSeconds(60f);
if (_pendingAttempts.Count > 0 && !_flushing)
yield return FlushPendingAttempts();
}
}
private IEnumerator FlushPendingAttempts()
{
if (_pendingAttempts.Count == 0 || _flushing) yield break;
_flushing = true;
var batch = _pendingAttempts.ToArray();
var payload = new BatchAttemptPayload { attempts = batch };
var json = JsonConvert.SerializeObject(payload,
new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore });
bool success = false;
yield return PostJson("report_batch", json,
_ => success = true,
err => Debug.LogWarning($"[SSApi] Flush failed: {err}"));
if (success)
{
Debug.Log($"[SSApi] Flushed {batch.Length} pending attempts");
_pendingAttempts.Clear();
SavePendingToDisk();
}
_flushing = false;
}
// ═══════════════════════════════════════════════════
// LOW-LEVEL HTTP HELPERS
// ═══════════════════════════════════════════════════
private void AttachAuth(UnityWebRequest req)
{
var token = SupabaseManager.Instance?.Supabase()?.Auth?.CurrentSession?.AccessToken;
if (!string.IsNullOrEmpty(token))
req.SetRequestHeader("Authorization", "Bearer " + token);
}
private IEnumerator GetRequest(string action,
Dictionary<string, string> parameters,
Action<string> onSuccess,
......@@ -66,6 +168,7 @@ namespace com.al_arcade.shared
using var req = UnityWebRequest.Get(url);
req.timeout = timeoutSeconds;
AttachAuth(req);
yield return req.SendWebRequest();
......@@ -95,6 +198,7 @@ namespace com.al_arcade.shared
using var req = UnityWebRequest.Post(baseUrl, form);
req.timeout = timeoutSeconds;
AttachAuth(req);
yield return req.SendWebRequest();
......@@ -119,6 +223,7 @@ namespace com.al_arcade.shared
req.uploadHandler = new UploadHandlerRaw(bodyRaw);
req.downloadHandler = new DownloadHandlerBuffer();
req.SetRequestHeader("Content-Type", "application/json");
AttachAuth(req);
req.timeout = timeoutSeconds;
yield return req.SendWebRequest();
......@@ -420,16 +525,31 @@ namespace com.al_arcade.shared
string json = JsonConvert.SerializeObject(attempt,
new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore });
bool failed = false;
yield return PostJson("report_attempt", json,
text =>
{
var r = Parse<ReportAttemptResponse>(text);
if (r is { success: true })
{
onSuccess?.Invoke(r);
if (_pendingAttempts.Count > 0 && !_flushing)
StartCoroutine(FlushPendingAttempts());
}
else
{
failed = true;
onError?.Invoke(r?.error ?? "Failed to report attempt");
}
},
onError);
err =>
{
failed = true;
onError?.Invoke(err);
});
if (failed)
QueueAttempt(attempt);
}
/// <summary>Quick overload: report with minimal data.</summary>
......
......@@ -132,7 +132,7 @@ namespace com.al_arcade.tf
public void SubmitAnswer(bool answer)
{
if (!_waitingForAnswer) return;
if (!_waitingForAnswer || !_timerRunning) return;
_waitingForAnswer = false;
_pendingAnswer = answer ? 1 : 0;
}
......@@ -171,7 +171,7 @@ namespace com.al_arcade.tf
if (handController != null) handController.SetReady(true);
while (_pendingAnswer < 0)
while (_pendingAnswer < 0 && _timerRunning)
{
if (Input.GetKeyDown(KeyCode.LeftArrow) || Input.GetKeyDown(KeyCode.A))
SubmitAnswer(true);
......@@ -180,6 +180,8 @@ namespace com.al_arcade.tf
yield return null;
}
if (_pendingAnswer < 0) yield break;
if (handController != null) handController.SetReady(false);
_state = TfGameState.Feedback;
......
......@@ -59,10 +59,7 @@ namespace com.al_arcade.tf
private void PlayMoveSound()
{
if (moveSound == null) return;
moveAudioSource.pitch = 1.2f;
moveAudioSource.PlayOneShot(moveSound);
SSAudioManager.Instance?.Play(moveSound, 1f, 1.2f);
}
public void Build(int stepsToWin, float stepSize, Vector3 cameraPos)
......
......@@ -158,7 +158,7 @@ namespace com.al_arcade.tf
{
_scoreText.enabled = value;
_scoreLbl.enabled = value;
_scoreLbl.Text = value ? "الوقت الموفر" : "النقاط";
_scoreLbl.Text = value ? "نقاط إضافية" : "النقاط";
}
public void SetStreak(int s)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment