Files
step3/scripts/autoload/review_queue_service.gd
T

273 lines
8.5 KiB
GDScript

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)]