Files
step3/scripts/autoload/question_bank_service.gd
T
2026-07-11 02:05:43 -05:00

388 lines
13 KiB
GDScript

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", {})),
"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