Implement persistent review queue
This commit is contained in:
@@ -27,6 +27,7 @@ var core_status_message := ""
|
||||
var simulation_block_index := 0
|
||||
var simulation_total_blocks := 12
|
||||
var simulation_deadline_unix := 0.0
|
||||
var review_first_submission_recorded := false
|
||||
|
||||
const CORE_MIN_TARGET_SECONDS := 45.0
|
||||
const CORE_MAX_TARGET_SECONDS := 150.0
|
||||
@@ -107,6 +108,38 @@ func replace_bookmark_question(question: Dictionary) -> bool:
|
||||
return true
|
||||
|
||||
|
||||
func start_review_question(question: Dictionary) -> bool:
|
||||
if question.is_empty():
|
||||
return false
|
||||
system_name = str(question.get("system", "All Systems"))
|
||||
mode = "Tutor"
|
||||
session_kind = "review"
|
||||
active_questions = [_prepare_question_choices(question)]
|
||||
answers = [null]
|
||||
current_index = 0
|
||||
seconds_remaining = 0.0
|
||||
session_initial_seconds = 90.0
|
||||
current_question_started_at = Time.get_ticks_msec() / 1000.0
|
||||
current_question_pause_started_at = 0.0
|
||||
review_first_submission_recorded = false
|
||||
block_started = true
|
||||
session_changed.emit()
|
||||
return true
|
||||
|
||||
|
||||
func replace_review_question(question: Dictionary) -> bool:
|
||||
if session_kind != "review" or question.is_empty():
|
||||
return false
|
||||
active_questions = [_prepare_question_choices(question)]
|
||||
answers = [null]
|
||||
current_index = 0
|
||||
current_question_started_at = Time.get_ticks_msec() / 1000.0
|
||||
current_question_pause_started_at = 0.0
|
||||
review_first_submission_recorded = false
|
||||
session_changed.emit()
|
||||
return true
|
||||
|
||||
|
||||
func start_simulation_block(questions: Array, block_index: int, total_blocks: int, deadline_unix: float) -> void:
|
||||
system_name = "All Systems"
|
||||
mode = "Simulation"
|
||||
@@ -239,6 +272,32 @@ func submit_bookmark_attempt(choice_id: String, eliminated: Array, flagged: bool
|
||||
return {"answer": answer, "is_correct": bool(answer.is_correct), "final": bool(answer.is_correct)}
|
||||
|
||||
|
||||
func submit_review_attempt(choice_id: String, eliminated: Array, flagged: bool) -> Dictionary:
|
||||
var question := get_current_question()
|
||||
if session_kind != "review" or question.is_empty():
|
||||
return {}
|
||||
var first_submission := not review_first_submission_recorded
|
||||
var answer := {
|
||||
"question": question.duplicate(true),
|
||||
"selected_choice_id": choice_id,
|
||||
"is_correct": choice_id == str(question.get("correct_choice_id", "")),
|
||||
"eliminated_choice_ids": eliminated.duplicate(),
|
||||
"flagged": flagged,
|
||||
"elapsed_seconds": get_current_elapsed_seconds(),
|
||||
"target_seconds": 0.0
|
||||
}
|
||||
review_first_submission_recorded = true
|
||||
if bool(answer.is_correct):
|
||||
answers[current_index] = answer
|
||||
session_changed.emit()
|
||||
return {
|
||||
"answer": answer,
|
||||
"is_correct": bool(answer.is_correct),
|
||||
"final": bool(answer.is_correct),
|
||||
"first_submission": first_submission
|
||||
}
|
||||
|
||||
|
||||
func skip_bookmark_question(eliminated: Array, flagged: bool) -> Dictionary:
|
||||
var question := get_current_question()
|
||||
if session_kind != "bookmark" or question.is_empty():
|
||||
@@ -316,7 +375,7 @@ func get_progress_text() -> String:
|
||||
|
||||
|
||||
func finish_block() -> Dictionary:
|
||||
if session_kind in ["core", "bookmark"]:
|
||||
if session_kind in ["core", "bookmark", "review"]:
|
||||
block_started = false
|
||||
session_changed.emit()
|
||||
return {}
|
||||
@@ -435,19 +494,20 @@ func get_session_timer_ratio() -> float:
|
||||
|
||||
|
||||
func pause_current_question_timer() -> void:
|
||||
if session_kind == "core" and current_question_pause_started_at <= 0.0:
|
||||
if session_kind in ["core", "review"] and current_question_pause_started_at <= 0.0:
|
||||
var now := Time.get_ticks_msec() / 1000.0
|
||||
current_question_pause_started_at = now
|
||||
_pause_core_question_total_timer(now)
|
||||
if session_kind == "core":
|
||||
_pause_core_question_total_timer(now)
|
||||
|
||||
|
||||
func resume_current_question_timer() -> void:
|
||||
if session_kind == "core" and current_question_pause_started_at > 0.0:
|
||||
if session_kind in ["core", "review"] and current_question_pause_started_at > 0.0:
|
||||
var now := Time.get_ticks_msec() / 1000.0
|
||||
var paused_duration := now - current_question_pause_started_at
|
||||
current_question_started_at += paused_duration
|
||||
current_question_pause_started_at = 0.0
|
||||
if not core_question_total_frozen:
|
||||
if session_kind == "core" and not core_question_total_frozen:
|
||||
core_question_total_active_since = now
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
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://s3q.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 := ""
|
||||
|
||||
|
||||
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() -> void:
|
||||
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
|
||||
}
|
||||
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)]
|
||||
@@ -0,0 +1 @@
|
||||
uid://coquauokw5nup
|
||||
Reference in New Issue
Block a user