extends Node signal question_loaded(question: Dictionary) signal question_failed(message: String) signal pass_completed(summary: Dictionary) signal attempt_synced(question_id: String, is_correct: bool, pending_count: int) const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd") const DEFAULT_API_URL := "https://us3q.ranaze.com/api/review-queue" const OUTBOX_PATH := "user://pending_review_attempts.json" var api_url := DEFAULT_API_URL var next_request: HTTPRequest var attempt_request: HTTPRequest var retry_timer: Timer var loading := false var submitting := false var request_token := "" var excluded_question_ids: Array = [] var current_cycle := 0 var pending_count := 0 var reviewed_count := 0 var cleared_count := 0 var retained_count := 0 var outbox: Array = [] var active_account_key := "" var step3_system := "" func _ready() -> void: api_url = EndpointConfig.resolve( str(ProjectSettings.get_setting("step3/review_queue_api_url", DEFAULT_API_URL)), "/api/review-queue") next_request = HTTPRequest.new() next_request.timeout = 25.0 next_request.request_completed.connect(_on_next_completed) add_child(next_request) attempt_request = HTTPRequest.new() attempt_request.timeout = 25.0 attempt_request.request_completed.connect(_on_attempt_completed) add_child(attempt_request) retry_timer = Timer.new() retry_timer.one_shot = true retry_timer.wait_time = 5.0 retry_timer.timeout.connect(_process_outbox) add_child(retry_timer) _load_outbox() AuthState.auth_changed.connect(_on_auth_changed) _on_auth_changed() func start_pass(requested_step3_system: String = "") -> void: step3_system = requested_step3_system excluded_question_ids.clear() reviewed_count = 0 cleared_count = 0 retained_count = 0 load_next() func load_next() -> void: if loading: return if not AuthState.can_send_authenticated_request(): question_failed.emit("Login is required to load the review queue.") return loading = true request_token = AuthState.access_token var payload := { "excludeExternalIds": excluded_question_ids, "supportsChoicePools": true } if not step3_system.is_empty(): payload["step3System"] = step3_system var error := next_request.request( "%s/next" % api_url, _headers(), HTTPClient.METHOD_POST, JSON.stringify(payload)) if error != OK: loading = false question_failed.emit("The review queue could not be loaded. Check your connection and try again.") func record_first_submission(question: Dictionary, choice_id: String, presented_choice_ids: Array, duration_seconds: int) -> void: var question_id := str(question.get("id", "")) if question_id == "" or choice_id == "" or current_cycle < 1: return var is_correct := choice_id == str(question.get("correct_choice_id", "")) reviewed_count += 1 if is_correct: cleared_count += 1 pending_count = maxi(0, pending_count - 1) else: retained_count += 1 var payload := { "clientAttemptId": _new_uuid(), "questionExternalId": question_id, "cycle": current_cycle, "choiceId": choice_id, "presentedChoiceIds": presented_choice_ids.duplicate(), "durationSeconds": clampi(duration_seconds, 0, 86400) } outbox.append({"account": _account_key(), "payload": payload, "retry_count": 0}) _save_outbox() _process_outbox() func get_summary() -> Dictionary: return { "reviewed": reviewed_count, "cleared": cleared_count, "remaining": maxi(0, pending_count), "needs_review": retained_count } func has_pending_submissions() -> bool: return _next_account_item_index() >= 0 func _on_next_completed(result: int, response_code: int, _headers_value: PackedStringArray, body: PackedByteArray) -> void: loading = false if response_code == 401: AuthState.handle_unauthorized(request_token) return if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: question_failed.emit("The review queue could not be loaded. Check your connection and try again.") return var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) if typeof(parsed) != TYPE_DICTIONARY: question_failed.emit("The review service returned an invalid response.") return pending_count = int(parsed.get("pendingCount", parsed.get("pending_count", pending_count))) var raw_question: Variant = parsed.get("question", {}) if typeof(raw_question) != TYPE_DICTIONARY or raw_question.is_empty(): pass_completed.emit(get_summary()) return var question := QuestionBankService.normalize_api_question(raw_question) if question.is_empty() or str(question.get("id", "")) == "": question_failed.emit("The review service returned an invalid question.") return current_cycle = int(parsed.get("cycle", 0)) question["review_cycle"] = current_cycle var question_id := str(question.get("id", "")) if not excluded_question_ids.has(question_id): excluded_question_ids.append(question_id) question_loaded.emit(question) func _process_outbox() -> void: if submitting or outbox.is_empty() or not AuthState.can_send_authenticated_request(): return var index := _next_account_item_index() if index < 0: return var item: Dictionary = outbox[index] item["active"] = true outbox[index] = item submitting = true request_token = AuthState.access_token var error := attempt_request.request( "%s/attempts" % api_url, _headers(), HTTPClient.METHOD_POST, JSON.stringify(item.get("payload", {}))) if error != OK: submitting = false item.erase("active") item["retry_count"] = int(item.get("retry_count", 0)) + 1 outbox[index] = item _save_outbox() retry_timer.start() func _on_attempt_completed(result: int, response_code: int, _headers_value: PackedStringArray, body: PackedByteArray) -> void: submitting = false var index := _active_item_index() if index < 0: return var item: Dictionary = outbox[index] item.erase("active") if response_code == 401: outbox[index] = item _save_outbox() AuthState.handle_unauthorized(request_token) return if result == HTTPRequest.RESULT_SUCCESS and response_code >= 200 and response_code < 300: var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) var synced_question_id := "" var synced_is_correct := false if typeof(parsed) == TYPE_DICTIONARY: pending_count = int(parsed.get("pendingCount", parsed.get("pending_count", pending_count))) synced_question_id = str(parsed.get("questionExternalId", parsed.get("question_external_id", ""))) synced_is_correct = bool(parsed.get("isCorrect", parsed.get("is_correct", false))) outbox.remove_at(index) _save_outbox() attempt_synced.emit(synced_question_id, synced_is_correct, pending_count) _process_outbox() return if response_code >= 400 and response_code < 500 and response_code not in [408, 429]: outbox.remove_at(index) _save_outbox() _process_outbox() return item["retry_count"] = int(item.get("retry_count", 0)) + 1 outbox[index] = item _save_outbox() retry_timer.start() func _headers() -> PackedStringArray: return PackedStringArray([ "Content-Type: application/json", "Authorization: Bearer %s" % AuthState.access_token ]) func _on_auth_changed() -> void: active_account_key = _account_key() if AuthState.is_logged_in: _process_outbox() elif retry_timer != null: retry_timer.stop() func _account_key() -> String: return AuthState.username.strip_edges().to_lower().sha256_text() if not AuthState.username.strip_edges().is_empty() else "" func _next_account_item_index() -> int: for index in range(outbox.size()): if str(outbox[index].get("account", "")) == active_account_key: return index return -1 func _active_item_index() -> int: for index in range(outbox.size()): if bool(outbox[index].get("active", false)): return index return -1 func _load_outbox() -> void: outbox = [] if not FileAccess.file_exists(OUTBOX_PATH): return var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(OUTBOX_PATH)) if typeof(parsed) != TYPE_ARRAY: return for item in parsed: if typeof(item) == TYPE_DICTIONARY and typeof(item.get("payload", {})) == TYPE_DICTIONARY: item.erase("active") outbox.append(item) func _save_outbox() -> void: var stored := outbox.duplicate(true) for item in stored: item.erase("active") var file := FileAccess.open(OUTBOX_PATH, FileAccess.WRITE) if file != null: file.store_string(JSON.stringify(stored)) func _new_uuid() -> String: var bytes := Crypto.new().generate_random_bytes(16) bytes[6] = (bytes[6] & 0x0f) | 0x40 bytes[8] = (bytes[8] & 0x3f) | 0x80 var value := bytes.hex_encode() return "%s-%s-%s-%s-%s" % [value.substr(0, 8), value.substr(8, 4), value.substr(12, 4), value.substr(16, 4), value.substr(20, 12)]