Add server-side ELO ratings
This commit is contained in:
@@ -130,6 +130,29 @@ func submit_core_attempt(choice_id: String, eliminated: Array, flagged: bool) ->
|
||||
}
|
||||
|
||||
|
||||
func skip_core_question(eliminated: Array, flagged: bool) -> Dictionary:
|
||||
var question := get_current_question()
|
||||
if question.is_empty():
|
||||
return {}
|
||||
|
||||
var answer := {
|
||||
"question": question.duplicate(true),
|
||||
"selected_choice_id": "",
|
||||
"is_correct": false,
|
||||
"is_skip": true,
|
||||
"eliminated_choice_ids": eliminated.duplicate(),
|
||||
"flagged": flagged,
|
||||
"elapsed_seconds": get_current_elapsed_seconds(),
|
||||
"target_seconds": core_current_target_seconds
|
||||
}
|
||||
answers[current_index] = answer
|
||||
ProfileService.record_wrong_attempt(question)
|
||||
_apply_core_wrong_penalty(answer)
|
||||
core_target_seconds = min(CORE_MAX_TARGET_SECONDS, core_target_seconds * 1.05)
|
||||
session_changed.emit()
|
||||
return answer
|
||||
|
||||
|
||||
func move_previous() -> bool:
|
||||
if current_index > 0:
|
||||
current_index -= 1
|
||||
@@ -207,6 +230,15 @@ func update_question_choice_percentages(question_id: String, percentages: Dictio
|
||||
break
|
||||
|
||||
|
||||
func update_question_elo(question_id: String, question_elo: int) -> void:
|
||||
for index in range(active_questions.size()):
|
||||
var question: Dictionary = active_questions[index]
|
||||
if str(question.get("id", "")) == question_id:
|
||||
question["question_elo"] = question_elo
|
||||
active_questions[index] = question
|
||||
break
|
||||
|
||||
|
||||
func advance_core_question() -> bool:
|
||||
var question := _draw_core_question()
|
||||
if question.is_empty():
|
||||
|
||||
@@ -62,12 +62,9 @@ func get_profile() -> Dictionary:
|
||||
|
||||
func apply_block_results(results: Dictionary) -> void:
|
||||
_ensure_daily_current()
|
||||
profile["xp"] = int(profile["xp"]) + int(results.get("xp_earned", 0))
|
||||
profile["level"] = ProgressionService.level_for_xp(int(profile["xp"]))
|
||||
profile["current_streak"] = int(results.get("ending_streak", profile["current_streak"]))
|
||||
profile["best_streak"] = max(int(profile["best_streak"]), int(profile["current_streak"]))
|
||||
profile["daily_questions_answered"] = int(profile["daily_questions_answered"]) + int(results.get("total", 0))
|
||||
profile["daily_xp_earned"] = int(profile["daily_xp_earned"]) + int(results.get("xp_earned", 0))
|
||||
|
||||
var unlocked_badges: Array = results.get("badges_unlocked", [])
|
||||
for badge in unlocked_badges:
|
||||
@@ -91,7 +88,7 @@ func apply_block_results(results: Dictionary) -> void:
|
||||
"system": results.get("system", "All Systems"),
|
||||
"correct": results.get("correct", 0),
|
||||
"total": results.get("total", 0),
|
||||
"xp_earned": results.get("xp_earned", 0)
|
||||
"xp_earned": 0
|
||||
}
|
||||
profile["block_history"].push_front(summary)
|
||||
if profile["block_history"].size() > 12:
|
||||
@@ -135,12 +132,9 @@ func reset_qbank_memory() -> void:
|
||||
|
||||
func apply_question_results(results: Dictionary) -> void:
|
||||
_ensure_daily_current()
|
||||
profile["xp"] = int(profile["xp"]) + int(results.get("xp_earned", 0))
|
||||
profile["level"] = ProgressionService.level_for_xp(int(profile["xp"]))
|
||||
profile["current_streak"] = int(results.get("ending_streak", profile["current_streak"]))
|
||||
profile["best_streak"] = max(int(profile["best_streak"]), int(profile["current_streak"]))
|
||||
profile["daily_questions_answered"] = int(profile["daily_questions_answered"]) + int(results.get("total", 0))
|
||||
profile["daily_xp_earned"] = int(profile["daily_xp_earned"]) + int(results.get("xp_earned", 0))
|
||||
|
||||
var unlocked_badges: Array = results.get("badges_unlocked", [])
|
||||
for badge in unlocked_badges:
|
||||
|
||||
@@ -2,27 +2,42 @@ 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)
|
||||
@@ -31,6 +46,14 @@ func _ready() -> void:
|
||||
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)
|
||||
@@ -52,6 +75,7 @@ func load_questions_from_api(reset_retry: bool = true) -> void:
|
||||
last_error = ""
|
||||
questions.clear()
|
||||
questions_changed.emit()
|
||||
load_ratings_from_api()
|
||||
|
||||
var headers := PackedStringArray([
|
||||
"Content-Type: application/json",
|
||||
@@ -107,6 +131,10 @@ func clear_questions() -> void:
|
||||
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()
|
||||
@@ -115,7 +143,12 @@ func clear_questions() -> void:
|
||||
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:
|
||||
@@ -135,6 +168,31 @@ func record_first_attempt(question_id: String, choice_id: String) -> void:
|
||||
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)
|
||||
|
||||
@@ -214,7 +272,53 @@ func _on_first_attempt_recorded(result: int, response_code: int, _headers: Packe
|
||||
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:
|
||||
@@ -226,6 +330,27 @@ func _update_question_percentages(question_id: String, percentages: Dictionary)
|
||||
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
|
||||
@@ -252,7 +377,9 @@ func _normalize_api_question(item: Variant) -> Dictionary:
|
||||
"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", [])
|
||||
|
||||
Reference in New Issue
Block a user