772 lines
29 KiB
GDScript
772 lines
29 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 pending_step3_system := ""
|
|
var pending_difficulty_min := 0
|
|
var pending_difficulty_max := 0
|
|
var pending_limit := 1
|
|
var targeted_fallback_used := 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,
|
|
step3_system: String = "", difficulty_min: int = 0, difficulty_max: int = 0, requested_limit: int = 1) -> 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
|
|
pending_step3_system = step3_system
|
|
pending_difficulty_min = difficulty_min
|
|
pending_difficulty_max = difficulty_max
|
|
pending_limit = clampi(requested_limit, 1, 40)
|
|
targeted_fallback_used = false
|
|
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": pending_limit,
|
|
"supportsChoicePools": true,
|
|
"requireMedia": pending_require_media,
|
|
"excludeExternalIds": pending_excluded_question_ids
|
|
}
|
|
if not pending_step3_system.is_empty():
|
|
payload["step3System"] = pending_step3_system
|
|
if pending_difficulty_min > 0:
|
|
payload["difficultyMin"] = pending_difficulty_min
|
|
if pending_difficulty_max > 0:
|
|
payload["difficultyMax"] = pending_difficulty_max
|
|
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():
|
|
if not pending_step3_system.is_empty() and (pending_difficulty_min > 0 or pending_difficulty_max > 0) and not targeted_fallback_used:
|
|
targeted_fallback_used = true
|
|
pending_difficulty_min = 0
|
|
pending_difficulty_max = 0
|
|
call_deferred("load_questions_from_api", false, pending_excluded_question_ids, pending_require_media)
|
|
return
|
|
_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 load_targeted_questions(step3_system: String, question_count: int, difficulty_min: int = 0, difficulty_max: int = 0) -> void:
|
|
load_questions_from_api(true, [], false, step3_system, difficulty_min, difficulty_max, question_count)
|
|
|
|
|
|
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
|
|
pending_step3_system = ""
|
|
pending_difficulty_min = 0
|
|
pending_difficulty_max = 0
|
|
pending_limit = 1
|
|
targeted_fallback_used = 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("step3_system", question.get("system", ""))) == system_name or 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", "")),
|
|
"step3_system": str(item.get("step3System", item.get("step3_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
|