Adds a server-computed tier/division (Bronze..Diamond, III-I) to the ratings profile endpoint and surfaces it next to the numeric ELO in the shared header badge, matching the coin balance display already there.
739 lines
27 KiB
GDScript
739 lines
27 KiB
GDScript
extends Node
|
|
|
|
const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
|
|
const DEFAULT_QUESTIONS_API_URL := "https://us3q.ranaze.com/api/questions/search"
|
|
const DEFAULT_QUESTION_ATTEMPT_API_URL := "https://us3q.ranaze.com/api/questions/attempts/first"
|
|
const DEFAULT_QUESTION_API_BASE_URL := "https://us3q.ranaze.com/api/questions"
|
|
const DEFAULT_RATINGS_API_URL := "https://us3q.ranaze.com/api/ratings/me"
|
|
const DEFAULT_QUESTION_REPORTS_API_URL := "https://us3q.ranaze.com/api/question-reports"
|
|
const RETRY_DELAYS := [2, 5, 10, 20, 30]
|
|
const REQUEST_TIMEOUT_SECONDS := 30.0
|
|
const IMAGE_QUESTION_SEARCH_LIMIT := 200
|
|
const ATTEMPT_QUEUE_PATH := "user://pending_question_attempts.json"
|
|
|
|
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)
|
|
signal question_report_submitted(question_id: String, report_id: String)
|
|
signal question_report_failed(question_id: String, message: String)
|
|
|
|
var questions: Array = []
|
|
var request: HTTPRequest
|
|
var attempt_request: HTTPRequest
|
|
var rating_request: HTTPRequest
|
|
var report_request: HTTPRequest
|
|
var retry_timer: Timer
|
|
var attempt_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 question_reports_api_url := DEFAULT_QUESTION_REPORTS_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 rank_tier := "Bronze"
|
|
var rank_division := 3
|
|
var rated_question_count := 0
|
|
var category_elos := {}
|
|
var last_rating_change := {}
|
|
var pending_report_question_id := ""
|
|
var attempt_queue: Array = []
|
|
var active_attempt_index := -1
|
|
var attempt_request_active := false
|
|
var questions_request_token := ""
|
|
var attempt_request_token := ""
|
|
var rating_request_token := ""
|
|
var report_request_token := ""
|
|
var pending_excluded_question_ids: Array = []
|
|
var pending_require_media := false
|
|
var image_question_search_attempts := 0
|
|
|
|
func _ready() -> void:
|
|
questions_api_url = EndpointConfig.resolve(
|
|
str(ProjectSettings.get_setting("step3/questions_api_url", DEFAULT_QUESTIONS_API_URL)),
|
|
"/api/questions/search")
|
|
question_attempt_api_url = EndpointConfig.resolve(
|
|
str(ProjectSettings.get_setting("step3/question_attempt_api_url", DEFAULT_QUESTION_ATTEMPT_API_URL)),
|
|
"/api/questions/attempts/first")
|
|
question_api_base_url = EndpointConfig.resolve(
|
|
str(ProjectSettings.get_setting("step3/question_api_base_url", DEFAULT_QUESTION_API_BASE_URL)),
|
|
"/api/questions")
|
|
ratings_api_url = EndpointConfig.resolve(
|
|
str(ProjectSettings.get_setting("step3/ratings_api_url", DEFAULT_RATINGS_API_URL)),
|
|
"/api/ratings/me")
|
|
question_reports_api_url = EndpointConfig.resolve(
|
|
str(ProjectSettings.get_setting("step3/question_reports_api_url", DEFAULT_QUESTION_REPORTS_API_URL)),
|
|
"/api/question-reports")
|
|
request = HTTPRequest.new()
|
|
request.timeout = REQUEST_TIMEOUT_SECONDS
|
|
request.request_completed.connect(_on_api_questions_loaded)
|
|
add_child(request)
|
|
|
|
attempt_request = HTTPRequest.new()
|
|
attempt_request.timeout = REQUEST_TIMEOUT_SECONDS
|
|
attempt_request.request_completed.connect(_on_first_attempt_recorded)
|
|
add_child(attempt_request)
|
|
|
|
rating_request = HTTPRequest.new()
|
|
rating_request.timeout = REQUEST_TIMEOUT_SECONDS
|
|
rating_request.request_completed.connect(_on_ratings_loaded)
|
|
add_child(rating_request)
|
|
|
|
report_request = HTTPRequest.new()
|
|
report_request.timeout = REQUEST_TIMEOUT_SECONDS
|
|
report_request.request_completed.connect(_on_question_report_submitted)
|
|
add_child(report_request)
|
|
|
|
retry_timer = Timer.new()
|
|
retry_timer.one_shot = true
|
|
retry_timer.timeout.connect(_on_retry_timer_timeout)
|
|
add_child(retry_timer)
|
|
|
|
attempt_retry_timer = Timer.new()
|
|
attempt_retry_timer.one_shot = true
|
|
attempt_retry_timer.timeout.connect(_process_attempt_queue)
|
|
add_child(attempt_retry_timer)
|
|
|
|
_load_attempt_queue()
|
|
if not AuthState.auth_changed.is_connected(_on_auth_changed_for_attempt_queue):
|
|
AuthState.auth_changed.connect(_on_auth_changed_for_attempt_queue)
|
|
_process_attempt_queue()
|
|
|
|
|
|
func load_questions_from_api(reset_retry: bool = true, excluded_question_ids: Array = [], require_media: bool = false) -> void:
|
|
if not AuthState.can_send_authenticated_request():
|
|
_set_load_error("Login required before loading questions.", false)
|
|
return
|
|
|
|
if reset_retry:
|
|
pending_excluded_question_ids = excluded_question_ids.duplicate()
|
|
pending_require_media = require_media
|
|
image_question_search_attempts = 0
|
|
retry_attempt = 0
|
|
next_retry_seconds = 0
|
|
retry_timer.stop()
|
|
if is_loading or request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
|
|
request.cancel_request()
|
|
is_loading = false
|
|
call_deferred("load_questions_from_api", false, pending_excluded_question_ids, pending_require_media)
|
|
return
|
|
elif is_loading or request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
|
|
# A late timer or repeated click must not turn an active request into an
|
|
# immediate ERR_BUSY failure.
|
|
return
|
|
|
|
var should_load_ratings := load_state == "idle"
|
|
is_loading = true
|
|
load_state = "retrying" if retry_attempt > 0 else "loading"
|
|
last_error = ""
|
|
questions.clear()
|
|
questions_changed.emit()
|
|
if should_load_ratings:
|
|
load_ratings_from_api()
|
|
|
|
questions_request_token = AuthState.access_token
|
|
var headers := PackedStringArray([
|
|
"Content-Type: application/json",
|
|
"Authorization: Bearer %s" % questions_request_token
|
|
])
|
|
var payload := {
|
|
"limit": 1,
|
|
"supportsChoicePools": true,
|
|
"requireMedia": pending_require_media,
|
|
"excludeExternalIds": pending_excluded_question_ids
|
|
}
|
|
var err := request.request(questions_api_url, headers, HTTPClient.METHOD_POST, JSON.stringify(payload))
|
|
if err != OK:
|
|
_set_load_error("Unable to connect to the question service.")
|
|
|
|
|
|
func _on_api_questions_loaded(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
|
is_loading = false
|
|
if response_code == 401:
|
|
AuthState.handle_unauthorized(questions_request_token)
|
|
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
|
var message := "The connection to the question service was interrupted."
|
|
if response_code > 0:
|
|
message = "Questions API request failed with status %d." % response_code
|
|
_set_load_error(message, _is_transient_question_failure(result, 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.", false)
|
|
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.", false)
|
|
return
|
|
if pending_require_media and not _contains_question_with_media(api_questions):
|
|
for question in api_questions:
|
|
var question_id := str(question.get("id", ""))
|
|
if question_id != "" and not pending_excluded_question_ids.has(question_id):
|
|
pending_excluded_question_ids.append(question_id)
|
|
image_question_search_attempts += api_questions.size()
|
|
if image_question_search_attempts >= IMAGE_QUESTION_SEARCH_LIMIT:
|
|
_set_load_error("No question with an image was found.", false)
|
|
return
|
|
call_deferred("load_questions_from_api", false, pending_excluded_question_ids, true)
|
|
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()
|
|
if is_loading or request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
|
|
request.cancel_request()
|
|
is_loading = false
|
|
call_deferred("load_questions_from_api", false, pending_excluded_question_ids, pending_require_media)
|
|
|
|
|
|
func load_next_random_question(excluded_question_ids: Array) -> void:
|
|
load_questions_from_api(true, excluded_question_ids)
|
|
|
|
|
|
func load_image_question_for_testing() -> void:
|
|
load_questions_from_api(true, [], true)
|
|
|
|
|
|
func _contains_question_with_media(question_list: Array) -> bool:
|
|
for question in question_list:
|
|
var media: Array = question.get("media", [])
|
|
if not media.is_empty():
|
|
return true
|
|
return 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 report_request != null:
|
|
report_request.cancel_request()
|
|
if retry_timer != null:
|
|
retry_timer.stop()
|
|
if attempt_retry_timer != null:
|
|
attempt_retry_timer.stop()
|
|
attempt_request_active = false
|
|
active_attempt_index = -1
|
|
questions.clear()
|
|
is_loading = false
|
|
last_error = ""
|
|
load_state = "idle"
|
|
retry_attempt = 0
|
|
next_retry_seconds = 0
|
|
overall_elo = 1000
|
|
rank_tier = "Bronze"
|
|
rank_division = 3
|
|
rated_question_count = 0
|
|
category_elos = {}
|
|
last_rating_change = {}
|
|
pending_report_question_id = ""
|
|
pending_excluded_question_ids.clear()
|
|
pending_require_media = false
|
|
image_question_search_attempts = 0
|
|
questions_changed.emit()
|
|
ratings_changed.emit()
|
|
|
|
|
|
func record_first_attempt(question_id: String, choice_id: String, presented_choice_ids: Array = [], duration_seconds: int = 0, met_time_limit: bool = true) -> void:
|
|
if not AuthState.can_send_authenticated_request() or question_id == "" or choice_id == "":
|
|
return
|
|
_enqueue_attempt("answer", question_id, choice_id, presented_choice_ids, duration_seconds, met_time_limit)
|
|
|
|
|
|
func skip_question(question_id: String) -> void:
|
|
if not AuthState.can_send_authenticated_request() or question_id == "":
|
|
return
|
|
_enqueue_attempt("skip", question_id, "")
|
|
|
|
|
|
func submit_question_report(question_id: String, category: String, details: String) -> bool:
|
|
if not AuthState.can_send_authenticated_request() or question_id == "":
|
|
question_report_failed.emit(question_id, "Login is required to submit a report.")
|
|
return false
|
|
if report_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
|
|
question_report_failed.emit(question_id, "Another report is still being submitted.")
|
|
return false
|
|
|
|
report_request_token = AuthState.access_token
|
|
var headers := PackedStringArray([
|
|
"Content-Type: application/json",
|
|
"Authorization: Bearer %s" % report_request_token
|
|
])
|
|
var payload := {
|
|
"questionExternalId": question_id,
|
|
"category": category,
|
|
"details": details
|
|
}
|
|
pending_report_question_id = question_id
|
|
var err := report_request.request(
|
|
question_reports_api_url,
|
|
headers,
|
|
HTTPClient.METHOD_POST,
|
|
JSON.stringify(payload)
|
|
)
|
|
if err != OK:
|
|
pending_report_question_id = ""
|
|
question_report_failed.emit(question_id, "The report could not be submitted. Please try again.")
|
|
return false
|
|
return true
|
|
|
|
|
|
func load_ratings_from_api() -> void:
|
|
if not AuthState.can_send_authenticated_request():
|
|
return
|
|
rating_request_token = AuthState.access_token
|
|
var headers := PackedStringArray([
|
|
"Authorization: Bearer %s" % rating_request_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)
|
|
if schedule_retry and not AuthState.access_token.is_empty():
|
|
_schedule_retry()
|
|
else:
|
|
load_state = "failed"
|
|
next_retry_seconds = 0
|
|
questions_load_failed.emit(message)
|
|
questions_changed.emit()
|
|
|
|
|
|
func _is_transient_question_failure(result: int, response_code: int) -> bool:
|
|
if result != HTTPRequest.RESULT_SUCCESS:
|
|
return true
|
|
return response_code == 0 or response_code == 408 or response_code == 425 \
|
|
or response_code == 429 or response_code >= 500
|
|
|
|
|
|
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
|
|
if is_loading or request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
|
|
retry_timer.start(1.0)
|
|
return
|
|
load_questions_from_api(false)
|
|
|
|
|
|
func _enqueue_attempt(kind: String, question_id: String, choice_id: String, presented_choice_ids: Array = [], duration_seconds: int = 0, met_time_limit: bool = true) -> void:
|
|
var account := AuthState.username.strip_edges()
|
|
if account == "":
|
|
return
|
|
for queued in attempt_queue:
|
|
if str(queued.get("account", "")) == account and str(queued.get("question_id", "")) == question_id:
|
|
return
|
|
attempt_queue.append({
|
|
"account": account,
|
|
"kind": kind,
|
|
"question_id": question_id,
|
|
"choice_id": choice_id,
|
|
"presented_choice_ids": presented_choice_ids.duplicate(),
|
|
"duration_seconds": maxi(0, duration_seconds),
|
|
"met_time_limit": met_time_limit,
|
|
"retry_count": 0
|
|
})
|
|
_save_attempt_queue()
|
|
_process_attempt_queue()
|
|
|
|
|
|
func _process_attempt_queue() -> void:
|
|
if attempt_request_active or attempt_queue.is_empty() or not AuthState.can_send_authenticated_request():
|
|
return
|
|
if attempt_retry_timer != null and not attempt_retry_timer.is_stopped():
|
|
return
|
|
|
|
active_attempt_index = -1
|
|
for index in range(attempt_queue.size()):
|
|
if str(attempt_queue[index].get("account", "")) == AuthState.username:
|
|
active_attempt_index = index
|
|
break
|
|
if active_attempt_index < 0:
|
|
return
|
|
|
|
var queued: Dictionary = attempt_queue[active_attempt_index]
|
|
attempt_request_token = AuthState.access_token
|
|
var headers := PackedStringArray([
|
|
"Content-Type: application/json",
|
|
"Authorization: Bearer %s" % attempt_request_token
|
|
])
|
|
var question_id := str(queued.get("question_id", ""))
|
|
var url := question_attempt_api_url
|
|
var body := JSON.stringify({
|
|
"questionExternalId": question_id,
|
|
"choiceId": str(queued.get("choice_id", "")),
|
|
"presentedChoiceIds": queued.get("presented_choice_ids", []),
|
|
"durationSeconds": int(queued.get("duration_seconds", 0)),
|
|
"metTimeLimit": bool(queued.get("met_time_limit", true))
|
|
})
|
|
if str(queued.get("kind", "answer")) == "skip":
|
|
url = "%s/%s/skip" % [question_api_base_url.rstrip("/"), question_id.uri_encode()]
|
|
body = ""
|
|
|
|
attempt_request_active = true
|
|
var err := attempt_request.request(url, headers, HTTPClient.METHOD_POST, body)
|
|
if err != OK:
|
|
attempt_request_active = false
|
|
_schedule_attempt_retry()
|
|
|
|
|
|
func _schedule_attempt_retry() -> void:
|
|
if active_attempt_index < 0 or active_attempt_index >= attempt_queue.size():
|
|
return
|
|
var queued: Dictionary = attempt_queue[active_attempt_index]
|
|
var retry_count := int(queued.get("retry_count", 0)) + 1
|
|
queued["retry_count"] = retry_count
|
|
attempt_queue[active_attempt_index] = queued
|
|
_save_attempt_queue()
|
|
var delay_index: int = min(retry_count - 1, RETRY_DELAYS.size() - 1)
|
|
attempt_retry_timer.start(float(RETRY_DELAYS[delay_index]))
|
|
|
|
|
|
func _remove_active_attempt() -> void:
|
|
if active_attempt_index >= 0 and active_attempt_index < attempt_queue.size():
|
|
attempt_queue.remove_at(active_attempt_index)
|
|
_save_attempt_queue()
|
|
active_attempt_index = -1
|
|
|
|
|
|
func _on_first_attempt_recorded(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
|
attempt_request_active = false
|
|
if active_attempt_index < 0 or active_attempt_index >= attempt_queue.size():
|
|
return
|
|
var queued: Dictionary = attempt_queue[active_attempt_index]
|
|
var succeeded := result == HTTPRequest.RESULT_SUCCESS and response_code >= 200 and response_code < 300
|
|
if not succeeded:
|
|
if response_code == 401:
|
|
AuthState.handle_unauthorized(attempt_request_token)
|
|
active_attempt_index = -1
|
|
elif response_code in [400, 403, 404]:
|
|
push_warning("Discarding invalid question attempt with status %d." % response_code)
|
|
_remove_active_attempt()
|
|
_process_attempt_queue()
|
|
else:
|
|
_schedule_attempt_retry()
|
|
return
|
|
|
|
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
|
|
if typeof(parsed) != TYPE_DICTIONARY:
|
|
_schedule_attempt_retry()
|
|
return
|
|
|
|
_remove_active_attempt()
|
|
if str(queued.get("kind", "answer")) == "skip":
|
|
_apply_skip_response(parsed)
|
|
else:
|
|
_apply_first_attempt_response(parsed)
|
|
_process_attempt_queue()
|
|
|
|
|
|
func _apply_first_attempt_response(parsed: Dictionary) -> void:
|
|
|
|
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)
|
|
var credits_status: Variant = parsed.get("credits", null)
|
|
if typeof(credits_status) == TYPE_DICTIONARY:
|
|
CreditsService.apply_status(credits_status)
|
|
choice_percentages_updated.emit(question_id, percentages)
|
|
question_rating_updated.emit(question_id, question_elo, rating)
|
|
|
|
|
|
func _apply_skip_response(parsed: Dictionary) -> void:
|
|
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_auth_changed_for_attempt_queue() -> void:
|
|
if AuthState.access_token.is_empty():
|
|
if attempt_retry_timer != null:
|
|
attempt_retry_timer.stop()
|
|
return
|
|
_process_attempt_queue()
|
|
|
|
|
|
func _load_attempt_queue() -> void:
|
|
attempt_queue = []
|
|
if not FileAccess.file_exists(ATTEMPT_QUEUE_PATH):
|
|
return
|
|
var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(ATTEMPT_QUEUE_PATH))
|
|
if typeof(parsed) != TYPE_ARRAY:
|
|
return
|
|
for item in parsed:
|
|
if typeof(item) != TYPE_DICTIONARY:
|
|
continue
|
|
var account := str(item.get("account", "")).strip_edges()
|
|
var question_id := str(item.get("question_id", "")).strip_edges()
|
|
var kind := str(item.get("kind", "answer"))
|
|
if account == "" or question_id == "" or kind not in ["answer", "skip"]:
|
|
continue
|
|
attempt_queue.append({
|
|
"account": account,
|
|
"kind": kind,
|
|
"question_id": question_id,
|
|
"choice_id": str(item.get("choice_id", "")),
|
|
"presented_choice_ids": item.get("presented_choice_ids", []).duplicate(),
|
|
"retry_count": max(0, int(item.get("retry_count", 0)))
|
|
})
|
|
|
|
|
|
func _save_attempt_queue() -> void:
|
|
var file := FileAccess.open(ATTEMPT_QUEUE_PATH, FileAccess.WRITE)
|
|
if file == null:
|
|
push_warning("Could not persist pending question attempts.")
|
|
return
|
|
file.store_string(JSON.stringify(attempt_queue))
|
|
|
|
|
|
func _on_question_report_submitted(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
|
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
|
|
var question_id := ""
|
|
if typeof(parsed) == TYPE_DICTIONARY:
|
|
question_id = str(parsed.get("questionExternalId", parsed.get("question_external_id", "")))
|
|
if question_id == "":
|
|
question_id = pending_report_question_id
|
|
pending_report_question_id = ""
|
|
if response_code == 401:
|
|
AuthState.handle_unauthorized(report_request_token)
|
|
|
|
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
|
var message := "The report could not be submitted. Please try again."
|
|
if typeof(parsed) == TYPE_DICTIONARY:
|
|
message = str(parsed.get("detail", parsed.get("title", message)))
|
|
elif typeof(parsed) == TYPE_STRING and not str(parsed).strip_edges().is_empty():
|
|
message = str(parsed).strip_edges()
|
|
question_report_failed.emit(question_id, message)
|
|
return
|
|
|
|
if typeof(parsed) != TYPE_DICTIONARY:
|
|
question_report_failed.emit(question_id, "The report API returned an invalid response.")
|
|
return
|
|
|
|
var report_id := str(parsed.get("id", ""))
|
|
if question_id == "" or report_id == "":
|
|
question_report_failed.emit(question_id, "The report API returned an incomplete response.")
|
|
return
|
|
question_report_submitted.emit(question_id, report_id)
|
|
|
|
|
|
func _on_ratings_loaded(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
|
if response_code == 401:
|
|
AuthState.handle_unauthorized(rating_request_token)
|
|
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)))
|
|
rank_tier = str(parsed.get("tier", rank_tier))
|
|
rank_division = int(parsed.get("tierDivision", parsed.get("tier_division", rank_division)))
|
|
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", "")),
|
|
"key_takeaway": str(item.get("keyTakeaway", item.get("key_takeaway", ""))),
|
|
"usmle_pearl": str(item.get("usmlePearl", item.get("usmle_pearl", ""))),
|
|
"review_followups": _normalize_review_followups(item.get("reviewFollowups", item.get("review_followups", []))),
|
|
"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))),
|
|
"is_bookmarked": bool(item.get("isBookmarked", item.get("is_bookmarked", false))),
|
|
"labs": item.get("labs", []),
|
|
"media": item.get("media", []),
|
|
"tags": item.get("tags", []),
|
|
"references": _normalize_references(item.get("references", []))
|
|
}
|
|
return normalized
|
|
|
|
|
|
func normalize_api_question(item: Variant) -> Dictionary:
|
|
return _normalize_api_question(item)
|
|
|
|
|
|
func _normalize_review_followups(raw_followups: Variant) -> Array:
|
|
var normalized: Array = []
|
|
if typeof(raw_followups) != TYPE_ARRAY:
|
|
return normalized
|
|
for raw_followup in raw_followups:
|
|
if typeof(raw_followup) != TYPE_DICTIONARY:
|
|
continue
|
|
normalized.append({
|
|
"prompt": str(raw_followup.get("prompt", "")).strip_edges(),
|
|
"choices": raw_followup.get("choices", []),
|
|
"correct_choice_id": str(raw_followup.get("correctChoiceId", raw_followup.get("correct_choice_id", ""))),
|
|
"teaching_point": str(raw_followup.get("teachingPoint", raw_followup.get("teaching_point", ""))).strip_edges()
|
|
})
|
|
return normalized
|
|
|
|
|
|
func _normalize_references(raw_references: Variant) -> Array:
|
|
var normalized: Array = []
|
|
if typeof(raw_references) != TYPE_ARRAY:
|
|
return normalized
|
|
for raw_reference in raw_references:
|
|
if typeof(raw_reference) != TYPE_DICTIONARY:
|
|
continue
|
|
normalized.append({
|
|
"title": str(raw_reference.get("title", raw_reference.get("Title", ""))).strip_edges(),
|
|
"publisher": str(raw_reference.get("publisher", raw_reference.get("Publisher", ""))).strip_edges(),
|
|
"url": str(raw_reference.get("url", raw_reference.get("Url", ""))).strip_edges(),
|
|
"supports": str(raw_reference.get("supports", raw_reference.get("Supports", ""))).strip_edges(),
|
|
"accessed_on": str(raw_reference.get("accessedOn", raw_reference.get("accessed_on", raw_reference.get("AccessedOn", "")))).strip_edges(),
|
|
"reviewer": str(raw_reference.get("reviewer", raw_reference.get("Reviewer", ""))).strip_edges()
|
|
})
|
|
return normalized
|