Add server-side ELO ratings

This commit is contained in:
hz
2026-07-11 02:05:43 -05:00
parent bdd32c9dac
commit c716b16c53
21 changed files with 688 additions and 47 deletions
+8 -3
View File
@@ -5,7 +5,7 @@ const Style := preload("res://scripts/ui/ui_style.gd")
var main: Control
var status_label: Label
var daily_label: Label
var xp_label: Label
var elo_label: Label
var streak_label: Label
var bank_label: Label
var error_label: Label
@@ -17,6 +17,8 @@ func _ready() -> void:
add_theme_constant_override("separation", 18)
if not QuestionBankService.questions_changed.is_connected(_refresh):
QuestionBankService.questions_changed.connect(_refresh)
if not QuestionBankService.ratings_changed.is_connected(_refresh):
QuestionBankService.ratings_changed.connect(_refresh)
if not ProfileService.profile_changed.is_connected(_refresh):
ProfileService.profile_changed.connect(_refresh)
@@ -36,7 +38,7 @@ func _ready() -> void:
add_child(summary)
daily_label = _add_metric(summary, "Today")
xp_label = _add_metric(summary, "Daily XP")
elo_label = _add_metric(summary, "ELO")
streak_label = _add_metric(summary, "Streak")
bank_label = _add_metric(summary, "Question Bank")
@@ -115,7 +117,10 @@ func _refresh() -> void:
if progress > 100:
progress = 100
daily_label.text = "Today\n%d/%d questions\n%d%% complete" % [answered, goal, progress]
xp_label.text = "Daily XP\n%d XP earned" % int(profile.get("daily_xp_earned", 0))
elo_label.text = "ELO\n%d overall\n%d rated" % [
QuestionBankService.overall_elo,
QuestionBankService.rated_question_count
]
streak_label.text = "Streak\n%d current\n%d best" % [
int(profile.get("current_streak", 0)),
int(profile.get("best_streak", 0))
+3 -3
View File
@@ -131,6 +131,7 @@ func _build_ui() -> void:
screens["profile"] = profile
ProfileService.profile_changed.connect(_refresh_header_stats)
QuestionBankService.ratings_changed.connect(_refresh_header_stats)
AuthState.auth_changed.connect(_refresh_header_stats)
_refresh_header_stats()
_build_app_menu()
@@ -148,9 +149,8 @@ func _refresh_header_stats() -> void:
if header_stats == null:
return
var profile := ProfileService.get_profile()
header_stats.text = "LV %d | %d XP | Streak %d" % [
profile.get("level", 1),
profile.get("xp", 0),
header_stats.text = "ELO %d | Streak %d" % [
QuestionBankService.overall_elo,
profile.get("current_streak", 0)
]
if user_label != null:
+4 -5
View File
@@ -10,7 +10,7 @@ func _ready() -> void:
add_theme_constant_override("separation", 18)
var headline := Label.new()
headline.text = "Answer board-style questions. Earn XP. Unlock the map."
headline.text = "Answer board-style questions. Raise your ELO. Unlock the map."
headline.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
Style.set_label(headline, 38, Style.TEXT)
add_child(headline)
@@ -31,7 +31,7 @@ func _ready() -> void:
hero.add_child(hero_content)
var loop := Label.new()
loop.text = "XP Rush | Streak Heat | System Zones | Timed Blocks"
loop.text = "ELO Climb | Streak Heat | System Zones | Timed Blocks"
Style.set_label(loop, 24, Style.CYAN)
hero_content.add_child(loop)
@@ -72,9 +72,8 @@ func _ready() -> void:
func on_show(_payload: Variant = null) -> void:
var profile := ProfileService.get_profile()
stats_label.text = "Level %d | %d XP | Current streak: %d | Badges: %d" % [
profile["level"],
profile["xp"],
stats_label.text = "ELO %d | Current streak: %d | Badges: %d" % [
QuestionBankService.overall_elo,
profile["current_streak"],
profile["badges"].size()
]
+12 -15
View File
@@ -52,23 +52,21 @@ func _ready() -> void:
func on_show(_payload: Variant = null) -> void:
_reset_qbank_confirmation()
var profile := ProfileService.get_profile()
var xp_into := ProgressionService.xp_into_level(int(profile["xp"]))
var text := "[b]Level %d[/b]\n%d total XP (%d/%d to next level)\nCurrent streak: %d\nBest streak: %d\nToday: %d/%d questions, +%d XP\n\n[b]Badges[/b]\n" % [
profile["level"],
profile["xp"],
xp_into,
ProgressionService.XP_PER_LEVEL,
var text := "[b]Overall ELO %d[/b]\n%d rated questions\nCurrent streak: %d\nBest streak: %d\nToday: %d/%d questions\n\n[b]Category ELO[/b]\n" % [
QuestionBankService.overall_elo,
QuestionBankService.rated_question_count,
profile["current_streak"],
profile["best_streak"],
profile.get("daily_questions_answered", 0),
profile.get("daily_goal_questions", 10),
profile.get("daily_xp_earned", 0)
profile.get("daily_goal_questions", 10)
]
if profile["badges"].is_empty():
text += "No badges yet.\n"
if QuestionBankService.category_elos.is_empty():
text += "No rated categories yet.\n"
else:
for badge in profile["badges"]:
text += "- %s\n" % badge
var categories := QuestionBankService.category_elos.keys()
categories.sort()
for category in categories:
text += "- %s: %d\n" % [category, int(QuestionBankService.category_elos[category])]
text += "\n[b]System Accuracy[/b]\n"
if profile["system_stats"].is_empty():
@@ -83,12 +81,11 @@ func on_show(_payload: Variant = null) -> void:
text += "\n[b]Recent Blocks[/b]\n"
for block in profile["block_history"]:
text += "- %s: %s %d/%d, +%d XP\n" % [
text += "- %s: %s %d/%d\n" % [
block.get("timestamp", ""),
block.get("system", ""),
block.get("correct", 0),
block.get("total", 0),
block.get("xp_earned", 0)
block.get("total", 0)
]
profile_text.text = text
+132 -3
View File
@@ -6,6 +6,7 @@ const CORRECT_ADVANCE_DELAY_SECONDS := 0.6
var main: Control
var choice_buttons: Array[Button] = []
var choice_percent_labels: Array[Label] = []
var eliminated: Array = []
var locked_wrong_choice_ids: Array = []
var flagged := false
@@ -23,7 +24,9 @@ var timer_label: Label
var stem_label: RichTextLabel
var lead_label: Label
var explanation_label: RichTextLabel
var question_elo_label: Label
var next_button: Button
var skip_button: Button
var flag_button: Button
var report_button: Button
var nav_palette: HBoxContainer
@@ -41,6 +44,10 @@ func _ready() -> void:
add_theme_constant_override("separation", 10)
if not QuestionBankService.choice_percentages_updated.is_connected(_on_choice_percentages_updated):
QuestionBankService.choice_percentages_updated.connect(_on_choice_percentages_updated)
if not QuestionBankService.question_rating_updated.is_connected(_on_question_rating_updated):
QuestionBankService.question_rating_updated.connect(_on_question_rating_updated)
if not QuestionBankService.question_skipped.is_connected(_on_question_skipped):
QuestionBankService.question_skipped.connect(_on_question_skipped)
var top := HBoxContainer.new()
top.add_theme_constant_override("separation", 12)
@@ -125,6 +132,12 @@ func _ready() -> void:
Style.set_label(lead_label, 21, Style.TEXT)
question_content.add_child(lead_label)
question_elo_label = Label.new()
question_elo_label.visible = false
question_elo_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
Style.set_label(question_elo_label, 16, Style.AMBER)
question_content.add_child(question_elo_label)
for index in range(5):
var button := Button.new()
button.custom_minimum_size = Vector2(0, 64)
@@ -133,6 +146,19 @@ func _ready() -> void:
button.pressed.connect(func(i := index): _select_choice(i))
button.gui_input.connect(func(event: InputEvent, i := index): _choice_gui_input(event, i))
choice_buttons.append(button)
var percent_label := Label.new()
percent_label.visible = false
percent_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
percent_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
percent_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
percent_label.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
percent_label.offset_left = 0
percent_label.offset_right = -18
Style.set_label(percent_label, 18, Style.TEXT)
button.add_child(percent_label)
choice_percent_labels.append(percent_label)
question_content.add_child(button)
explanation_label = RichTextLabel.new()
@@ -145,6 +171,13 @@ func _ready() -> void:
var actions := HBoxContainer.new()
actions.add_theme_constant_override("separation", 10)
skip_button = Button.new()
skip_button.text = "Skip"
skip_button.custom_minimum_size = Vector2(120, 48)
Style.style_chip(skip_button, Style.AMBER)
skip_button.pressed.connect(_skip_current_question)
actions.add_child(skip_button)
next_button = Button.new()
next_button.text = "Submit"
next_button.custom_minimum_size = Vector2(140, 48)
@@ -215,7 +248,12 @@ func _load_question() -> void:
flag_button.button_pressed = false
_refresh_toolbar_state()
explanation_label.visible = false
question_elo_label.visible = false
question_elo_label.text = ""
next_button.text = "Submit"
next_button.disabled = false
skip_button.disabled = false
skip_button.visible = BlockSessionService.session_kind == "core"
var question := BlockSessionService.get_current_question()
if question.is_empty():
@@ -255,6 +293,7 @@ func _load_question() -> void:
else:
_apply_saved_choice_state()
next_button.text = "Next"
_update_question_elo_label(true)
_refresh_nav_palette()
_refresh_toolbar_state()
@@ -276,6 +315,25 @@ func _select_choice(index: int) -> void:
_apply_saved_choice_state()
func _skip_current_question() -> void:
if pending_correct_advance or not current_answer.is_empty() or BlockSessionService.session_kind != "core":
return
var question_id := str(BlockSessionService.get_current_question().get("id", ""))
if question_id == "":
return
current_answer = BlockSessionService.skip_core_question(eliminated, flagged)
if current_answer.is_empty():
return
QuestionBankService.skip_question(question_id)
selected_choice = ""
next_button.disabled = true
skip_button.disabled = true
pending_correct_advance = true
correct_advance_delay = CORRECT_ADVANCE_DELAY_SECONDS
_show_explanation()
_update_question_elo_label(true)
func _choice_gui_input(event: InputEvent, index: int) -> void:
if pending_correct_advance:
return
@@ -333,6 +391,7 @@ func _submit_or_next() -> void:
var answer: Dictionary = result.get("answer", {})
if bool(result.get("is_correct", false)):
current_answer = answer
_update_question_elo_label(true)
_trigger_correct_feedback()
return
_lock_wrong_choice(attempted_choice)
@@ -361,6 +420,7 @@ func _show_explanation() -> void:
for index in range(question.get("choices", []).size()):
var choice: Dictionary = question["choices"][index]
var id := str(choice.get("id", ""))
_set_choice_button_text(index, choice)
if id == correct_id:
choice_buttons[index].modulate = Color(0.54, 1.0, 0.58)
elif id == selected_choice:
@@ -369,6 +429,7 @@ func _show_explanation() -> void:
var result_text := "Correct" if current_answer.get("is_correct", false) else "Incorrect"
explanation_label.text = "[b]%s[/b]\n%s" % [result_text, question.get("explanation", "")]
explanation_label.visible = true
_update_question_elo_label(true)
_refresh_nav_palette()
@@ -434,6 +495,8 @@ func _trigger_correct_feedback() -> void:
red_fog_time = 0.0
green_fog_time = FEEDBACK_FADE_SECONDS
next_button.disabled = true
if skip_button != null:
skip_button.disabled = true
for button in choice_buttons:
button.disabled = true
if feedback_fog != null:
@@ -462,6 +525,8 @@ func _update_answer_feedback(delta: float) -> void:
if correct_advance_delay <= 0.0:
pending_correct_advance = false
next_button.disabled = false
if skip_button != null:
skip_button.disabled = false
_advance_or_finish()
@@ -474,6 +539,8 @@ func _reset_wrong_feedback() -> void:
correct_advance_delay = 0.0
pending_correct_advance = false
next_button.disabled = false
if skip_button != null:
skip_button.disabled = false
if feedback_fog != null:
feedback_fog.color = Color(1.0, 0.0, 0.0, 0.0)
@@ -596,22 +663,38 @@ func _apply_saved_choice_state() -> void:
else:
choice_buttons[index].modulate = Color.WHITE
if pending_correct_advance:
_apply_correct_answer_preview()
func _set_choice_button_text(index: int, choice: Dictionary) -> void:
var choice_id := str(choice.get("id", ""))
var choice_text := str(choice.get("text", ""))
_set_choice_percent_label(index, "")
if locked_wrong_choice_ids.has(choice_id):
var text := "%s. %s" % [choice_id, _strike_text(choice_text)]
var percentage := _get_choice_selection_percentage(choice_id)
if percentage >= 0:
text += "\n %d%%" % percentage
choice_buttons[index].text = text
_set_choice_percent_label(index, "%d%%" % percentage)
choice_buttons[index].text = "%s. %s" % [choice_id, _strike_text(choice_text)]
elif _should_show_correct_choice_percentage(choice_id):
var correct_percentage := _get_choice_selection_percentage(choice_id)
if correct_percentage >= 0:
_set_choice_percent_label(index, "%d%%" % correct_percentage)
choice_buttons[index].text = "%s. %s" % [choice_id, choice_text]
elif eliminated.has(choice_id):
choice_buttons[index].text = "%s. [eliminated] %s" % [choice_id, _strike_text(choice_text)]
else:
choice_buttons[index].text = "%s. %s" % [choice_id, choice_text]
func _set_choice_percent_label(index: int, text: String) -> void:
if index < 0 or index >= choice_percent_labels.size():
return
var label := choice_percent_labels[index]
label.text = text
label.visible = text != ""
func _get_choice_selection_percentage(choice_id: String) -> int:
var question := BlockSessionService.get_current_question()
var percentages: Dictionary = question.get("choice_selection_percentages", {})
@@ -622,6 +705,21 @@ func _get_choice_selection_percentage(choice_id: String) -> int:
return clamp(int(percentages.get(choice_id, -1)), 0, 100)
func _should_show_correct_choice_percentage(choice_id: String) -> bool:
var question := BlockSessionService.get_current_question()
return choice_id == str(question.get("correct_choice_id", "")) and (pending_correct_advance or not current_answer.is_empty())
func _apply_correct_answer_preview() -> void:
var question := BlockSessionService.get_current_question()
var correct_id := str(question.get("correct_choice_id", ""))
var choices: Array = question.get("choices", [])
for index in range(choices.size()):
if str(choices[index].get("id", "")) == correct_id:
choice_buttons[index].modulate = Color(0.54, 1.0, 0.58)
return
func _on_choice_percentages_updated(question_id: String, percentages: Dictionary) -> void:
if not visible:
return
@@ -631,6 +729,37 @@ func _on_choice_percentages_updated(question_id: String, percentages: Dictionary
_apply_saved_choice_state()
func _on_question_rating_updated(question_id: String, question_elo: int, _rating: Dictionary) -> void:
if str(BlockSessionService.get_current_question().get("id", "")) != question_id:
return
BlockSessionService.update_question_elo(question_id, question_elo)
_update_question_elo_label(not current_answer.is_empty() or pending_correct_advance)
_apply_saved_choice_state()
func _on_question_skipped(question_id: String, correct_choice_id: String, question_elo: int, _rating: Dictionary) -> void:
if str(BlockSessionService.get_current_question().get("id", "")) != question_id:
return
var question := BlockSessionService.get_current_question()
if correct_choice_id != "":
question["correct_choice_id"] = correct_choice_id
BlockSessionService.update_question_elo(question_id, question_elo)
_update_question_elo_label(true)
_apply_saved_choice_state()
func _update_question_elo_label(show_label: bool) -> void:
if question_elo_label == null:
return
if not show_label:
question_elo_label.visible = false
return
var question := BlockSessionService.get_current_question()
var question_elo := int(question.get("question_elo", 1000))
question_elo_label.text = "Question ELO %d" % question_elo
question_elo_label.visible = true
func _strike_text(value: String) -> String:
var result := ""
for character in value:
+1 -2
View File
@@ -48,11 +48,10 @@ func _ready() -> void:
func on_show(payload: Variant = null) -> void:
var results: Dictionary = payload if typeof(payload) == TYPE_DICTIONARY else {}
var accuracy := int(round(float(results.get("accuracy", 0.0)) * 100.0))
summary_label.text = "%d/%d correct (%d%%) | +%d XP | Streak: %d | New badges: %s" % [
summary_label.text = "%d/%d correct (%d%%) | Streak: %d | New badges: %s" % [
results.get("correct", 0),
results.get("total", 0),
accuracy,
results.get("xp_earned", 0),
results.get("ending_streak", 0),
", ".join(results.get("badges_unlocked", [])) if results.get("badges_unlocked", []).size() > 0 else "None"
]
+1 -1
View File
@@ -17,7 +17,7 @@ func _ready() -> void:
add_child(title)
var help := Label.new()
help.text = "Choose a system zone. Strong scores unlock momentum badges and level progress."
help.text = "Choose a system zone. Strong scores raise your rating and build momentum."
help.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
Style.set_label(help, 16, Style.MUTED)
add_child(help)