extends Node const DEFAULT_QUESTIONS_API_URL := "https://s3q.ranaze.com/api/questions/search" const DEFAULT_QUESTION_ATTEMPT_API_URL := "https://s3q.ranaze.com/api/questions/attempts/first" const DEFAULT_QUESTION_API_BASE_URL := "https://s3q.ranaze.com/api/questions" const DEFAULT_RATINGS_API_URL := "https://s3q.ranaze.com/api/ratings/me" const RETRY_DELAYS := [2, 5, 10, 20, 30] signal questions_changed signal questions_load_failed(message: String) signal choice_percentages_updated(question_id: String, percentages: Dictionary) signal ratings_changed signal question_rating_updated(question_id: String, question_elo: int, rating: Dictionary) signal question_skipped(question_id: String, correct_choice_id: String, question_elo: int, rating: Dictionary) var questions: Array = [] var request: HTTPRequest var attempt_request: HTTPRequest var rating_request: HTTPRequest var skip_request: HTTPRequest var retry_timer: Timer var questions_api_url := DEFAULT_QUESTIONS_API_URL var question_attempt_api_url := DEFAULT_QUESTION_ATTEMPT_API_URL var question_api_base_url := DEFAULT_QUESTION_API_BASE_URL var ratings_api_url := DEFAULT_RATINGS_API_URL var is_loading := false var last_error := "" var load_state := "idle" var retry_attempt := 0 var next_retry_seconds := 0 var overall_elo := 1000 var rated_question_count := 0 var category_elos := {} var last_rating_change := {} func _ready() -> void: questions_api_url = str(ProjectSettings.get_setting("step3/questions_api_url", DEFAULT_QUESTIONS_API_URL)) question_attempt_api_url = str(ProjectSettings.get_setting("step3/question_attempt_api_url", DEFAULT_QUESTION_ATTEMPT_API_URL)) question_api_base_url = str(ProjectSettings.get_setting("step3/question_api_base_url", DEFAULT_QUESTION_API_BASE_URL)) ratings_api_url = str(ProjectSettings.get_setting("step3/ratings_api_url", DEFAULT_RATINGS_API_URL)) request = HTTPRequest.new() request.request_completed.connect(_on_api_questions_loaded) add_child(request) attempt_request = HTTPRequest.new() attempt_request.request_completed.connect(_on_first_attempt_recorded) add_child(attempt_request) rating_request = HTTPRequest.new() rating_request.request_completed.connect(_on_ratings_loaded) add_child(rating_request) skip_request = HTTPRequest.new() skip_request.request_completed.connect(_on_question_skipped) add_child(skip_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(reset_retry: bool = true) -> void: if AuthState.access_token.is_empty(): _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() load_ratings_from_api() var headers := PackedStringArray([ "Content-Type: application/json", "Authorization: Bearer %s" % AuthState.access_token ]) var payload := { "limit": 80 } var err := request.request(questions_api_url, headers, HTTPClient.METHOD_POST, JSON.stringify(payload)) if err != OK: _set_load_error("Failed to request questions API.") func _on_api_questions_loaded(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: is_loading = false if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: _set_load_error("Questions API request failed with status %d." % response_code) return var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) if typeof(parsed) != TYPE_ARRAY: _set_load_error("Questions API response was not an array.") return var api_questions: Array = [] for item in parsed: var normalized := _normalize_api_question(item) if _is_valid_question(normalized): api_questions.append(normalized) if api_questions.is_empty(): _set_load_error("Questions API returned no usable questions.") return 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 attempt_request != null: attempt_request.cancel_request() if rating_request != null: rating_request.cancel_request() if skip_request != null: skip_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 overall_elo = 1000 rated_question_count = 0 category_elos = {} last_rating_change = {} questions_changed.emit() ratings_changed.emit() func record_first_attempt(question_id: String, choice_id: String) -> void: if AuthState.access_token.is_empty() or question_id == "" or choice_id == "": return var headers := PackedStringArray([ "Content-Type: application/json", "Authorization: Bearer %s" % AuthState.access_token ]) var payload := { "questionExternalId": question_id, "choiceId": choice_id } var err := attempt_request.request(question_attempt_api_url, headers, HTTPClient.METHOD_POST, JSON.stringify(payload)) if err != OK: push_warning("Failed to record first question attempt.") func skip_question(question_id: String) -> void: if AuthState.access_token.is_empty() or question_id == "": return var headers := PackedStringArray([ "Content-Type: application/json", "Authorization: Bearer %s" % AuthState.access_token ]) var url := "%s/%s/skip" % [question_api_base_url.rstrip("/"), question_id.uri_encode()] var err := skip_request.request(url, headers, HTTPClient.METHOD_POST, "") if err != OK: push_warning("Failed to record skipped question.") func load_ratings_from_api() -> void: if AuthState.access_token.is_empty(): return var headers := PackedStringArray([ "Authorization: Bearer %s" % AuthState.access_token ]) var err := rating_request.request(ratings_api_url, headers, HTTPClient.METHOD_GET) if err != OK: push_warning("Failed to request rating profile.") func get_all_questions() -> Array: return questions.duplicate(true) func get_systems() -> Array[String]: var system_names: Array[String] = [] for question in questions: var system_name := str(question.get("system", "General")) if not system_names.has(system_name): system_names.append(system_name) system_names.sort() return system_names func get_questions_for_system(system_name: String) -> Array: if system_name == "All Systems": return get_all_questions() var filtered: Array = [] for question in questions: if str(question.get("system", "")) == system_name: filtered.append(question.duplicate(true)) return filtered func build_block(system_name: String, block_size: int) -> Array: var pool := get_questions_for_system(system_name) pool.shuffle() return pool.slice(0, min(block_size, pool.size())) func has_questions() -> bool: return not questions.is_empty() 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 _on_first_attempt_recorded(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: push_warning("Question attempt API request failed with status %d." % response_code) return var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) if typeof(parsed) != TYPE_DICTIONARY: push_warning("Question attempt API response was not an object.") return var question_id := str(parsed.get("questionExternalId", parsed.get("question_external_id", ""))) var percentages: Dictionary = parsed.get("choiceSelectionPercentages", parsed.get("choice_selection_percentages", {})) if question_id == "": return _update_question_percentages(question_id, percentages) var question_elo := int(parsed.get("questionElo", parsed.get("question_elo", 1000))) var rating: Dictionary = parsed.get("rating", {}) _update_question_elo(question_id, question_elo) _apply_rating_change(rating) choice_percentages_updated.emit(question_id, percentages) question_rating_updated.emit(question_id, question_elo, rating) func _on_question_skipped(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: push_warning("Question skip API request failed with status %d." % response_code) return var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) if typeof(parsed) != TYPE_DICTIONARY: push_warning("Question skip API response was not an object.") return var question_id := str(parsed.get("questionExternalId", parsed.get("question_external_id", ""))) var correct_choice_id := str(parsed.get("correctChoiceId", parsed.get("correct_choice_id", ""))) var percentages: Dictionary = parsed.get("choiceSelectionPercentages", parsed.get("choice_selection_percentages", {})) var question_elo := int(parsed.get("questionElo", parsed.get("question_elo", 1000))) var rating: Dictionary = parsed.get("rating", {}) if question_id == "": return _update_question_percentages(question_id, percentages) _update_question_elo(question_id, question_elo) _apply_rating_change(rating) choice_percentages_updated.emit(question_id, percentages) question_skipped.emit(question_id, correct_choice_id, question_elo, rating) func _on_ratings_loaded(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: push_warning("Ratings API request failed with status %d." % response_code) return var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) if typeof(parsed) != TYPE_DICTIONARY: push_warning("Ratings API response was not an object.") return overall_elo = int(parsed.get("overallElo", parsed.get("overall_elo", 1000))) rated_question_count = int(parsed.get("ratedQuestionCount", parsed.get("rated_question_count", 0))) category_elos = parsed.get("categoryElos", parsed.get("category_elos", {})) ratings_changed.emit() func _update_question_percentages(question_id: String, percentages: Dictionary) -> void: for index in range(questions.size()): var question: Dictionary = questions[index] if str(question.get("id", "")) == question_id: question["choice_selection_percentages"] = percentages questions[index] = question return func _update_question_elo(question_id: String, question_elo: int) -> void: for index in range(questions.size()): var question: Dictionary = questions[index] if str(question.get("id", "")) == question_id: question["question_elo"] = question_elo questions[index] = question return func _apply_rating_change(rating: Dictionary) -> void: if rating.is_empty(): return last_rating_change = rating overall_elo = int(rating.get("userOverallElo", rating.get("user_overall_elo", overall_elo))) var category := str(rating.get("category", "")) if category != "": category_elos[category] = int(rating.get("userCategoryElo", rating.get("user_category_elo", category_elos.get(category, 1000)))) rated_question_count += 1 if bool(rating.get("rated", false)) else 0 ratings_changed.emit() func _is_valid_question(item: Variant) -> bool: if typeof(item) != TYPE_DICTIONARY: return false var required := ["id", "stem", "lead_in", "choices", "correct_choice_id", "explanation", "system", "discipline"] for key in required: if not item.has(key): return false return typeof(item["choices"]) == TYPE_ARRAY and item["choices"].size() >= 2 func _normalize_api_question(item: Variant) -> Dictionary: if typeof(item) != TYPE_DICTIONARY: return {} var normalized := { "id": str(item.get("externalId", item.get("id", ""))), "api_id": str(item.get("id", "")), "stem": str(item.get("stem", "")), "lead_in": str(item.get("leadIn", item.get("lead_in", ""))), "choices": item.get("choices", []), "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", {})), "wrong_answer_followups": item.get("wrongAnswerFollowups", item.get("wrong_answer_followups", {})), "choice_selection_percentages": item.get("choiceSelectionPercentages", item.get("choice_selection_percentages", {})), "discipline": str(item.get("discipline", "")), "system": str(item.get("system", "")), "performance_category": str(item.get("performanceCategory", item.get("performance_category", ""))), "difficulty": int(item.get("difficulty", 1)), "question_elo": int(item.get("questionElo", item.get("question_elo", 1000))), "labs": item.get("labs", []), "media": item.get("media", []), "tags": item.get("tags", []) } return normalized