From e991dccbb255732dbd85f109758279ef897e9495 Mon Sep 17 00:00:00 2001 From: hz Date: Fri, 10 Jul 2026 14:05:57 -0500 Subject: [PATCH] Add core game retry flow and question stats --- .../Controllers/QuestionsController.cs | 2 + api/QuestionsApi/Models/Question.cs | 1 + api/QuestionsApi/Models/QuestionRequests.cs | 1 + api/QuestionsApi/Models/QuestionResponse.cs | 2 + api/QuestionsApi/Services/QuestionStore.cs | 1 + scripts/autoload/block_session_service.gd | 212 +++++++++- scripts/autoload/profile_service.gd | 117 +++++- scripts/autoload/progression_service.gd | 33 ++ scripts/autoload/question_bank_service.gd | 68 +++- scripts/ui/block_setup.gd | 17 +- scripts/ui/dashboard.gd | 155 ++++++++ scripts/ui/dashboard.gd.uid | 1 + scripts/ui/login_screen.gd | 2 +- scripts/ui/main.gd | 109 ++++- scripts/ui/progress_profile.gd | 11 +- scripts/ui/question_player.gd | 375 +++++++++++++++--- scripts/ui/study_map.gd | 9 +- 17 files changed, 1036 insertions(+), 80 deletions(-) create mode 100644 scripts/ui/dashboard.gd create mode 100644 scripts/ui/dashboard.gd.uid diff --git a/api/QuestionsApi/Controllers/QuestionsController.cs b/api/QuestionsApi/Controllers/QuestionsController.cs index 0ee2fe3..45f1a09 100644 --- a/api/QuestionsApi/Controllers/QuestionsController.cs +++ b/api/QuestionsApi/Controllers/QuestionsController.cs @@ -126,6 +126,8 @@ public class QuestionsController : ControllerBase return "correctChoiceId must match a choice id"; if (string.IsNullOrWhiteSpace(req.Explanation)) return "explanation required"; + if (req.ChoiceSelectionPercentages.Any(entry => entry.Value is < 0 or > 100)) + return "choiceSelectionPercentages values must be between 0 and 100"; if (string.IsNullOrWhiteSpace(req.System)) return "system required"; if (string.IsNullOrWhiteSpace(req.Discipline)) diff --git a/api/QuestionsApi/Models/Question.cs b/api/QuestionsApi/Models/Question.cs index 8f385b8..df21fc9 100644 --- a/api/QuestionsApi/Models/Question.cs +++ b/api/QuestionsApi/Models/Question.cs @@ -16,6 +16,7 @@ public class Question public string CorrectChoiceId { get; set; } = ""; public string Explanation { get; set; } = ""; public Dictionary ChoiceExplanations { get; set; } = []; + public Dictionary ChoiceSelectionPercentages { get; set; } = []; public string Discipline { get; set; } = ""; public string System { get; set; } = ""; public int Difficulty { get; set; } = 1; diff --git a/api/QuestionsApi/Models/QuestionRequests.cs b/api/QuestionsApi/Models/QuestionRequests.cs index 81465d5..2841a74 100644 --- a/api/QuestionsApi/Models/QuestionRequests.cs +++ b/api/QuestionsApi/Models/QuestionRequests.cs @@ -19,6 +19,7 @@ public class UpsertQuestionRequest public string CorrectChoiceId { get; set; } = ""; public string Explanation { get; set; } = ""; public Dictionary ChoiceExplanations { get; set; } = []; + public Dictionary ChoiceSelectionPercentages { get; set; } = []; public string Discipline { get; set; } = ""; public string System { get; set; } = ""; public int Difficulty { get; set; } = 1; diff --git a/api/QuestionsApi/Models/QuestionResponse.cs b/api/QuestionsApi/Models/QuestionResponse.cs index ccdbc1a..a79b7bf 100644 --- a/api/QuestionsApi/Models/QuestionResponse.cs +++ b/api/QuestionsApi/Models/QuestionResponse.cs @@ -10,6 +10,7 @@ public class QuestionResponse public string CorrectChoiceId { get; set; } = ""; public string Explanation { get; set; } = ""; public Dictionary ChoiceExplanations { get; set; } = []; + public Dictionary ChoiceSelectionPercentages { get; set; } = []; public string Discipline { get; set; } = ""; public string System { get; set; } = ""; public int Difficulty { get; set; } @@ -27,6 +28,7 @@ public class QuestionResponse CorrectChoiceId = question.CorrectChoiceId, Explanation = question.Explanation, ChoiceExplanations = question.ChoiceExplanations, + ChoiceSelectionPercentages = question.ChoiceSelectionPercentages, Discipline = question.Discipline, System = question.System, Difficulty = question.Difficulty, diff --git a/api/QuestionsApi/Services/QuestionStore.cs b/api/QuestionsApi/Services/QuestionStore.cs index 2664514..da71016 100644 --- a/api/QuestionsApi/Services/QuestionStore.cs +++ b/api/QuestionsApi/Services/QuestionStore.cs @@ -133,6 +133,7 @@ public class QuestionStore CorrectChoiceId = req.CorrectChoiceId.Trim(), Explanation = req.Explanation.Trim(), ChoiceExplanations = req.ChoiceExplanations, + ChoiceSelectionPercentages = req.ChoiceSelectionPercentages, Discipline = req.Discipline.Trim(), System = req.System.Trim(), Difficulty = req.Difficulty, diff --git a/scripts/autoload/block_session_service.gd b/scripts/autoload/block_session_service.gd index 247e57d..8b0ab29 100644 --- a/scripts/autoload/block_session_service.gd +++ b/scripts/autoload/block_session_service.gd @@ -9,10 +9,26 @@ var mode := "Tutor" var system_name := "All Systems" var seconds_remaining := 0.0 var block_started := false +var session_kind := "block" +var core_question_pool: Array = [] +var core_answer_count := 0 +var core_score := 0 +var core_energy := 70 +var core_target_seconds := 90.0 +var core_current_target_seconds := 90.0 +var core_current_had_wrong_attempt := false +var current_question_started_at := 0.0 +var current_question_pause_started_at := 0.0 +var core_status_message := "" + +const CORE_MIN_TARGET_SECONDS := 45.0 +const CORE_MAX_TARGET_SECONDS := 150.0 +const CORE_RETRY_TARGET_SECONDS := 30.0 func start_block(selected_system: String, block_size: int, selected_mode: String) -> void: system_name = selected_system mode = selected_mode + session_kind = "block" active_questions = QuestionBankService.build_block(selected_system, block_size) answers.clear() answers.resize(active_questions.size()) @@ -22,6 +38,31 @@ func start_block(selected_system: String, block_size: int, selected_mode: String session_changed.emit() +func start_core_run() -> bool: + system_name = "All Systems" + mode = "Core" + session_kind = "core" + active_questions.clear() + answers.clear() + core_question_pool.clear() + current_index = -1 + seconds_remaining = 0.0 + block_started = true + core_answer_count = 0 + core_score = 0 + core_energy = 70 + core_target_seconds = 90.0 + core_current_target_seconds = core_target_seconds + core_current_had_wrong_attempt = false + core_status_message = "" + if not advance_core_question(): + block_started = false + core_status_message = "All available questions cleared." + session_changed.emit() + return false + return true + + func get_current_question() -> Dictionary: if active_questions.is_empty() or current_index < 0 or current_index >= active_questions.size(): return {} @@ -38,13 +79,57 @@ func answer_current(choice_id: String, eliminated: Array, flagged: bool) -> Dict "selected_choice_id": choice_id, "is_correct": choice_id == str(question.get("correct_choice_id", "")), "eliminated_choice_ids": eliminated.duplicate(), - "flagged": flagged + "flagged": flagged, + "elapsed_seconds": get_current_elapsed_seconds(), + "target_seconds": core_target_seconds if session_kind == "core" else 0.0 } answers[current_index] = answer + if session_kind == "core": + _apply_core_answer_rewards(answer) session_changed.emit() return answer +func submit_core_attempt(choice_id: String, eliminated: Array, flagged: bool) -> Dictionary: + var question := get_current_question() + if question.is_empty(): + return {} + + var answer := { + "question": question.duplicate(true), + "selected_choice_id": choice_id, + "is_correct": choice_id == str(question.get("correct_choice_id", "")), + "eliminated_choice_ids": eliminated.duplicate(), + "flagged": flagged, + "elapsed_seconds": get_current_elapsed_seconds(), + "target_seconds": core_current_target_seconds + } + + if bool(answer.get("is_correct", false)): + var mastered := not core_current_had_wrong_attempt + answers[current_index] = answer + _apply_core_answer_rewards(answer, mastered) + session_changed.emit() + return { + "answer": answer, + "is_correct": true, + "final": true, + "mastered": mastered + } + + ProfileService.record_wrong_attempt(question) + core_current_had_wrong_attempt = true + _apply_core_wrong_penalty(answer) + _start_core_retry_window() + session_changed.emit() + return { + "answer": answer, + "is_correct": false, + "final": false, + "mastered": false + } + + func move_previous() -> bool: if current_index > 0: current_index -= 1 @@ -54,6 +139,8 @@ func move_previous() -> bool: func move_next() -> bool: + if session_kind == "core": + return advance_core_question() if current_index + 1 < active_questions.size(): current_index += 1 session_changed.emit() @@ -70,10 +157,17 @@ func move_to(index: int) -> bool: func get_progress_text() -> String: + if session_kind == "core": + return str(core_answer_count + 1) return "%d / %d" % [min(current_index + 1, active_questions.size()), active_questions.size()] func finish_block() -> Dictionary: + if session_kind == "core": + block_started = false + session_changed.emit() + return {} + var final_answers: Array = [] for index in range(active_questions.size()): if answers[index] == null: @@ -102,3 +196,119 @@ func get_answer_for_index(index: int) -> Variant: if index < 0 or index >= answers.size(): return null return answers[index] + + +func advance_core_question() -> bool: + var question := _draw_core_question() + if question.is_empty(): + core_status_message = "All available questions cleared." + return false + active_questions.append(question) + answers.append(null) + current_index = active_questions.size() - 1 + current_question_started_at = Time.get_ticks_msec() / 1000.0 + current_question_pause_started_at = 0.0 + core_current_target_seconds = core_target_seconds + core_current_had_wrong_attempt = false + session_changed.emit() + return true + + +func get_current_elapsed_seconds() -> float: + if current_question_started_at <= 0.0: + return 0.0 + var now := current_question_pause_started_at if current_question_pause_started_at > 0.0 else Time.get_ticks_msec() / 1000.0 + return max(0.0, now - current_question_started_at) + + +func get_core_timer_ratio() -> float: + if session_kind != "core" or core_current_target_seconds <= 0.0: + return 1.0 + var ratio := 1.0 - (get_current_elapsed_seconds() / core_current_target_seconds) + return clamp(ratio, 0.0, 1.0) + + +func pause_current_question_timer() -> void: + if session_kind == "core" and current_question_pause_started_at <= 0.0: + current_question_pause_started_at = Time.get_ticks_msec() / 1000.0 + + +func resume_current_question_timer() -> void: + if session_kind == "core" and current_question_pause_started_at > 0.0: + var paused_duration := Time.get_ticks_msec() / 1000.0 - current_question_pause_started_at + current_question_started_at += paused_duration + current_question_pause_started_at = 0.0 + + +func _draw_core_question() -> Dictionary: + if core_question_pool.is_empty(): + core_question_pool = _build_core_question_pool() + core_question_pool.shuffle() + if core_question_pool.is_empty(): + return {} + + var last_id := str(get_current_question().get("id", "")) + for index in range(core_question_pool.size()): + var candidate: Dictionary = core_question_pool[index] + if str(candidate.get("id", "")) != last_id or core_question_pool.size() == 1: + core_question_pool.remove_at(index) + return candidate.duplicate(true) + return {} + + +func _build_core_question_pool() -> Array: + var pool: Array = [] + var profile := ProfileService.get_profile() + var correct_ids: Array = profile.get("correct_question_ids", []) + for question in QuestionBankService.get_all_questions(): + var question_id := str(question.get("id", "")) + if question_id == "" or not correct_ids.has(question_id): + pool.append(question) + return pool + + +func _start_core_retry_window() -> void: + core_current_target_seconds = CORE_RETRY_TARGET_SECONDS + current_question_started_at = Time.get_ticks_msec() / 1000.0 + current_question_pause_started_at = 0.0 + + +func _apply_core_wrong_penalty(_answer: Dictionary) -> void: + core_score -= 25 + core_energy = max(0, core_energy - 14) + + +func _apply_core_answer_rewards(answer: Dictionary, mastered: bool = true) -> void: + core_answer_count += 1 + var elapsed := float(answer.get("elapsed_seconds", 0.0)) + var target := core_current_target_seconds + if target < CORE_MIN_TARGET_SECONDS: + target = CORE_MIN_TARGET_SECONDS + var is_correct := bool(answer.get("is_correct", false)) + var score_delta := 100 if is_correct else 0 + if elapsed > target: + var overage_ratio := (elapsed - target) / target + if overage_ratio > 1.0: + overage_ratio = 1.0 + score_delta -= int(round(40.0 * overage_ratio)) + if not is_correct: + score_delta -= 25 + core_score += score_delta + + if is_correct: + core_energy = min(100, core_energy + 8) + else: + core_energy = max(0, core_energy - 14) + if elapsed > target: + core_energy = max(0, core_energy - 4) + + if mastered and is_correct and elapsed <= target: + core_target_seconds = max(CORE_MIN_TARGET_SECONDS, core_target_seconds * 0.97) + elif not mastered or not is_correct or elapsed > target * 1.25: + core_target_seconds = min(CORE_MAX_TARGET_SECONDS, core_target_seconds * 1.05) + else: + core_target_seconds = max(CORE_MIN_TARGET_SECONDS, core_target_seconds * 0.99) + + var results := ProgressionService.calculate_question_rewards(answer, elapsed, target, score_delta) + results["mastered"] = mastered + ProfileService.apply_question_results(results) diff --git a/scripts/autoload/profile_service.gd b/scripts/autoload/profile_service.gd index b2e7450..d7b8273 100644 --- a/scripts/autoload/profile_service.gd +++ b/scripts/autoload/profile_service.gd @@ -11,8 +11,15 @@ var profile: Dictionary = { "best_streak": 0, "badges": [], "completed_question_ids": [], + "correct_question_ids": [], + "question_wrong_counts": {}, + "missed_question_ids": [], "system_stats": {}, - "block_history": [] + "block_history": [], + "daily_date": "", + "daily_questions_answered": 0, + "daily_xp_earned": 0, + "daily_goal_questions": 10 } func _ready() -> void: @@ -21,6 +28,7 @@ func _ready() -> void: func load_profile() -> void: if not FileAccess.file_exists(SAVE_PATH): + _ensure_daily_current() save_profile() return @@ -34,6 +42,8 @@ func load_profile() -> void: for key in profile.keys(): if parsed.has(key): profile[key] = parsed[key] + _ensure_tracking_fields() + _ensure_daily_current() profile_changed.emit() @@ -46,14 +56,18 @@ func save_profile() -> void: func get_profile() -> Dictionary: + _ensure_daily_current() return profile.duplicate(true) func apply_block_results(results: Dictionary) -> void: + _ensure_daily_current() profile["xp"] = int(profile["xp"]) + int(results.get("xp_earned", 0)) profile["level"] = ProgressionService.level_for_xp(int(profile["xp"])) profile["current_streak"] = int(results.get("ending_streak", profile["current_streak"])) profile["best_streak"] = max(int(profile["best_streak"]), int(profile["current_streak"])) + profile["daily_questions_answered"] = int(profile["daily_questions_answered"]) + int(results.get("total", 0)) + profile["daily_xp_earned"] = int(profile["daily_xp_earned"]) + int(results.get("xp_earned", 0)) var unlocked_badges: Array = results.get("badges_unlocked", []) for badge in unlocked_badges: @@ -66,13 +80,10 @@ func apply_block_results(results: Dictionary) -> void: var question_id := str(question.get("id", "")) if question_id != "" and not profile["completed_question_ids"].has(question_id): profile["completed_question_ids"].append(question_id) + _apply_question_tracking(question_id, bool(answer.get("is_correct", false))) var system_name := str(question.get("system", "General")) - if not profile["system_stats"].has(system_name): - profile["system_stats"][system_name] = {"seen": 0, "correct": 0} - profile["system_stats"][system_name]["seen"] = int(profile["system_stats"][system_name]["seen"]) + 1 - if bool(answer.get("is_correct", false)): - profile["system_stats"][system_name]["correct"] = int(profile["system_stats"][system_name]["correct"]) + 1 + _apply_system_result(system_name, bool(answer.get("is_correct", false))) var summary: Dictionary = { "timestamp": Time.get_datetime_string_from_system(), @@ -98,8 +109,100 @@ func reset_profile() -> void: "best_streak": 0, "badges": [], "completed_question_ids": [], + "correct_question_ids": [], + "question_wrong_counts": {}, + "missed_question_ids": [], "system_stats": {}, - "block_history": [] + "block_history": [], + "daily_date": _today_string(), + "daily_questions_answered": 0, + "daily_xp_earned": 0, + "daily_goal_questions": 10 } save_profile() profile_changed.emit() + + +func apply_question_results(results: Dictionary) -> void: + _ensure_daily_current() + profile["xp"] = int(profile["xp"]) + int(results.get("xp_earned", 0)) + profile["level"] = ProgressionService.level_for_xp(int(profile["xp"])) + profile["current_streak"] = int(results.get("ending_streak", profile["current_streak"])) + profile["best_streak"] = max(int(profile["best_streak"]), int(profile["current_streak"])) + profile["daily_questions_answered"] = int(profile["daily_questions_answered"]) + int(results.get("total", 0)) + profile["daily_xp_earned"] = int(profile["daily_xp_earned"]) + int(results.get("xp_earned", 0)) + + var unlocked_badges: Array = results.get("badges_unlocked", []) + for badge in unlocked_badges: + if not profile["badges"].has(badge): + profile["badges"].append(badge) + + var answers: Array = results.get("answers", []) + for answer in answers: + var question: Dictionary = answer.get("question", {}) + var question_id := str(question.get("id", "")) + if question_id != "" and not profile["completed_question_ids"].has(question_id): + profile["completed_question_ids"].append(question_id) + _apply_question_tracking(question_id, bool(answer.get("is_correct", false)), bool(results.get("mastered", true))) + _apply_system_result(str(question.get("system", "General")), bool(answer.get("is_correct", false))) + + save_profile() + profile_changed.emit() + + +func record_wrong_attempt(question: Dictionary) -> void: + _ensure_daily_current() + var question_id := str(question.get("id", "")) + _apply_question_tracking(question_id, false, false) + save_profile() + profile_changed.emit() + + +func _ensure_daily_current() -> void: + _ensure_tracking_fields() + var today := _today_string() + var changed := false + if not profile.has("daily_goal_questions"): + profile["daily_goal_questions"] = 10 + changed = true + if str(profile.get("daily_date", "")) != today: + profile["daily_date"] = today + profile["daily_questions_answered"] = 0 + profile["daily_xp_earned"] = 0 + changed = true + if changed: + save_profile() + + +func _ensure_tracking_fields() -> void: + if not profile.has("correct_question_ids"): + profile["correct_question_ids"] = [] + if not profile.has("question_wrong_counts"): + profile["question_wrong_counts"] = {} + if not profile.has("missed_question_ids"): + profile["missed_question_ids"] = [] + + +func _apply_question_tracking(question_id: String, is_correct: bool, mastered: bool = true) -> void: + if question_id == "": + return + if is_correct and mastered: + if not profile["correct_question_ids"].has(question_id): + profile["correct_question_ids"].append(question_id) + elif not is_correct: + if not profile["missed_question_ids"].has(question_id): + profile["missed_question_ids"].append(question_id) + profile["question_wrong_counts"][question_id] = int(profile["question_wrong_counts"].get(question_id, 0)) + 1 + + +func _apply_system_result(system_name: String, is_correct: bool) -> void: + if not profile["system_stats"].has(system_name): + profile["system_stats"][system_name] = {"seen": 0, "correct": 0} + profile["system_stats"][system_name]["seen"] = int(profile["system_stats"][system_name]["seen"]) + 1 + if is_correct: + profile["system_stats"][system_name]["correct"] = int(profile["system_stats"][system_name]["correct"]) + 1 + + +func _today_string() -> String: + var date := Time.get_datetime_dict_from_system() + return "%04d-%02d-%02d" % [int(date["year"]), int(date["month"]), int(date["day"])] diff --git a/scripts/autoload/progression_service.gd b/scripts/autoload/progression_service.gd index 8a14bfb..9b818c8 100644 --- a/scripts/autoload/progression_service.gd +++ b/scripts/autoload/progression_service.gd @@ -4,6 +4,7 @@ const XP_PER_LEVEL := 500 var badge_catalog := { "First Block": "Complete your first question block.", + "First Run": "Answer your first core mode question.", "Hot Streak": "Answer 5 questions correctly in a row.", "Tenacious": "Complete 10 total questions.", "Sharp Shooter": "Score at least 80% on a block.", @@ -62,6 +63,38 @@ func calculate_block_rewards(answers: Array, mode: String, system_name: String) } +func calculate_question_rewards(answer: Dictionary, elapsed_seconds: float, target_seconds: float, score_delta: int) -> Dictionary: + var is_correct := bool(answer.get("is_correct", false)) + var profile: Dictionary = ProfileService.get_profile() + var ending_streak := int(profile.get("current_streak", 0)) + 1 if is_correct else 0 + var xp := 20 + if is_correct: + xp += 35 + if elapsed_seconds <= target_seconds: + xp += 10 + elif elapsed_seconds > target_seconds * 1.5: + xp = max(10, xp - 10) + + var badges: Array[String] = [] + _add_badge_if_needed(badges, profile, "First Run", true) + _add_badge_if_needed(badges, profile, "Hot Streak", ending_streak >= 5) + _add_badge_if_needed(badges, profile, "Tenacious", profile.get("completed_question_ids", []).size() + 1 >= 10) + + return { + "mode": "Core", + "system": str(answer.get("question", {}).get("system", "All Systems")), + "correct": 1 if is_correct else 0, + "total": 1, + "accuracy": 1.0 if is_correct else 0.0, + "xp_earned": xp, + "score_delta": score_delta, + "ending_streak": ending_streak, + "longest_streak": ending_streak, + "badges_unlocked": badges, + "answers": [answer] + } + + func _add_badge_if_needed(badges: Array[String], profile: Dictionary, badge_name: String, condition: bool) -> void: if condition and not profile.get("badges", []).has(badge_name): badges.append(badge_name) diff --git a/scripts/autoload/question_bank_service.gd b/scripts/autoload/question_bank_service.gd index 1b80891..ca54d22 100644 --- a/scripts/autoload/question_bank_service.gd +++ b/scripts/autoload/question_bank_service.gd @@ -1,15 +1,20 @@ extends Node const DEFAULT_QUESTIONS_API_URL := "https://s3q.ranaze.com/api/questions/search" +const RETRY_DELAYS := [2, 5, 10, 20, 30] signal questions_changed signal questions_load_failed(message: String) var questions: Array = [] var request: HTTPRequest +var retry_timer: Timer var questions_api_url := DEFAULT_QUESTIONS_API_URL var is_loading := false var last_error := "" +var load_state := "idle" +var retry_attempt := 0 +var next_retry_seconds := 0 func _ready() -> void: questions_api_url = str(ProjectSettings.get_setting("step3/questions_api_url", DEFAULT_QUESTIONS_API_URL)) @@ -17,13 +22,24 @@ func _ready() -> void: request.request_completed.connect(_on_api_questions_loaded) add_child(request) + retry_timer = Timer.new() + retry_timer.one_shot = true + retry_timer.timeout.connect(_on_retry_timer_timeout) + add_child(retry_timer) -func load_questions_from_api() -> void: + +func load_questions_from_api(reset_retry: bool = true) -> void: if AuthState.access_token.is_empty(): - _set_load_error("Login required before loading questions.") + _set_load_error("Login required before loading questions.", false) return + if reset_retry: + retry_attempt = 0 + next_retry_seconds = 0 + retry_timer.stop() + is_loading = true + load_state = "retrying" if retry_attempt > 0 else "loading" last_error = "" questions.clear() questions_changed.emit() @@ -63,6 +79,31 @@ func _on_api_questions_loaded(result: int, response_code: int, _headers: PackedS questions = api_questions last_error = "" + load_state = "ready" + retry_attempt = 0 + next_retry_seconds = 0 + retry_timer.stop() + questions_changed.emit() + + +func retry_now() -> void: + retry_attempt = 0 + next_retry_seconds = 0 + retry_timer.stop() + load_questions_from_api(false) + + +func clear_questions() -> void: + if request != null: + request.cancel_request() + if retry_timer != null: + retry_timer.stop() + questions.clear() + is_loading = false + last_error = "" + load_state = "idle" + retry_attempt = 0 + next_retry_seconds = 0 questions_changed.emit() @@ -101,14 +142,34 @@ func has_questions() -> bool: return not questions.is_empty() -func _set_load_error(message: String) -> void: +func _set_load_error(message: String, schedule_retry: bool = true) -> void: is_loading = false last_error = message push_warning(message) questions_load_failed.emit(message) + if schedule_retry and not AuthState.access_token.is_empty(): + _schedule_retry() + else: + load_state = "failed" + next_retry_seconds = 0 questions_changed.emit() +func _schedule_retry() -> void: + var delay_index := retry_attempt + if delay_index >= RETRY_DELAYS.size(): + delay_index = RETRY_DELAYS.size() - 1 + next_retry_seconds = int(RETRY_DELAYS[delay_index]) + retry_attempt += 1 + load_state = "retrying" + retry_timer.start(float(next_retry_seconds)) + + +func _on_retry_timer_timeout() -> void: + next_retry_seconds = 0 + load_questions_from_api(false) + + func _is_valid_question(item: Variant) -> bool: if typeof(item) != TYPE_DICTIONARY: return false @@ -132,6 +193,7 @@ func _normalize_api_question(item: Variant) -> Dictionary: "correct_choice_id": str(item.get("correctChoiceId", item.get("correct_choice_id", ""))), "explanation": str(item.get("explanation", "")), "choice_explanations": item.get("choiceExplanations", item.get("choice_explanations", {})), + "choice_selection_percentages": item.get("choiceSelectionPercentages", item.get("choice_selection_percentages", {})), "discipline": str(item.get("discipline", "")), "system": str(item.get("system", "")), "difficulty": int(item.get("difficulty", 1)), diff --git a/scripts/ui/block_setup.gd b/scripts/ui/block_setup.gd index 3cfd3c8..52c366c 100644 --- a/scripts/ui/block_setup.gd +++ b/scripts/ui/block_setup.gd @@ -13,6 +13,8 @@ var start_button: Button func _ready() -> void: set_anchors_preset(Control.PRESET_FULL_RECT) add_theme_constant_override("separation", 16) + if not QuestionBankService.questions_changed.is_connected(_refresh_setup): + QuestionBankService.questions_changed.connect(_refresh_setup) var title := Label.new() title.text = "Block Setup" @@ -66,12 +68,23 @@ func _ready() -> void: func on_show(payload: Variant = null) -> void: selected_system = str(payload) if payload != null else "All Systems" + _refresh_setup() + + +func _refresh_setup() -> void: + if system_label == null: + return system_label.text = "Zone: %s" % selected_system var available := QuestionBankService.get_questions_for_system(selected_system).size() size_spin.max_value = max(1, available) size_spin.value = min(size_spin.value, available) - start_button.disabled = available == 0 - status_label.text = QuestionBankService.last_error if available == 0 else "" + start_button.disabled = available == 0 or QuestionBankService.is_loading + if QuestionBankService.is_loading: + status_label.text = "Question API unavailable. Retrying in %d seconds..." % QuestionBankService.next_retry_seconds if QuestionBankService.load_state == "retrying" else "Loading question bank..." + elif available == 0: + status_label.text = QuestionBankService.last_error if QuestionBankService.last_error != "" else "No questions are available for this zone yet." + else: + status_label.text = "%d questions available." % available func _start_block() -> void: diff --git a/scripts/ui/dashboard.gd b/scripts/ui/dashboard.gd new file mode 100644 index 0000000..373a77f --- /dev/null +++ b/scripts/ui/dashboard.gd @@ -0,0 +1,155 @@ +extends VBoxContainer + +const Style := preload("res://scripts/ui/ui_style.gd") + +var main: Control +var status_label: Label +var daily_label: Label +var xp_label: Label +var streak_label: Label +var bank_label: Label +var error_label: Label +var begin_button: Button +var retry_button: Button + +func _ready() -> void: + set_anchors_preset(Control.PRESET_FULL_RECT) + add_theme_constant_override("separation", 18) + if not QuestionBankService.questions_changed.is_connected(_refresh): + QuestionBankService.questions_changed.connect(_refresh) + if not ProfileService.profile_changed.is_connected(_refresh): + ProfileService.profile_changed.connect(_refresh) + + var title := Label.new() + title.text = "Core Mode" + Style.set_label(title, 36, Style.TEXT) + add_child(title) + + var subtitle := Label.new() + subtitle.text = "Begin a continuous STEP 3 question run. Keep your energy up, build score, and stay on pace." + subtitle.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + Style.set_label(subtitle, 17, Style.MUTED) + add_child(subtitle) + + var summary := HBoxContainer.new() + summary.add_theme_constant_override("separation", 14) + add_child(summary) + + daily_label = _add_metric(summary, "Today") + xp_label = _add_metric(summary, "Daily XP") + streak_label = _add_metric(summary, "Streak") + bank_label = _add_metric(summary, "Question Bank") + + var action_panel := PanelContainer.new() + action_panel.add_theme_stylebox_override("panel", Style.panel_box(Color("172337"), Color("405475"))) + action_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL + add_child(action_panel) + + var actions := VBoxContainer.new() + actions.add_theme_constant_override("separation", 12) + action_panel.add_child(actions) + + status_label = Label.new() + status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + Style.set_label(status_label, 22, Style.CYAN) + actions.add_child(status_label) + + error_label = Label.new() + error_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + Style.set_label(error_label, 15, Style.RED) + actions.add_child(error_label) + + var button_row := HBoxContainer.new() + button_row.add_theme_constant_override("separation", 12) + actions.add_child(button_row) + + begin_button = Button.new() + begin_button.text = "Begin" + begin_button.custom_minimum_size = Vector2(260, 60) + Style.style_primary_button(begin_button) + begin_button.pressed.connect(_begin_core_mode) + button_row.add_child(begin_button) + + retry_button = Button.new() + retry_button.text = "Retry Questions" + retry_button.custom_minimum_size = Vector2(170, 54) + Style.style_chip(retry_button, Style.AMBER) + retry_button.pressed.connect(QuestionBankService.retry_now) + button_row.add_child(retry_button) + + var spacer := Control.new() + spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL + add_child(spacer) + + +func on_show(_payload: Variant = null) -> void: + if QuestionBankService.load_state == "idle" and AuthState.is_logged_in: + QuestionBankService.load_questions_from_api() + _refresh() + + +func _add_metric(parent: HBoxContainer, metric_name: String) -> Label: + var panel := PanelContainer.new() + panel.add_theme_stylebox_override("panel", Style.panel_box(Style.SURFACE, Color("33415c"))) + panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL + parent.add_child(panel) + + var label := Label.new() + label.text = metric_name + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + Style.set_label(label, 18, Style.TEXT) + panel.add_child(label) + return label + + +func _refresh() -> void: + if daily_label == null: + return + + var profile := ProfileService.get_profile() + var answered := int(profile.get("daily_questions_answered", 0)) + var goal := int(profile.get("daily_goal_questions", 10)) + if goal < 1: + goal = 1 + var progress := int(round(float(answered) / float(goal) * 100.0)) + if progress > 100: + progress = 100 + daily_label.text = "Today\n%d/%d questions\n%d%% complete" % [answered, goal, progress] + xp_label.text = "Daily XP\n%d XP earned" % int(profile.get("daily_xp_earned", 0)) + streak_label.text = "Streak\n%d current\n%d best" % [ + int(profile.get("current_streak", 0)), + int(profile.get("best_streak", 0)) + ] + bank_label.text = "Question Bank\n%d available\n%s" % [ + QuestionBankService.get_all_questions().size(), + QuestionBankService.load_state.capitalize() + ] + + var state := QuestionBankService.load_state + var has_questions := QuestionBankService.has_questions() + begin_button.disabled = not has_questions + retry_button.disabled = state == "loading" + retry_button.visible = state != "ready" + error_label.text = "" + + if state == "ready": + status_label.text = BlockSessionService.core_status_message if BlockSessionService.core_status_message != "" else "Question bank ready. Begin core mode when you are ready." + elif state == "loading": + status_label.text = "Loading question bank..." + elif state == "retrying": + status_label.text = "Question API unavailable. Retrying in %d seconds." % QuestionBankService.next_retry_seconds + error_label.text = QuestionBankService.last_error + elif state == "failed": + status_label.text = "Question bank is not available." + error_label.text = QuestionBankService.last_error + else: + status_label.text = "Question bank has not loaded yet." + + +func _begin_core_mode() -> void: + if not QuestionBankService.has_questions(): + return + if BlockSessionService.start_core_run(): + main.show_screen("player") + else: + _refresh() diff --git a/scripts/ui/dashboard.gd.uid b/scripts/ui/dashboard.gd.uid new file mode 100644 index 0000000..6e7a523 --- /dev/null +++ b/scripts/ui/dashboard.gd.uid @@ -0,0 +1 @@ +uid://7r2eyomsnpoe diff --git a/scripts/ui/login_screen.gd b/scripts/ui/login_screen.gd index cd0b83d..a75061b 100644 --- a/scripts/ui/login_screen.gd +++ b/scripts/ui/login_screen.gd @@ -152,7 +152,7 @@ func _on_login_request_completed(result: int, response_code: int, _headers: Pack ) QuestionBankService.load_questions_from_api() password_input.text = "" - main.show_screen("menu") + main.show_screen("dashboard") func _on_input_submitted(_new_text: String) -> void: diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd index cb6c2c7..31dc71f 100644 --- a/scripts/ui/main.gd +++ b/scripts/ui/main.gd @@ -6,14 +6,18 @@ var screens := {} var header_stats: Label var header_panel: PanelContainer var user_label: Label +var app_menu: PanelContainer +var menu_exit_button: Button +var current_screen_name := "" func _ready() -> void: Style.apply_app_theme(self) _build_ui() - show_screen("menu" if AuthState.is_logged_in else "login") + show_screen("dashboard" if AuthState.is_logged_in else "login") func show_screen(screen_name: String, payload: Variant = null) -> void: + _close_app_menu() for child in $Root/Screens.get_children(): child.visible = false @@ -21,6 +25,7 @@ func show_screen(screen_name: String, payload: Variant = null) -> void: if screen == null: return + current_screen_name = screen_name screen.visible = true if header_panel != null: header_panel.visible = screen_name != "login" @@ -68,21 +73,6 @@ func _build_ui() -> void: user_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT header.add_child(user_label) - var profile_button := Button.new() - profile_button.text = "Profile" - profile_button.pressed.connect(func(): show_screen("profile")) - header.add_child(profile_button) - - var map_button := Button.new() - map_button.text = "Study Map" - map_button.pressed.connect(func(): show_screen("map")) - header.add_child(map_button) - - var logout_button := Button.new() - logout_button.text = "Log Out" - logout_button.pressed.connect(_logout) - header.add_child(logout_button) - var screens_container := MarginContainer.new() screens_container.name = "Screens" screens_container.size_flags_vertical = Control.SIZE_EXPAND_FILL @@ -104,6 +94,12 @@ func _build_ui() -> void: screens_container.add_child(login) screens["login"] = login + var dashboard := preload("res://scripts/ui/dashboard.gd").new() + dashboard.name = "Dashboard" + dashboard.main = self + screens_container.add_child(dashboard) + screens["dashboard"] = dashboard + var map := preload("res://scripts/ui/study_map.gd").new() map.name = "StudyMap" map.main = self @@ -137,6 +133,15 @@ func _build_ui() -> void: ProfileService.profile_changed.connect(_refresh_header_stats) AuthState.auth_changed.connect(_refresh_header_stats) _refresh_header_stats() + _build_app_menu() + + +func _input(event: InputEvent) -> void: + if not AuthState.is_logged_in or current_screen_name == "login": + return + if event.is_action_pressed("ui_cancel"): + _toggle_app_menu() + get_viewport().set_input_as_handled() func _refresh_header_stats() -> void: @@ -153,5 +158,77 @@ func _refresh_header_stats() -> void: func _logout() -> void: + _close_app_menu() + if BlockSessionService.block_started: + BlockSessionService.finish_block() + QuestionBankService.clear_questions() AuthState.clear_session() show_screen("login") + + +func _build_app_menu() -> void: + app_menu = PanelContainer.new() + app_menu.visible = false + app_menu.top_level = true + app_menu.mouse_filter = Control.MOUSE_FILTER_STOP + app_menu.custom_minimum_size = Vector2(340, 230) + app_menu.add_theme_stylebox_override("panel", Style.panel_box(Color("172337"), Style.CYAN)) + add_child(app_menu) + + var content := VBoxContainer.new() + content.add_theme_constant_override("separation", 12) + app_menu.add_child(content) + + var title := Label.new() + title.text = "Paused" + title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + Style.set_label(title, 28, Style.TEXT) + content.add_child(title) + + var resume := Button.new() + resume.text = "Resume" + resume.custom_minimum_size = Vector2(0, 48) + Style.style_primary_button(resume) + resume.pressed.connect(_close_app_menu) + content.add_child(resume) + + menu_exit_button = Button.new() + menu_exit_button.text = "Exit Run" + menu_exit_button.custom_minimum_size = Vector2(0, 48) + menu_exit_button.pressed.connect(_exit_run) + content.add_child(menu_exit_button) + + var logout := Button.new() + logout.text = "Log Out" + logout.custom_minimum_size = Vector2(0, 48) + Style.style_chip(logout, Style.RED) + logout.pressed.connect(_logout) + content.add_child(logout) + + +func _toggle_app_menu() -> void: + if app_menu == null: + return + if app_menu.visible: + _close_app_menu() + else: + app_menu.visible = true + app_menu.size = app_menu.custom_minimum_size + app_menu.position = (get_viewport_rect().size - app_menu.size) * 0.5 + menu_exit_button.visible = current_screen_name == "player" and BlockSessionService.block_started + if menu_exit_button.visible: + Style.style_chip(menu_exit_button, Style.AMBER) + BlockSessionService.pause_current_question_timer() + + +func _close_app_menu() -> void: + if app_menu != null: + app_menu.visible = false + BlockSessionService.resume_current_question_timer() + + +func _exit_run() -> void: + _close_app_menu() + if BlockSessionService.block_started: + BlockSessionService.finish_block() + show_screen("dashboard") diff --git a/scripts/ui/progress_profile.gd b/scripts/ui/progress_profile.gd index 0dc1248..08165f2 100644 --- a/scripts/ui/progress_profile.gd +++ b/scripts/ui/progress_profile.gd @@ -26,8 +26,8 @@ func _ready() -> void: var row := HBoxContainer.new() var back := Button.new() - back.text = "Back to Menu" - back.pressed.connect(func(): main.show_screen("menu")) + back.text = "Dashboard" + back.pressed.connect(func(): main.show_screen("dashboard")) row.add_child(back) var reset := Button.new() @@ -44,13 +44,16 @@ func _ready() -> void: func on_show(_payload: Variant = null) -> void: var profile := ProfileService.get_profile() var xp_into := ProgressionService.xp_into_level(int(profile["xp"])) - var text := "[b]Level %d[/b]\n%d total XP (%d/%d to next level)\nCurrent streak: %d\nBest streak: %d\n\n[b]Badges[/b]\n" % [ + var text := "[b]Level %d[/b]\n%d total XP (%d/%d to next level)\nCurrent streak: %d\nBest streak: %d\nToday: %d/%d questions, +%d XP\n\n[b]Badges[/b]\n" % [ profile["level"], profile["xp"], xp_into, ProgressionService.XP_PER_LEVEL, profile["current_streak"], - profile["best_streak"] + profile["best_streak"], + profile.get("daily_questions_answered", 0), + profile.get("daily_goal_questions", 10), + profile.get("daily_xp_earned", 0) ] if profile["badges"].is_empty(): text += "No badges yet.\n" diff --git a/scripts/ui/question_player.gd b/scripts/ui/question_player.gd index 1f9964b..545d031 100644 --- a/scripts/ui/question_player.gd +++ b/scripts/ui/question_player.gd @@ -1,17 +1,22 @@ extends VBoxContainer const Style := preload("res://scripts/ui/ui_style.gd") +const FEEDBACK_FADE_SECONDS := 0.45 +const CORRECT_ADVANCE_DELAY_SECONDS := 0.6 var main: Control var choice_buttons: Array[Button] = [] var eliminated: Array = [] +var locked_wrong_choice_ids: Array = [] var flagged := false var selected_choice := "" var current_answer := {} var question_highlights := {} +var reported_question_ids := [] var stem_plain_text := "" -var highlight_mode_enabled := false var highlight_drag_started_in_stem := false +var timer_color_state := "" +var pending_correct_advance := false var progress_label: Label var timer_label: Label @@ -19,10 +24,17 @@ var stem_label: RichTextLabel var lead_label: Label var explanation_label: RichTextLabel var next_button: Button -var prev_button: Button var flag_button: Button +var report_button: Button var nav_palette: HBoxContainer var question_content: VBoxContainer +var timer_bar: ProgressBar +var feedback_fog: ColorRect +var red_fog_time := 0.0 +var green_fog_time := 0.0 +var correct_advance_delay := 0.0 +var shake_time := 0.0 +var shake_origin := Vector2.ZERO func _ready() -> void: set_anchors_preset(Control.PRESET_FULL_RECT) @@ -36,31 +48,48 @@ func _ready() -> void: top.add_child(progress_label) timer_label = Label.new() Style.set_label(timer_label, 18, Style.AMBER) + timer_label.visible = false + timer_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + timer_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT top.add_child(timer_label) - flag_button = Button.new() - flag_button.text = "Flag" - flag_button.toggle_mode = true - Style.style_chip(flag_button, Style.AMBER) - flag_button.toggled.connect(func(value: bool): flagged = value) - top.add_child(flag_button) - add_child(top) - var highlight_row := HBoxContainer.new() - highlight_row.add_theme_constant_override("separation", 8) - var highlight_button := Button.new() - highlight_button.text = "Highlight Mode" - highlight_button.toggle_mode = true - highlight_button.toggled.connect(func(enabled: bool): - highlight_mode_enabled = enabled - highlight_drag_started_in_stem = false - ) - Style.style_chip(highlight_button, Style.AMBER) - highlight_row.add_child(highlight_button) + var toolbar_spacer := Control.new() + toolbar_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + top.add_child(toolbar_spacer) + + var toolbar := HBoxContainer.new() + toolbar.add_theme_constant_override("separation", 8) + toolbar.alignment = BoxContainer.ALIGNMENT_END + var clear_highlight_button := Button.new() - clear_highlight_button.text = "Clear Highlights" + clear_highlight_button.tooltip_text = "Clear highlights" + clear_highlight_button.icon = _make_clear_highlights_icon() + clear_highlight_button.custom_minimum_size = Vector2(44, 44) + Style.style_chip(clear_highlight_button, Style.CYAN) clear_highlight_button.pressed.connect(_clear_current_highlights) - highlight_row.add_child(clear_highlight_button) - add_child(highlight_row) + toolbar.add_child(clear_highlight_button) + + flag_button = Button.new() + flag_button.toggle_mode = true + flag_button.tooltip_text = "Flag question" + flag_button.icon = _make_flag_icon() + flag_button.custom_minimum_size = Vector2(44, 44) + Style.style_chip(flag_button, Style.AMBER) + flag_button.toggled.connect(func(value: bool): + flagged = value + _refresh_toolbar_state() + ) + toolbar.add_child(flag_button) + + report_button = Button.new() + report_button.tooltip_text = "Report question" + report_button.icon = _make_report_icon() + report_button.custom_minimum_size = Vector2(44, 44) + report_button.pressed.connect(_toggle_report_current_question) + toolbar.add_child(report_button) + + top.add_child(toolbar) + add_child(top) nav_palette = HBoxContainer.new() nav_palette.add_theme_constant_override("separation", 6) @@ -96,7 +125,7 @@ func _ready() -> void: for index in range(5): var button := Button.new() - button.custom_minimum_size = Vector2(0, 52) + button.custom_minimum_size = Vector2(0, 64) button.alignment = HORIZONTAL_ALIGNMENT_LEFT Style.style_answer_button(button) button.pressed.connect(func(i := index): _select_choice(i)) @@ -114,33 +143,42 @@ func _ready() -> void: var actions := HBoxContainer.new() actions.add_theme_constant_override("separation", 10) - prev_button = Button.new() - prev_button.text = "Previous" - prev_button.pressed.connect(func(): - if BlockSessionService.move_previous(): - _load_question() - ) - actions.add_child(prev_button) - - var eliminate := Button.new() - eliminate.text = "Eliminate Selected" - eliminate.pressed.connect(_toggle_eliminate_selected) - actions.add_child(eliminate) - next_button = Button.new() next_button.text = "Submit" next_button.custom_minimum_size = Vector2(140, 48) + next_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL Style.style_primary_button(next_button) next_button.pressed.connect(_submit_or_next) actions.add_child(next_button) add_child(actions) + timer_bar = ProgressBar.new() + timer_bar.show_percentage = false + timer_bar.min_value = 0.0 + timer_bar.max_value = 100.0 + timer_bar.value = 100.0 + timer_bar.custom_minimum_size = Vector2(0, 14) + timer_bar.size_flags_horizontal = Control.SIZE_EXPAND_FILL + timer_bar.add_theme_stylebox_override("background", Style.button_box(Color("232b3d"), Color("303b53"))) + timer_bar.add_theme_stylebox_override("fill", Style.button_box(Style.LIME, Style.LIME)) + add_child(timer_bar) + + feedback_fog = ColorRect.new() + feedback_fog.color = Color(1.0, 0.0, 0.0, 0.0) + feedback_fog.mouse_filter = Control.MOUSE_FILTER_IGNORE + feedback_fog.top_level = true + feedback_fog.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + add_child(feedback_fog) + func on_show(_payload: Variant = null) -> void: _load_question() func _process(delta: float) -> void: + _update_answer_feedback(delta) + if visible and BlockSessionService.block_started and BlockSessionService.session_kind == "core": + _update_timer_bar() if visible and BlockSessionService.block_started and BlockSessionService.mode == "Timed": BlockSessionService.seconds_remaining = max(0.0, BlockSessionService.seconds_remaining - delta) var shown_seconds := int(ceil(BlockSessionService.seconds_remaining)) @@ -151,7 +189,7 @@ func _process(delta: float) -> void: func _input(event: InputEvent) -> void: - if not visible or not highlight_mode_enabled or stem_label == null: + if not visible or pending_correct_advance or stem_label == null: return var mouse_event := event as InputEventMouseButton @@ -166,17 +204,27 @@ func _input(event: InputEvent) -> void: func _load_question() -> void: + _reset_wrong_feedback() eliminated.clear() + locked_wrong_choice_ids.clear() flagged = false selected_choice = "" current_answer = {} flag_button.button_pressed = false + _refresh_toolbar_state() explanation_label.visible = false next_button.text = "Submit" var question := BlockSessionService.get_current_question() - progress_label.text = "Question %s" % BlockSessionService.get_progress_text() - timer_label.text = "Tutor Mode" if BlockSessionService.mode == "Tutor" else "Timed" + if question.is_empty(): + main.show_screen("dashboard") + return + progress_label.visible = BlockSessionService.session_kind != "core" + progress_label.text = "" if BlockSessionService.session_kind == "core" else "Question %s" % BlockSessionService.get_progress_text() + timer_label.visible = BlockSessionService.session_kind != "core" + timer_label.text = "" if BlockSessionService.session_kind == "core" else "Tutor Mode" if BlockSessionService.mode == "Tutor" else "Timed" + timer_bar.visible = BlockSessionService.session_kind == "core" + _update_timer_bar() stem_plain_text = str(question.get("stem", "")) _render_stem() lead_label.text = question.get("lead_in", "") @@ -198,6 +246,7 @@ func _load_question() -> void: eliminated = saved_answer.get("eliminated_choice_ids", []).duplicate() flagged = bool(saved_answer.get("flagged", false)) flag_button.button_pressed = flagged + _refresh_toolbar_state() if BlockSessionService.mode == "Tutor": _show_explanation() next_button.text = "Next" @@ -205,15 +254,19 @@ func _load_question() -> void: _apply_saved_choice_state() next_button.text = "Next" - prev_button.disabled = BlockSessionService.current_index == 0 _refresh_nav_palette() + _refresh_toolbar_state() func _select_choice(index: int) -> void: + if pending_correct_advance: + return var question := BlockSessionService.get_current_question() var choices: Array = question.get("choices", []) if index >= choices.size() or (BlockSessionService.mode == "Tutor" and not current_answer.is_empty()): return + if locked_wrong_choice_ids.has(str(choices[index].get("id", ""))): + return selected_choice = str(choices[index].get("id", "")) if BlockSessionService.mode == "Timed": current_answer = {} @@ -222,6 +275,8 @@ func _select_choice(index: int) -> void: func _choice_gui_input(event: InputEvent, index: int) -> void: + if pending_correct_advance: + return var mouse_event := event as InputEventMouseButton if mouse_event == null or mouse_event.button_index != MOUSE_BUTTON_RIGHT or not mouse_event.pressed: return @@ -235,6 +290,8 @@ func _choice_gui_input(event: InputEvent, index: int) -> void: var choice: Dictionary = choices[index] var choice_id := str(choice.get("id", "")) + if locked_wrong_choice_ids.has(choice_id): + return if eliminated.has(choice_id): eliminated.erase(choice_id) else: @@ -244,7 +301,7 @@ func _choice_gui_input(event: InputEvent, index: int) -> void: func _toggle_eliminate_selected() -> void: - if selected_choice == "" or not current_answer.is_empty(): + if pending_correct_advance or selected_choice == "" or not current_answer.is_empty(): return var question := BlockSessionService.get_current_question() var choices: Array = question.get("choices", []) @@ -259,9 +316,27 @@ func _toggle_eliminate_selected() -> void: func _submit_or_next() -> void: + if pending_correct_advance: + return if current_answer.is_empty(): if selected_choice == "": return + if BlockSessionService.session_kind == "core": + var result := BlockSessionService.submit_core_attempt(selected_choice, eliminated, flagged) + if result.is_empty(): + return + var answer: Dictionary = result.get("answer", {}) + if bool(result.get("is_correct", false)): + current_answer = answer + _trigger_correct_feedback() + return + _lock_wrong_choice(selected_choice) + selected_choice = "" + current_answer = {} + next_button.text = "Submit" + _trigger_wrong_feedback() + _apply_saved_choice_state() + return current_answer = BlockSessionService.answer_current(selected_choice, eliminated, flagged) if BlockSessionService.mode == "Tutor": _show_explanation() @@ -300,12 +375,106 @@ func _advance_or_finish() -> void: func _finish() -> void: + if BlockSessionService.session_kind == "core": + BlockSessionService.finish_block() + main.show_screen("dashboard") + return var results := BlockSessionService.finish_block() main.show_screen("review", results) +func _exit_run() -> void: + BlockSessionService.resume_current_question_timer() + BlockSessionService.finish_block() + main.show_screen("dashboard") + + +func _update_timer_bar() -> void: + if timer_bar == null: + return + var ratio := BlockSessionService.get_core_timer_ratio() + timer_bar.value = ratio * 100.0 + var color_state := "green" + var fill_color := Style.LIME + if ratio < 0.25: + color_state = "red" + fill_color = Style.RED + elif ratio <= 0.5: + color_state = "amber" + fill_color = Style.AMBER + if color_state != timer_color_state: + timer_color_state = color_state + timer_bar.add_theme_stylebox_override("fill", Style.button_box(fill_color, fill_color)) + + +func _lock_wrong_choice(choice_id: String) -> void: + if choice_id == "": + return + if not locked_wrong_choice_ids.has(choice_id): + locked_wrong_choice_ids.append(choice_id) + + +func _trigger_wrong_feedback() -> void: + shake_time = 0.28 + shake_origin = position + green_fog_time = 0.0 + red_fog_time = FEEDBACK_FADE_SECONDS + if feedback_fog != null: + feedback_fog.color = Color(1.0, 0.0, 0.0, 0.26) + + +func _trigger_correct_feedback() -> void: + pending_correct_advance = true + correct_advance_delay = CORRECT_ADVANCE_DELAY_SECONDS + red_fog_time = 0.0 + green_fog_time = FEEDBACK_FADE_SECONDS + next_button.disabled = true + for button in choice_buttons: + button.disabled = true + if feedback_fog != null: + feedback_fog.color = Color(0.25, 1.0, 0.25, 0.23) + + +func _update_answer_feedback(delta: float) -> void: + if shake_time > 0.0: + shake_time = max(0.0, shake_time - delta) + if shake_time > 0.0: + position = shake_origin + Vector2(randf_range(-9.0, 9.0), randf_range(-4.0, 4.0)) + else: + position = shake_origin + + if red_fog_time > 0.0 and feedback_fog != null: + red_fog_time = max(0.0, red_fog_time - delta) + var red_alpha := 0.26 * (red_fog_time / FEEDBACK_FADE_SECONDS) + feedback_fog.color = Color(1.0, 0.0, 0.0, red_alpha) + elif green_fog_time > 0.0 and feedback_fog != null: + green_fog_time = max(0.0, green_fog_time - delta) + var green_alpha := 0.23 * (green_fog_time / FEEDBACK_FADE_SECONDS) + feedback_fog.color = Color(0.25, 1.0, 0.25, green_alpha) + + if pending_correct_advance: + correct_advance_delay = max(0.0, correct_advance_delay - delta) + if correct_advance_delay <= 0.0: + pending_correct_advance = false + next_button.disabled = false + _advance_or_finish() + + +func _reset_wrong_feedback() -> void: + if shake_time > 0.0: + position = shake_origin + shake_time = 0.0 + red_fog_time = 0.0 + green_fog_time = 0.0 + correct_advance_delay = 0.0 + pending_correct_advance = false + next_button.disabled = false + if feedback_fog != null: + feedback_fog.color = Color(1.0, 0.0, 0.0, 0.0) + + func _commit_current_text_selection() -> void: - if not highlight_mode_enabled or stem_label == null: + if stem_label == null: return var raw_selected_text := stem_label.get_selected_text() var selected_text := raw_selected_text.strip_edges() @@ -347,6 +516,25 @@ func _clear_current_highlights() -> void: _render_stem() +func _toggle_report_current_question() -> void: + var question_id := str(BlockSessionService.get_current_question().get("id", "")) + if question_id == "": + return + if reported_question_ids.has(question_id): + reported_question_ids.erase(question_id) + else: + reported_question_ids.append(question_id) + _refresh_toolbar_state() + + +func _refresh_toolbar_state() -> void: + if flag_button != null: + Style.style_chip(flag_button, Style.AMBER if flagged else Style.CYAN) + if report_button != null: + var question_id := str(BlockSessionService.get_current_question().get("id", "")) + Style.style_chip(report_button, Style.RED if reported_question_ids.has(question_id) else Style.CYAN) + + func _render_stem() -> void: var question_id := str(BlockSessionService.get_current_question().get("id", "")) var ranges: Array = question_highlights.get(question_id, []).duplicate() @@ -393,7 +581,10 @@ func _apply_saved_choice_state() -> void: for index in range(choices.size()): var id := str(choices[index].get("id", "")) _set_choice_button_text(index, choices[index]) - if eliminated.has(id): + choice_buttons[index].disabled = locked_wrong_choice_ids.has(id) + if locked_wrong_choice_ids.has(id): + choice_buttons[index].modulate = Color(1.0, 0.25, 0.25) + elif eliminated.has(id): choice_buttons[index].modulate = Color(0.45, 0.45, 0.45) elif id == selected_choice: choice_buttons[index].modulate = Color(0.68, 0.92, 1.0) @@ -404,12 +595,26 @@ func _apply_saved_choice_state() -> void: func _set_choice_button_text(index: int, choice: Dictionary) -> void: var choice_id := str(choice.get("id", "")) var choice_text := str(choice.get("text", "")) - if eliminated.has(choice_id): + if locked_wrong_choice_ids.has(choice_id): + var text := "%s. [wrong] %s" % [choice_id, _strike_text(choice_text)] + var percentage := _get_choice_selection_percentage(choice_id) + if percentage >= 0: + text += "\n %d%% chose this on first attempt" % percentage + choice_buttons[index].text = text + elif eliminated.has(choice_id): choice_buttons[index].text = "%s. [eliminated] %s" % [choice_id, _strike_text(choice_text)] else: choice_buttons[index].text = "%s. %s" % [choice_id, choice_text] +func _get_choice_selection_percentage(choice_id: String) -> int: + var question := BlockSessionService.get_current_question() + var percentages: Dictionary = question.get("choice_selection_percentages", {}) + if not percentages.has(choice_id): + return -1 + return clamp(int(percentages.get(choice_id, -1)), 0, 100) + + func _strike_text(value: String) -> String: var result := "" for character in value: @@ -417,10 +622,90 @@ func _strike_text(value: String) -> String: return result +func _make_flag_icon() -> Texture2D: + var image := Image.create(24, 24, false, Image.FORMAT_RGBA8) + image.fill(Color(0, 0, 0, 0)) + for y in range(4, 20): + image.set_pixel(6, y, Style.TEXT) + image.set_pixel(7, y, Style.TEXT) + for x in range(8, 18): + for y in range(4, 11): + if x < 16 or y < 9: + image.set_pixel(x, y, Style.AMBER) + for y in range(11, 14): + for x in range(8, 14 - (y - 11)): + image.set_pixel(x, y, Style.AMBER) + return ImageTexture.create_from_image(image) + + +func _make_clear_highlights_icon() -> Texture2D: + var image := Image.create(24, 24, false, Image.FORMAT_RGBA8) + image.fill(Color(0, 0, 0, 0)) + var handle_color := Style.TEXT + var bristle_color := Style.AMBER + for offset in range(3): + _draw_icon_line(image, Vector2i(6 + offset, 16), Vector2i(16 + offset, 6), handle_color) + for x in range(13, 20): + for y in range(15, 19): + if x + y < 36: + image.set_pixel(x, y, bristle_color) + for x in range(12, 20): + image.set_pixel(x, 19, bristle_color) + return ImageTexture.create_from_image(image) + + +func _make_report_icon() -> Texture2D: + var image := Image.create(24, 24, false, Image.FORMAT_RGBA8) + image.fill(Color(0, 0, 0, 0)) + var body_color := Style.TEXT + var accent_color := Style.RED + for x in range(5, 14): + for y in range(9, 15): + image.set_pixel(x, y, body_color) + for x in range(14, 20): + image.set_pixel(x, 7, accent_color) + image.set_pixel(x, 16, accent_color) + for y in range(8, 16): + image.set_pixel(20, y, accent_color) + for y in range(10, 14): + image.set_pixel(4, y, body_color) + for y in range(15, 21): + image.set_pixel(8, y, body_color) + if y < 20: + image.set_pixel(9, y, body_color) + _draw_icon_line(image, Vector2i(2, 5), Vector2i(5, 8), accent_color) + _draw_icon_line(image, Vector2i(2, 19), Vector2i(5, 16), accent_color) + return ImageTexture.create_from_image(image) + + +func _draw_icon_line(image: Image, from: Vector2i, to: Vector2i, color: Color) -> void: + var dx := int(abs(to.x - from.x)) + var sx := 1 if from.x < to.x else -1 + var dy := -int(abs(to.y - from.y)) + var sy := 1 if from.y < to.y else -1 + var err: int = dx + dy + var point := from + while true: + if point.x >= 0 and point.x < image.get_width() and point.y >= 0 and point.y < image.get_height(): + image.set_pixel(point.x, point.y, color) + if point == to: + break + var e2: int = 2 * err + if e2 >= dy: + err += dy + point.x += sx + if e2 <= dx: + err += dx + point.y += sy + + func _refresh_nav_palette() -> void: for child in nav_palette.get_children(): child.queue_free() + if BlockSessionService.session_kind == "core": + return + for index in range(BlockSessionService.active_questions.size()): var button := Button.new() button.text = str(index + 1) diff --git a/scripts/ui/study_map.gd b/scripts/ui/study_map.gd index 65389b2..cf04b06 100644 --- a/scripts/ui/study_map.gd +++ b/scripts/ui/study_map.gd @@ -41,7 +41,7 @@ func _refresh_zones() -> void: if QuestionBankService.is_loading: var loading := Label.new() - loading.text = "Loading question bank..." + loading.text = "Question API unavailable. Retrying in %d seconds..." % QuestionBankService.next_retry_seconds if QuestionBankService.load_state == "retrying" else "Loading question bank..." Style.set_label(loading, 20, Style.AMBER) zone_list.add_child(loading) return @@ -52,6 +52,13 @@ func _refresh_zones() -> void: empty.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART Style.set_label(empty, 20, Style.RED) zone_list.add_child(empty) + + var retry := Button.new() + retry.text = "Retry Questions" + retry.custom_minimum_size = Vector2(220, 54) + Style.style_chip(retry, Style.AMBER) + retry.pressed.connect(QuestionBankService.retry_now) + zone_list.add_child(retry) return var systems: Array[String] = ["All Systems"]