extends Node signal session_changed var active_questions: Array = [] var answers: Array = [] var current_index := 0 var mode := "Tutor" var system_name := "All Systems" var seconds_remaining := 0.0 var session_initial_seconds := 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_question_total_elapsed_seconds := 0.0 var core_question_total_active_since := 0.0 var core_question_total_frozen := false var core_status_message := "" var simulation_block_index := 0 var simulation_total_blocks := 12 var simulation_deadline_unix := 0.0 var review_first_submission_recorded := false var core_block_size := DEFAULT_CORE_BLOCK_SIZE var show_review_followups := true const CORE_MIN_TARGET_SECONDS := 45.0 const CORE_MAX_TARGET_SECONDS := 150.0 const CORE_RETRY_TARGET_SECONDS := 30.0 const DEFAULT_CORE_BLOCK_SIZE := 10 const CORE_BLOCK_SIZE_MIN := 5 const CORE_BLOCK_SIZE_MAX := 40 const SETTINGS_PATH := "user://settings.cfg" const SETTINGS_SECTION := "session" const SETTINGS_KEY_CORE_BLOCK_SIZE := "core_block_size" const SETTINGS_KEY_SHOW_REVIEW_FOLLOWUPS := "show_review_followups" func _ready() -> void: _load_core_block_size() _load_show_review_followups() func set_core_block_size(value: int) -> void: var clamped := clampi(value, CORE_BLOCK_SIZE_MIN, CORE_BLOCK_SIZE_MAX) if clamped == core_block_size: return core_block_size = clamped var config := ConfigFile.new() config.load(SETTINGS_PATH) config.set_value(SETTINGS_SECTION, SETTINGS_KEY_CORE_BLOCK_SIZE, core_block_size) if config.save(SETTINGS_PATH) != OK: push_warning("Unable to save core question block size preference.") func set_show_review_followups(value: bool) -> void: if show_review_followups == value: return show_review_followups = value var config := ConfigFile.new() config.load(SETTINGS_PATH) config.set_value(SETTINGS_SECTION, SETTINGS_KEY_SHOW_REVIEW_FOLLOWUPS, show_review_followups) if config.save(SETTINGS_PATH) != OK: push_warning("Unable to save follow-up question preference.") func _load_core_block_size() -> void: var config := ConfigFile.new() if config.load(SETTINGS_PATH) == OK: core_block_size = clampi( int(config.get_value(SETTINGS_SECTION, SETTINGS_KEY_CORE_BLOCK_SIZE, DEFAULT_CORE_BLOCK_SIZE)), CORE_BLOCK_SIZE_MIN, CORE_BLOCK_SIZE_MAX) func _load_show_review_followups() -> void: var config := ConfigFile.new() if config.load(SETTINGS_PATH) == OK: show_review_followups = bool(config.get_value(SETTINGS_SECTION, SETTINGS_KEY_SHOW_REVIEW_FOLLOWUPS, true)) func start_dynamic_question(question: Dictionary) -> bool: if question.is_empty(): return false system_name = str(question.get("system", "All Systems")) mode = "Tutor" session_kind = "dynamic" active_questions = [_prepare_question_choices(question)] answers = [null] current_index = 0 seconds_remaining = 0.0 session_initial_seconds = 90.0 current_question_started_at = Time.get_ticks_msec() / 1000.0 current_question_pause_started_at = 0.0 block_started = true session_changed.emit() return true func replace_dynamic_question(question: Dictionary) -> bool: if session_kind != "dynamic" or question.is_empty(): return false active_questions = [_prepare_question_choices(question)] answers = [null] current_index = 0 current_question_started_at = Time.get_ticks_msec() / 1000.0 current_question_pause_started_at = 0.0 session_changed.emit() return true 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) for index in range(active_questions.size()): active_questions[index] = _prepare_question_choices(active_questions[index]) answers.clear() answers.resize(active_questions.size()) current_index = 0 seconds_remaining = float(active_questions.size() * 90) session_initial_seconds = seconds_remaining block_started = true 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 session_initial_seconds = core_current_target_seconds 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_question_total_elapsed_seconds = 0.0 core_question_total_active_since = 0.0 core_question_total_frozen = 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 start_bookmark_question(question: Dictionary) -> bool: if question.is_empty(): return false system_name = str(question.get("system", "All Systems")) mode = "Tutor" session_kind = "bookmark" active_questions = [_prepare_question_choices(question)] answers = [null] current_index = 0 seconds_remaining = 0.0 session_initial_seconds = 90.0 current_question_started_at = Time.get_ticks_msec() / 1000.0 current_question_pause_started_at = 0.0 block_started = true session_changed.emit() return true func replace_bookmark_question(question: Dictionary) -> bool: if session_kind != "bookmark" or question.is_empty(): return false active_questions = [_prepare_question_choices(question)] answers = [null] current_index = 0 current_question_started_at = Time.get_ticks_msec() / 1000.0 current_question_pause_started_at = 0.0 session_changed.emit() return true func start_review_question(question: Dictionary) -> bool: if question.is_empty(): return false system_name = str(question.get("system", "All Systems")) mode = "Tutor" session_kind = "review" active_questions = [_prepare_question_choices(question)] answers = [null] current_index = 0 seconds_remaining = 0.0 session_initial_seconds = 90.0 current_question_started_at = Time.get_ticks_msec() / 1000.0 current_question_pause_started_at = 0.0 review_first_submission_recorded = false block_started = true session_changed.emit() return true func replace_review_question(question: Dictionary) -> bool: if session_kind != "review" or question.is_empty(): return false active_questions = [_prepare_question_choices(question)] answers = [null] current_index = 0 current_question_started_at = Time.get_ticks_msec() / 1000.0 current_question_pause_started_at = 0.0 review_first_submission_recorded = false session_changed.emit() return true func start_simulation_block(questions: Array, block_index: int, total_blocks: int, deadline_unix: float) -> void: system_name = "All Systems" mode = "Simulation" session_kind = "simulation" active_questions = questions.duplicate(true) for index in range(active_questions.size()): active_questions[index] = _prepare_question_choices(active_questions[index]) answers.clear() answers.resize(active_questions.size()) current_index = 0 simulation_block_index = block_index simulation_total_blocks = total_blocks simulation_deadline_unix = deadline_unix seconds_remaining = maxf(0.0, deadline_unix - Time.get_unix_time_from_system()) session_initial_seconds = maxf(seconds_remaining, float(active_questions.size() * 90)) block_started = true current_question_started_at = Time.get_ticks_msec() / 1000.0 session_changed.emit() func restore_simulation_responses(saved_responses: Array) -> void: if session_kind != "simulation": return for response in saved_responses: if typeof(response) != TYPE_DICTIONARY: continue var question_id := str(response.get("questionExternalId", "")) for index in range(active_questions.size()): if str(active_questions[index].get("id", "")) == question_id: answers[index] = { "question": active_questions[index].duplicate(true), "selected_choice_id": str(response.get("choiceId", "")), "is_correct": false, "eliminated_choice_ids": response.get("eliminatedChoiceIds", []).duplicate(), "flagged": bool(response.get("flagged", false)), "elapsed_seconds": int(response.get("elapsedSeconds", 0)), "is_submitted": _simulation_response_is_submitted(response) } break func _simulation_response_is_submitted(response: Dictionary) -> bool: var submitted: Variant = response.get("isSubmitted", null) if submitted == null: return not str(response.get("choiceId", "")).is_empty() return bool(submitted) func get_current_question() -> Dictionary: if active_questions.is_empty() or current_index < 0 or current_index >= active_questions.size(): return {} return active_questions[current_index] func answer_current(choice_id: String, eliminated: Array, flagged: bool, is_submitted := false) -> 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_target_seconds if session_kind == "core" else 0.0 } answers[current_index] = answer if session_kind == "simulation": answer["is_correct"] = false answer["is_submitted"] = is_submitted answers[current_index] = answer session_changed.emit() return 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 _freeze_core_question_total_timer() _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 submit_bookmark_attempt(choice_id: String, eliminated: Array, flagged: bool) -> Dictionary: var question := get_current_question() if session_kind != "bookmark" or 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": 0.0 } if bool(answer.is_correct): answers[current_index] = answer session_changed.emit() return {"answer": answer, "is_correct": bool(answer.is_correct), "final": bool(answer.is_correct)} func submit_review_attempt(choice_id: String, eliminated: Array, flagged: bool) -> Dictionary: var question := get_current_question() if session_kind != "review" or question.is_empty(): return {} var first_submission := not review_first_submission_recorded 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": 0.0 } review_first_submission_recorded = true if bool(answer.is_correct): answers[current_index] = answer session_changed.emit() return { "answer": answer, "is_correct": bool(answer.is_correct), "final": bool(answer.is_correct), "first_submission": first_submission } func skip_bookmark_question(eliminated: Array, flagged: bool) -> Dictionary: var question := get_current_question() if session_kind != "bookmark" or question.is_empty(): return {} var answer := { "question": question.duplicate(true), "selected_choice_id": "", "is_correct": false, "is_skip": true, "eliminated_choice_ids": eliminated.duplicate(), "flagged": flagged, "elapsed_seconds": get_current_elapsed_seconds(), "target_seconds": 0.0 } answers[current_index] = answer session_changed.emit() return answer func skip_core_question(eliminated: Array, flagged: bool) -> Dictionary: var question := get_current_question() if question.is_empty(): return {} var answer := { "question": question.duplicate(true), "selected_choice_id": "", "is_correct": false, "is_skip": true, "eliminated_choice_ids": eliminated.duplicate(), "flagged": flagged, "elapsed_seconds": get_current_elapsed_seconds(), "target_seconds": core_current_target_seconds } answers[current_index] = answer ProfileService.record_wrong_attempt(question) _apply_core_wrong_penalty(answer) core_target_seconds = min(CORE_MAX_TARGET_SECONDS, core_target_seconds * 1.05) session_changed.emit() return answer func move_previous() -> bool: if current_index > 0: current_index -= 1 session_changed.emit() return true return false 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() return true return false func move_to(index: int) -> bool: if index >= 0 and index < active_questions.size(): current_index = index session_changed.emit() return true return false func get_progress_text() -> String: if session_kind == "core": return str(core_answer_count + 1) if session_kind == "simulation": return "Block %d/%d | Question %d/%d" % [simulation_block_index + 1, simulation_total_blocks, current_index + 1, active_questions.size()] return "%d / %d" % [min(current_index + 1, active_questions.size()), active_questions.size()] func finish_block() -> Dictionary: if session_kind in ["core", "bookmark", "review"]: block_started = false session_changed.emit() return {} var final_answers: Array = [] for index in range(active_questions.size()): if answers[index] == null: final_answers.append({ "question": active_questions[index].duplicate(true), "selected_choice_id": "", "is_correct": false, "eliminated_choice_ids": [], "flagged": false }) else: final_answers.append(answers[index]) var results := ProgressionService.calculate_block_rewards(final_answers, mode, system_name) ProfileService.apply_block_results(results) block_started = false session_changed.emit() return results func has_answered_current() -> bool: return get_answer_for_index(current_index) != null func get_answer_for_index(index: int) -> Variant: if index < 0 or index >= answers.size(): return null return answers[index] func update_question_choice_percentages(question_id: String, percentages: Dictionary) -> void: for index in range(active_questions.size()): var question: Dictionary = active_questions[index] if str(question.get("id", "")) == question_id: question["choice_selection_percentages"] = percentages active_questions[index] = question break func update_question_elo(question_id: String, question_elo: int) -> void: for index in range(active_questions.size()): var question: Dictionary = active_questions[index] if str(question.get("id", "")) == question_id: question["question_elo"] = question_elo active_questions[index] = question break func advance_core_question() -> bool: var question := _draw_core_question() if question.is_empty(): core_status_message = "All available questions cleared." return false return append_core_question(question) func append_core_question(question: Dictionary) -> bool: if question.is_empty(): return false active_questions.append(_prepare_question_choices(question.duplicate(true))) 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_question_total_elapsed_seconds = 0.0 core_question_total_active_since = current_question_started_at core_question_total_frozen = false core_current_had_wrong_attempt = false session_changed.emit() return true func get_core_excluded_question_ids() -> Array: var excluded: Array = ProfileService.get_profile().get("correct_question_ids", []).duplicate() for question in active_questions: var question_id := str(question.get("id", "")) if question_id != "" and not excluded.has(question_id): excluded.append(question_id) return excluded 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 get_core_question_total_elapsed_seconds() -> float: if session_kind != "core": return 0.0 var elapsed := core_question_total_elapsed_seconds if core_question_total_active_since > 0.0: elapsed += Time.get_ticks_msec() / 1000.0 - core_question_total_active_since return maxf(0.0, elapsed) func get_session_timer_ratio() -> float: if session_kind == "core": return get_core_timer_ratio() if session_initial_seconds <= 0.0: return 1.0 return clamp(seconds_remaining / session_initial_seconds, 0.0, 1.0) func pause_current_question_timer() -> void: if session_kind in ["core", "review"] and current_question_pause_started_at <= 0.0: var now := Time.get_ticks_msec() / 1000.0 current_question_pause_started_at = now if session_kind == "core": _pause_core_question_total_timer(now) func resume_current_question_timer() -> void: if session_kind in ["core", "review"] and current_question_pause_started_at > 0.0: var now := Time.get_ticks_msec() / 1000.0 var paused_duration := now - current_question_pause_started_at current_question_started_at += paused_duration current_question_pause_started_at = 0.0 if session_kind == "core" and not core_question_total_frozen: core_question_total_active_since = now func _pause_core_question_total_timer(now: float) -> void: if core_question_total_active_since <= 0.0: return core_question_total_elapsed_seconds += maxf(0.0, now - core_question_total_active_since) core_question_total_active_since = 0.0 func _freeze_core_question_total_timer() -> void: _pause_core_question_total_timer(Time.get_ticks_msec() / 1000.0) core_question_total_frozen = true 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 _prepare_question_choices(candidate.duplicate(true)) return {} func _prepare_question_choices(question: Dictionary) -> Dictionary: var existing: Array = question.get("presented_choices", []) if not existing.is_empty(): return question var all_choices: Array = question.get("choices", []).duplicate(true) var correct_id := str(question.get("correct_choice_id", "")) var percentages: Dictionary = question.get("choice_selection_percentages", {}) var correct_choice: Dictionary = {} var distractors: Array = [] for choice in all_choices: if typeof(choice) != TYPE_DICTIONARY: continue if str(choice.get("id", "")) == correct_id: correct_choice = choice else: distractors.append(choice) var presented: Array = _select_weighted_distractors(distractors, percentages, 4) if not correct_choice.is_empty(): presented.append(correct_choice) elif not all_choices.is_empty(): presented = all_choices.slice(0, min(5, all_choices.size())) presented.shuffle() question["presented_choices"] = presented return question func _select_weighted_distractors(distractors: Array, percentages: Dictionary, count: int) -> Array: var remaining: Array = distractors.duplicate(true) var selected: Array = [] while selected.size() < count and not remaining.is_empty(): var total_weight := 0.0 for choice in remaining: total_weight += _distractor_weight(str(choice.get("id", "")), percentages) var threshold := randf() * total_weight var cumulative := 0.0 var selected_index := remaining.size() - 1 for index in range(remaining.size()): cumulative += _distractor_weight(str(remaining[index].get("id", "")), percentages) if threshold < cumulative: selected_index = index break selected.append(remaining[selected_index]) remaining.remove_at(selected_index) return selected func _distractor_weight(choice_id: String, percentages: Dictionary) -> float: var selection_rate := clampf(float(percentages.get(choice_id, 0)), 0.0, 100.0) return 1.0 + 5.0 * selection_rate / 100.0 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)