extends Node const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd") const DEFAULT_API_URL := "https://us3q.ranaze.com/api/exam-simulations" signal readiness_changed signal state_changed signal request_failed(message: String) signal outcome_changed signal history_changed signal exam_token_required var api_url := DEFAULT_API_URL var outcome_api_url := "https://us3q.ranaze.com/api/step3-outcomes" var request: HTTPRequest var readiness := {} var simulation := {} var outcome := {} var history: Array = [] var history_next_before := "" var history_next_cursor := "" var is_loading := false var pending_action := "" var request_queue: Array = [] var request_access_token := "" var last_failed_action := "" var submit_recovery_attempts := 0 func _ready() -> void: api_url = EndpointConfig.resolve( str(ProjectSettings.get_setting("step3/exam_simulation_api_url", DEFAULT_API_URL)), "/api/exam-simulations") request = HTTPRequest.new() request.request_completed.connect(_on_request_completed) add_child(request) outcome_api_url = EndpointConfig.resolve("https://us3q.ranaze.com/api/step3-outcomes", "/api/step3-outcomes") func load_readiness() -> void: _send("readiness", HTTPClient.METHOD_GET, "/readiness") func load_active() -> void: _send("active", HTTPClient.METHOD_GET, "/active") func load_history(cursor: String = "", status: String = "all", sort_order: String = "newest") -> void: var suffix := "/history?limit=20&status=%s&sort=%s" % [status.uri_encode(), sort_order.uri_encode()] if not cursor.is_empty(): suffix += "&cursor=%s" % cursor.uri_encode() _send("history_append" if not cursor.is_empty() else "history", HTTPClient.METHOD_GET, suffix) func load_simulation(simulation_id: String) -> void: _send("completed_get", HTTPClient.METHOD_GET, "/%s" % simulation_id.uri_encode()) func create_simulation() -> void: _send("create", HTTPClient.METHOD_POST, "") func load_results_preview() -> void: _send("preview", HTTPClient.METHOD_GET, "/preview-results") func start_next_block() -> void: if simulation.is_empty(): return _send("start", HTTPClient.METHOD_POST, "/%s/blocks/start" % str(simulation.get("id", ""))) func save_response(question_id: String, choice_id: String, eliminated: Array, flagged: bool, elapsed_seconds: int, is_submitted := false) -> void: if simulation.is_empty(): return _send("save", HTTPClient.METHOD_PUT, "/%s/responses" % str(simulation.get("id", "")), { "questionExternalId": question_id, "choiceId": choice_id, "eliminatedChoiceIds": eliminated, "flagged": flagged, "elapsedSeconds": elapsed_seconds, "isSubmitted": is_submitted }) func submit_block() -> void: if simulation.is_empty(): return submit_recovery_attempts = 0 _send("submit", HTTPClient.METHOD_POST, "/%s/blocks/submit" % str(simulation.get("id", ""))) func abandon() -> void: if simulation.is_empty(): return _send("abandon", HTTPClient.METHOD_POST, "/%s/abandon" % str(simulation.get("id", ""))) func load_outcome() -> void: _send_absolute("outcome_get", HTTPClient.METHOD_GET, "%s/me" % outcome_api_url.rstrip("/")) func save_outcome(official_score: int, exam_date: String) -> void: _send_absolute("outcome_save", HTTPClient.METHOD_PUT, "%s/me" % outcome_api_url.rstrip("/"), { "officialScore": official_score, "examDate": "%sT00:00:00Z" % exam_date, "consent": true }) func delete_outcome() -> void: _send_absolute("outcome_delete", HTTPClient.METHOD_DELETE, "%s/me" % outcome_api_url.rstrip("/")) func get_block_deadline_unix() -> float: var value := str(simulation.get("blockDeadlineUtc", "")) if value == "": return 0.0 return Time.get_unix_time_from_datetime_string(value) func get_current_questions() -> Array: return simulation.get("questions", []) func _send(action: String, method: int, suffix: String, payload: Dictionary = {}) -> void: _send_absolute(action, method, "%s%s" % [api_url.rstrip("/"), suffix], payload) func _send_absolute(action: String, method: int, url: String, payload: Dictionary = {}) -> void: if not AuthState.can_send_authenticated_request(): return if is_loading: request_queue.append({"action": action, "method": method, "url": url, "payload": payload.duplicate(true)}) return is_loading = true pending_action = action request_access_token = AuthState.access_token var headers := PackedStringArray(["Authorization: Bearer %s" % request_access_token]) var body := "" if not payload.is_empty(): headers.append("Content-Type: application/json") body = JSON.stringify(payload) var err := request.request(url, headers, method, body) if err != OK: is_loading = false pending_action = "" last_failed_action = action request_failed.emit("The simulation request could not be started.") func _on_request_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: var action := pending_action pending_action = "" is_loading = false call_deferred("_process_request_queue") if response_code == 401: AuthState.handle_unauthorized(request_access_token) if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: if _is_stale_submit_failure(action): return if _should_reconcile_submit_failure(action, response_code): submit_recovery_attempts += 1 _send( "submit_reconcile", HTTPClient.METHOD_GET, "/%s" % str(simulation.get("id", "")).uri_encode()) return var server_message := body.get_string_from_utf8().strip_edges() var error_parsed: Variant = JSON.parse_string(server_message) if typeof(error_parsed) == TYPE_DICTIONARY: var credit_status: Variant = error_parsed.get("credits", {}) if typeof(credit_status) == TYPE_DICTIONARY: CreditsService.apply_status(credit_status) if str(error_parsed.get("code", "")) == "exam_token_required": exam_token_required.emit() server_message = str(error_parsed.get("message", server_message)) if server_message.length() > 240: server_message = server_message.left(240) var message := "Simulation request failed with status %d." % response_code if not server_message.is_empty(): message = "%s %s" % [message, server_message] last_failed_action = action request_failed.emit(message) return if response_code == 204: if action == "active" or action == "abandon": simulation = {} state_changed.emit() elif action == "outcome_delete" or action == "outcome_get": outcome = {} outcome_changed.emit() return var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) if typeof(parsed) != TYPE_DICTIONARY: last_failed_action = action request_failed.emit("The simulation API returned an invalid response.") return CreditsService.apply_award_response(parsed) StudySessionService.apply_response(parsed) if action == "readiness": readiness = parsed readiness_changed.emit() return if action == "history" or action == "history_append": var items: Array = parsed.get("items", []) history = history + items if action == "history_append" else items var next_before: Variant = parsed.get("nextBefore", null) history_next_before = "" if next_before == null else str(next_before) var next_cursor: Variant = parsed.get("nextCursor", null) history_next_cursor = "" if next_cursor == null else str(next_cursor) history_changed.emit() return if action.begins_with("outcome_"): outcome = parsed outcome_changed.emit() return simulation = _normalize_simulation(parsed) state_changed.emit() if action == "submit": submit_recovery_attempts = 0 elif action == "submit_reconcile": if str(simulation.get("state", "")) == "block_active": _send("submit", HTTPClient.METHOD_POST, "/%s/blocks/submit" % str(simulation.get("id", ""))) else: submit_recovery_attempts = 0 func _is_stale_submit_failure(action: String) -> bool: return action == "submit" and str(simulation.get("state", "")) in ["between_blocks", "completed"] func _should_reconcile_submit_failure(action: String, response_code: int) -> bool: return ( action == "submit" and response_code == 409 and submit_recovery_attempts == 0 and str(simulation.get("state", "")) == "block_active") func _process_request_queue() -> void: if is_loading or request_queue.is_empty(): return var queued: Dictionary = request_queue.pop_front() _send_absolute(str(queued.get("action", "")), int(queued.get("method", HTTPClient.METHOD_GET)), str(queued.get("url", "")), queued.get("payload", {})) func _normalize_simulation(raw: Dictionary) -> Dictionary: var normalized := raw.duplicate(true) var normalized_questions: Array = [] for question in raw.get("questions", []): if typeof(question) != TYPE_DICTIONARY: continue normalized_questions.append({ "id": str(question.get("externalId", "")), "stem": str(question.get("stem", "")), "lead_in": str(question.get("leadIn", "")), "choices": question.get("choices", []), "discipline": str(question.get("discipline", "")), "system": str(question.get("system", "")), "performance_category": str(question.get("performanceCategory", "")), "labs": question.get("labs", []), "media": question.get("media", []), "correct_choice_id": "", "explanation": "" }) normalized["questions"] = normalized_questions return normalized