Scale unique answer tracking
This commit is contained in:
@@ -6,6 +6,7 @@ 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 DEFAULT_QUESTION_REPORTS_API_URL := "https://s3q.ranaze.com/api/question-reports"
|
||||
const RETRY_DELAYS := [2, 5, 10, 20, 30]
|
||||
const ATTEMPT_QUEUE_PATH := "user://pending_question_attempts.json"
|
||||
|
||||
signal questions_changed
|
||||
signal questions_load_failed(message: String)
|
||||
@@ -20,9 +21,9 @@ var questions: Array = []
|
||||
var request: HTTPRequest
|
||||
var attempt_request: HTTPRequest
|
||||
var rating_request: HTTPRequest
|
||||
var skip_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
|
||||
@@ -38,6 +39,9 @@ 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
|
||||
|
||||
func _ready() -> void:
|
||||
questions_api_url = str(ProjectSettings.get_setting("step3/questions_api_url", DEFAULT_QUESTIONS_API_URL))
|
||||
@@ -57,10 +61,6 @@ func _ready() -> void:
|
||||
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)
|
||||
|
||||
report_request = HTTPRequest.new()
|
||||
report_request.request_completed.connect(_on_question_report_submitted)
|
||||
add_child(report_request)
|
||||
@@ -70,6 +70,16 @@ func _ready() -> void:
|
||||
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) -> void:
|
||||
if AuthState.access_token.is_empty():
|
||||
@@ -144,12 +154,14 @@ func clear_questions() -> void:
|
||||
attempt_request.cancel_request()
|
||||
if rating_request != null:
|
||||
rating_request.cancel_request()
|
||||
if skip_request != null:
|
||||
skip_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 = ""
|
||||
@@ -168,32 +180,13 @@ func clear_questions() -> void:
|
||||
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.")
|
||||
_enqueue_attempt("answer", question_id, choice_id)
|
||||
|
||||
|
||||
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.")
|
||||
_enqueue_attempt("skip", question_id, "")
|
||||
|
||||
|
||||
func submit_question_report(question_id: String, category: String, details: String) -> bool:
|
||||
@@ -301,16 +294,111 @@ func _on_retry_timer_timeout() -> void:
|
||||
load_questions_from_api(false)
|
||||
|
||||
|
||||
func _enqueue_attempt(kind: String, question_id: String, choice_id: String) -> 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,
|
||||
"retry_count": 0
|
||||
})
|
||||
_save_attempt_queue()
|
||||
_process_attempt_queue()
|
||||
|
||||
|
||||
func _process_attempt_queue() -> void:
|
||||
if attempt_request_active or attempt_queue.is_empty() or AuthState.access_token.is_empty():
|
||||
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]
|
||||
var headers := PackedStringArray([
|
||||
"Content-Type: application/json",
|
||||
"Authorization: Bearer %s" % AuthState.access_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", ""))
|
||||
})
|
||||
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:
|
||||
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)
|
||||
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:
|
||||
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:
|
||||
push_warning("Question attempt API response was not an object.")
|
||||
_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 == "":
|
||||
@@ -325,16 +413,7 @@ func _on_first_attempt_recorded(result: int, response_code: int, _headers: Packe
|
||||
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
|
||||
|
||||
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", {}))
|
||||
@@ -350,6 +429,46 @@ func _on_question_skipped(result: int, response_code: int, _headers: PackedStrin
|
||||
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", "")),
|
||||
"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 := ""
|
||||
|
||||
Reference in New Issue
Block a user